Feat: Landingpages centralized, new app news integrated

This commit is contained in:
Till-JS 2025-11-25 18:20:17 +01:00
parent 36b85fc8a0
commit 865d74ff37
91 changed files with 8242 additions and 610 deletions

View file

@ -0,0 +1,80 @@
---
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="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
<span class="font-bold text-xl text-text-primary">ManaChat</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Dein intelligenter KI-Chat-Assistent. Chatte mit GPT-4o, GPT-4o-Mini und mehr -
alles in einer einfachen, eleganten Oberfläche.
</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} ManaChat. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">
Made with 💙 in Germany
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,86 @@
---
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="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
<span class="font-bold text-xl text-text-primary">ManaChat</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{navLinks.map(link => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))}
</div>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a
href="#download"
class="btn-primary text-sm px-4 py-2"
>
App herunterladen
</a>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{navLinks.map(link => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))}
</div>
</div>
</nav>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -1,28 +0,0 @@
---
interface Props {
title: string;
description?: string;
}
const { title, description = 'ManaChat - AI Chat Assistant' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-white dark:bg-gray-900">
<slot />
</body>
</html>
<style is:global>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

View file

@ -0,0 +1,47 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const {
title,
description = 'ManaChat - Dein intelligenter KI-Chat-Assistent mit GPT-4o und mehr'
} = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -1,100 +1,259 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; 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: 'Mehrere KI-Modelle',
description: 'Wähle zwischen GPT-4o, GPT-4o-Mini und weiteren leistungsstarken Modellen für deine Gespräche.'
},
{
icon: '💬',
title: 'Konversationen speichern',
description: 'Alle deine Chats werden sicher in der Cloud gespeichert und sind jederzeit abrufbar.'
},
{
icon: '📱',
title: 'Plattformübergreifend',
description: 'Nutze ManaChat auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
},
{
icon: '📝',
title: 'Dokument-Modus',
description: 'Arbeite mit der KI an längeren Texten im speziellen Dokument-Modus mit Versionierung.'
},
{
icon: '🎨',
title: 'Vorlagen',
description: 'Nutze vorgefertigte Vorlagen für häufige Aufgaben wie Texte schreiben, Code erklären oder Übersetzungen.'
},
{
icon: '🔒',
title: 'Privatsphäre',
description: 'Deine Daten sind sicher. Wir verkaufen keine Nutzerdaten und sind DSGVO-konform.'
}
];
// Steps data
const steps = [
{
number: '1',
title: 'App herunterladen',
description: 'Lade ManaChat kostenlos im App Store oder Google Play Store herunter.',
image: '/screenshots/download.png'
},
{
number: '2',
title: 'Konto erstellen',
description: 'Registriere dich in wenigen Sekunden mit E-Mail oder Google-Account.',
image: '/screenshots/register.png'
},
{
number: '3',
title: 'Loslegen',
description: 'Starte dein erstes Gespräch mit der KI - einfach und intuitiv.',
image: '/screenshots/chat.png'
}
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '50 Nachrichten/Tag', included: true },
{ text: 'GPT-4o-Mini Zugang', included: true },
{ text: 'Chat-Verlauf speichern', included: true },
{ text: 'Basis-Vorlagen', included: true },
{ text: 'GPT-4o Zugang', included: false },
{ text: 'Dokument-Modus', included: false }
],
cta: {
text: 'Kostenlos starten',
href: '#download'
}
},
{
name: 'Pro',
price: '9,99',
period: '/Monat',
description: 'Für Power-User',
features: [
{ text: 'Unbegrenzte Nachrichten', included: true },
{ text: 'Alle KI-Modelle', included: true },
{ text: 'Dokument-Modus', included: true },
{ text: 'Alle Vorlagen', included: true },
{ text: 'Prioritäts-Antworten', included: true },
{ text: 'Premium-Support', included: true }
],
cta: {
text: 'Pro werden',
href: '#download'
},
highlighted: true,
badge: 'Beliebt'
},
{
name: 'Team',
price: '24,99',
period: '/Monat',
description: 'Für Teams und Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'Geteilte Chats', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'API-Zugang', included: true },
{ text: 'Dedizierter Support', included: true }
],
cta: {
text: 'Team starten',
href: '#download'
}
}
];
// FAQ data
const faqs = [
{
question: 'Welche KI-Modelle sind verfügbar?',
answer: 'ManaChat bietet Zugang zu GPT-4o, GPT-4o-Mini und weiteren Modellen. Du kannst das Modell für jedes Gespräch individuell auswählen, je nach Komplexität deiner Anfrage.'
},
{
question: 'Wie sicher sind meine Daten?',
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Wir verkaufen keine Nutzerdaten an Dritte und sind vollständig DSGVO-konform. Du kannst deine Daten jederzeit exportieren oder löschen.'
},
{
question: 'Was ist der Dokument-Modus?',
answer: 'Im Dokument-Modus kannst du mit der KI an längeren Texten arbeiten. Die KI hilft dir beim Schreiben, Überarbeiten und Verbessern. Alle Änderungen werden versioniert, sodass du jederzeit frühere Versionen wiederherstellen kannst.'
},
{
question: 'Kann ich ManaChat offline nutzen?',
answer: 'Da ManaChat auf Cloud-KI-Modellen basiert, ist eine Internetverbindung erforderlich. Dein Chat-Verlauf wird jedoch lokal zwischengespeichert und synchronisiert, sobald du wieder online bist.'
},
{
question: 'Wie funktioniert die Synchronisierung?',
answer: 'Alle deine Chats werden automatisch in der Cloud gespeichert und sind auf allen deinen Geräten verfügbar. Melde dich einfach mit dem gleichen Account an und du hast sofort Zugriff auf alle Gespräche.'
},
{
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.'
}
];
--- ---
<BaseLayout title="ManaChat - AI Chat Assistant"> <Layout title="ManaChat - Dein intelligenter KI-Chat-Assistent">
<main class="min-h-screen"> <Navigation />
<!-- Hero Section -->
<section class="relative overflow-hidden bg-gradient-to-br from-blue-600 to-purple-700 py-20 text-white">
<div class="container mx-auto px-4 text-center">
<h1 class="mb-6 text-5xl font-bold md:text-6xl">
ManaChat
</h1>
<p class="mb-8 text-xl text-blue-100 md:text-2xl">
Dein intelligenter KI-Chat-Assistent
</p>
<p class="mx-auto mb-12 max-w-2xl text-lg text-blue-200">
Chatte mit den leistungsstärksten KI-Modellen. GPT-4o, GPT-4o-Mini und mehr -
alles in einer einfachen, eleganten Oberfläche.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#features"
class="rounded-full bg-white px-8 py-3 font-semibold text-blue-600 transition hover:bg-blue-50"
>
Mehr erfahren
</a>
<a
href="https://apps.apple.com"
class="rounded-full border-2 border-white px-8 py-3 font-semibold text-white transition hover:bg-white hover:text-blue-600"
>
App herunterladen
</a>
</div>
</div>
</section>
<!-- Features Section --> <main class="pt-16">
<section id="features" class="py-20"> <HeroSection
<div class="container mx-auto px-4"> title="Chatte mit den besten KI-Modellen"
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900 dark:text-white"> subtitle="ManaChat gibt dir Zugang zu GPT-4o, GPT-4o-Mini und mehr. Eine elegante App für intelligente Gespräche - auf allen deinen Geräten."
Funktionen variant="default"
</h2> primaryCta={{
<div class="grid gap-8 md:grid-cols-3"> text: 'Jetzt kostenlos starten',
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800"> href: '#download'
<div class="mb-4 text-4xl">🤖</div> }}
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white"> secondaryCta={{
Mehrere KI-Modelle text: 'Features entdecken',
</h3> href: '#features',
<p class="text-gray-600 dark:text-gray-300"> variant: 'secondary'
Wähle zwischen GPT-4o, GPT-4o-Mini und weiteren Modellen für deine Gespräche. }}
</p> trustBadges={[
</div> { icon: '✓', text: 'Kostenlos testen' },
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800"> { icon: '🔒', text: 'DSGVO-konform' },
<div class="mb-4 text-4xl">💬</div> { icon: '📱', text: 'iOS, Android & Web' }
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white"> ]}
Konversationen speichern />
</h3>
<p class="text-gray-600 dark:text-gray-300">
Alle deine Chats werden sicher gespeichert und sind jederzeit abrufbar.
</p>
</div>
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800">
<div class="mb-4 text-4xl">📱</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Plattformübergreifend
</h3>
<p class="text-gray-600 dark:text-gray-300">
Nutze ManaChat auf iOS, Android und im Web - deine Daten sind überall synchronisiert.
</p>
</div>
</div>
</div>
</section>
<!-- CTA Section --> <FeatureSection
<section class="bg-gray-100 py-20 dark:bg-gray-800"> id="features"
<div class="container mx-auto px-4 text-center"> title="Alles was du für KI-Chats brauchst"
<h2 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white"> subtitle="ManaChat kombiniert die besten KI-Modelle mit einer intuitiven Oberfläche für maximale Produktivität."
Bereit für intelligente Gespräche? features={features}
</h2> columns={3}
<p class="mb-8 text-lg text-gray-600 dark:text-gray-300"> variant="cards"
Starte jetzt kostenlos mit ManaChat. class="bg-[var(--color-background-card)]"
</p> />
<a
href="https://apps.apple.com" <StepsSection
class="inline-block rounded-full bg-blue-600 px-8 py-3 font-semibold text-white transition hover:bg-blue-700" id="how-it-works"
> title="In 3 Schritten loslegen"
Jetzt herunterladen subtitle="So einfach startest du mit ManaChat"
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 ManaChat wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Bereit für intelligente Gespräche?"
subtitle="Lade ManaChat jetzt herunter und starte dein erstes Gespräch mit GPT-4o. 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> </a>
</div> </div>
</section>
<!-- Footer --> <!-- Trust Indicators -->
<footer class="bg-gray-900 py-8 text-gray-400"> <div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="container mx-auto px-4 text-center"> <div class="flex items-center gap-2">
<p>&copy; 2024 ManaChat. Powered by Mana Core.</p> <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> </div>
</footer> </CTASection>
</main> </main>
</BaseLayout>
<Footer />
</Layout>

View file

@ -0,0 +1,103 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ManaChat Theme CSS Variables - Sky Blue */
:root {
/* Primary colors - ManaChat Sky Blue */
--color-primary: #0ea5e9;
--color-primary-hover: #38bdf8;
--color-primary-glow: rgba(14, 165, 233, 0.3);
/* Text colors */
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #6b7280;
/* Background colors */
--color-background-page: #0c1929;
--color-background-card: #142236;
--color-background-card-hover: #1e3a50;
/* Border colors */
--color-border: #1e3a50;
--color-border-hover: #2d5a73;
}
/* 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, #0ea5e9 0%, #38bdf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
/* Button styles */
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
@apply hover:border-border-hover hover:bg-background-card;
}

View file

@ -1,28 +1,39 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
// ManaChat Sky Blue Theme
primary: { primary: {
50: '#eff6ff', DEFAULT: '#0ea5e9',
100: '#dbeafe', hover: '#38bdf8',
200: '#bfdbfe', glow: 'rgba(14, 165, 233, 0.3)'
300: '#93c5fd',
400: '#60a5fa',
500: '#0A84FF',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}, },
secondary: { background: {
500: '#5E5CE6', page: '#0c1929',
card: '#142236',
'card-hover': '#1e3a50'
},
text: {
primary: '#f9fafb',
secondary: '#d1d5db',
muted: '#6b7280'
},
border: {
DEFAULT: '#1e3a50',
hover: '#2d5a73'
} }
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
} }
}, }
}, },
plugins: [ plugins: [
require('@tailwindcss/typography'), require('@tailwindcss/typography')
], ]
}; };

12
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
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/

File diff suppressed because it is too large Load diff

178
news/README.md Normal file
View file

@ -0,0 +1,178 @@
# News Hub
A unified news reading platform combining AI-curated news with personal article saving capabilities.
## Architecture
```
news/
├── apps/
│ ├── mobile/ # React Native/Expo App
│ └── api/ # NestJS Backend
├── packages/
│ ├── database/ # Drizzle ORM Schema
│ ├── shared/ # Shared utilities
│ └── browser-extension/ # Chrome Extension
└── docker/ # PostgreSQL Docker setup
```
## Tech Stack
| Component | Technology |
|-----------|------------|
| **Database** | PostgreSQL 16 (Docker) |
| **ORM** | Drizzle |
| **Backend** | NestJS + Fastify |
| **Auth** | Custom JWT Auth |
| **Mobile** | React Native / Expo |
| **State** | Zustand |
| **Styling** | NativeWind (Tailwind) |
| **Monorepo** | pnpm workspaces + Turborepo |
## Getting Started
### Prerequisites
- Node.js 20+
- pnpm 9+
- Docker Desktop
### Setup
```bash
# 1. Install dependencies
pnpm install
# 2. Start PostgreSQL
pnpm docker:up
# 3. Push database schema
pnpm db:push
# 4. Start API server
pnpm dev:api
# 5. Start mobile app (in another terminal)
pnpm dev:mobile
```
### Available Scripts
```bash
# Development
pnpm dev # Start all services
pnpm dev:api # Start API only
pnpm dev:mobile # Start mobile app only
# Database
pnpm db:push # Push schema to database
pnpm db:generate # Generate migrations
pnpm db:migrate # Run migrations
pnpm db:studio # Open Drizzle Studio
# Docker
pnpm docker:up # Start PostgreSQL
pnpm docker:down # Stop PostgreSQL
pnpm docker:logs # View logs
# Build
pnpm build # Build all packages
```
## Environment Variables
Create a `.env` file in the root directory:
```env
# Database
DATABASE_URL=postgresql://news:news_dev_password@localhost:5432/news_hub
# API
API_PORT=3000
API_URL=http://localhost:3000
# Better Auth Secret
BETTER_AUTH_SECRET=your-secret-key
# Mobile App
EXPO_PUBLIC_API_URL=http://localhost:3000
```
## Features
### News Feed (AI-Generated)
- **Feed**: Quick news updates with infinite scroll
- **Summaries**: 4 daily summaries (morning, noon, evening, night)
- **In-Depth**: Detailed analysis articles
### Personal Library (Read Later)
- Save articles from any URL
- Browser extension for one-click saving
- Content extraction with Readability
- Archive and organize articles
## API Endpoints
### Auth
- `POST /auth/signup` - Create account
- `POST /auth/signin` - Sign in
- `POST /auth/signout` - Sign out
- `GET /auth/session` - Get current session
### Articles
- `GET /articles` - Get AI articles (public)
- `GET /articles/:id` - Get single article
- `GET /articles/saved/list` - Get saved articles (auth required)
- `POST /articles/:id/archive` - Archive article
- `DELETE /articles/:id` - Delete article
### Content Extraction
- `POST /extract/save` - Save article from URL (auth required)
- `POST /extract/preview` - Preview URL extraction (public)
### Categories
- `GET /categories` - Get all categories
### Users
- `GET /users/me` - Get current user
- `PATCH /users/me` - Update profile
- `PATCH /users/me/onboarding` - Complete onboarding
## Browser Extension
The browser extension is located in `packages/browser-extension/`.
### Installation (Development)
1. Go to `chrome://extensions/`
2. Enable "Developer mode"
3. Click "Load unpacked"
4. Select the `packages/browser-extension` folder
## Database Schema
### Tables
- `users` - User accounts and preferences
- `articles` - All articles (AI-generated and user-saved)
- `categories` - Article categories
- `user_article_interactions` - Reading progress, ratings, bookmarks
- `sessions` - Auth sessions
- `accounts` - Auth providers
- `verifications` - Email verification tokens
## Development
### Adding a new API endpoint
1. Create service in `apps/api/src/{module}/{module}.service.ts`
2. Create controller in `apps/api/src/{module}/{module}.controller.ts`
3. Add module to `app.module.ts`
### Adding a new database table
1. Create schema in `packages/database/src/schema/{table}.ts`
2. Export from `packages/database/src/schema/index.ts`
3. Run `pnpm db:push` to update database
## License
Private

View file

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

View file

@ -0,0 +1,38 @@
{
"name": "@news/api",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
},
"dependencies": {
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-fastify": "^10.4.0",
"@manacore/news-database": "workspace:*",
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5",
"@mozilla/readability": "^0.5.0",
"jsdom": "^25.0.0",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.2.0",
"@types/jsdom": "^21.1.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}

View file

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './auth/auth.module';
import { ArticlesModule } from './articles/articles.module';
import { CategoriesModule } from './categories/categories.module';
import { UsersModule } from './users/users.module';
import { ContentExtractionModule } from './content-extraction/content-extraction.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../../.env',
}),
DatabaseModule,
AuthModule,
ArticlesModule,
CategoriesModule,
UsersModule,
ContentExtractionModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,81 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
@Controller('articles')
export class ArticlesController {
constructor(private articlesService: ArticlesService) {}
// Public: Get AI-generated articles
@Get()
async getArticles(
@Query('type') type?: 'feed' | 'summary' | 'in_depth',
@Query('categoryId') categoryId?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.articlesService.getAIArticles({
type,
categoryId,
limit: limit ? parseInt(limit, 10) : 20,
offset: offset ? parseInt(offset, 10) : 0,
});
}
// Public: Get single article
@Get(':id')
async getArticle(@Param('id') id: string) {
const article = await this.articlesService.getArticleById(id);
if (!article) {
return { error: 'Article not found' };
}
return article;
}
// Protected: Get user's saved articles
@Get('saved/list')
@UseGuards(AuthGuard)
async getSavedArticles(
@CurrentUser() user: User,
@Query('includeArchived') includeArchived?: string,
) {
return this.articlesService.getSavedArticles(
user.id,
includeArchived === 'true',
);
}
// Protected: Archive article
@Post(':id/archive')
@UseGuards(AuthGuard)
async archiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.archiveArticle(id, user.id);
return { success: true };
}
// Protected: Unarchive article
@Post(':id/unarchive')
@UseGuards(AuthGuard)
async unarchiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.unarchiveArticle(id, user.id);
return { success: true };
}
// Protected: Delete article
@Delete(':id')
@UseGuards(AuthGuard)
async deleteArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.deleteArticle(id, user.id);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
})
export class ArticlesModule {}

View file

@ -0,0 +1,147 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import {
Database,
articles,
Article,
eq,
and,
desc,
} from '@manacore/news-database';
@Injectable()
export class ArticlesService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
// Get AI-generated articles (feed, summary, in_depth)
async getAIArticles(options: {
type?: 'feed' | 'summary' | 'in_depth';
categoryId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
const { type, categoryId, limit = 20, offset = 0 } = options;
const conditions = [eq(articles.sourceOrigin, 'ai')];
if (type) {
conditions.push(eq(articles.type, type));
}
if (categoryId) {
conditions.push(eq(articles.categoryId, categoryId));
}
return this.database
.select()
.from(articles)
.where(and(...conditions))
.orderBy(desc(articles.publishedAt))
.limit(limit)
.offset(offset);
}
// Get user-saved articles
async getSavedArticles(
userId: string,
includeArchived = false,
): Promise<Article[]> {
const conditions = [
eq(articles.sourceOrigin, 'user_saved'),
eq(articles.userId, userId),
];
if (!includeArchived) {
conditions.push(eq(articles.isArchived, false));
}
return this.database
.select()
.from(articles)
.where(and(...conditions))
.orderBy(desc(articles.createdAt));
}
// Get single article by ID
async getArticleById(articleId: string): Promise<Article | null> {
const [article] = await this.database
.select()
.from(articles)
.where(eq(articles.id, articleId))
.limit(1);
return article || null;
}
// Create a saved article
async createSavedArticle(data: {
userId: string;
title: string;
content: string;
parsedContent: string;
originalUrl: string;
author?: string;
imageUrl?: string;
}): Promise<Article> {
const wordCount = data.content.split(/\s+/).length;
const readingTimeMinutes = Math.ceil(wordCount / 200);
const [article] = await this.database
.insert(articles)
.values({
type: 'saved',
sourceOrigin: 'user_saved',
userId: data.userId,
title: data.title,
content: data.content,
parsedContent: data.parsedContent,
originalUrl: data.originalUrl,
author: data.author,
imageUrl: data.imageUrl,
wordCount,
readingTimeMinutes,
isArchived: false,
})
.returning();
return article;
}
// Archive an article
async archiveArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.update(articles)
.set({ isArchived: true, updatedAt: new Date() })
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
// Unarchive an article
async unarchiveArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.update(articles)
.set({ isArchived: false, updatedAt: new Date() })
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
// Delete an article
async deleteArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.delete(articles)
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
}

View file

@ -0,0 +1,88 @@
import { Controller, Post, Get, Body, Headers, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
class SignUpDto {
@IsEmail()
email: string;
@IsString()
@MinLength(6)
password: string;
@IsOptional()
@IsString()
name?: string;
}
class SignInDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('signup')
async signUp(@Body() body: SignUpDto) {
const result = await this.authService.signUp(body.email, body.password, body.name);
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
token: result.token,
};
}
@Post('signin')
async signIn(@Body() body: SignInDto) {
const result = await this.authService.signIn(body.email, body.password);
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
token: result.token,
};
}
@Post('signout')
async signOut(@Headers('authorization') authHeader: string) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
await this.authService.signOut(token);
return { success: true };
}
@Get('session')
async getSession(@Headers('authorization') authHeader: string) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
const session = await this.authService.getSession(token);
if (!session) {
throw new UnauthorizedException('Invalid or expired session');
}
return {
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
};
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -0,0 +1,159 @@
import { Injectable, Inject, UnauthorizedException, ConflictException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DATABASE_CONNECTION } from '../database/database.module';
import {
Database,
users,
sessions,
accounts,
eq,
and,
User,
} from '@manacore/news-database';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
constructor(
@Inject(DATABASE_CONNECTION) private database: Database,
private configService: ConfigService,
) {}
private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return `${salt}:${hash}`;
}
private verifyPassword(password: string, storedHash: string): boolean {
const [salt, hash] = storedHash.split(':');
const verifyHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return hash === verifyHash;
}
private generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
async signUp(email: string, password: string, name?: string): Promise<{ user: User; token: string }> {
// Check if user exists
const existingUser = await this.database
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existingUser.length > 0) {
throw new ConflictException('User already exists');
}
// Create user
const [user] = await this.database
.insert(users)
.values({
email: email.toLowerCase(),
name: name || null,
})
.returning();
// Create account with password
await this.database.insert(accounts).values({
userId: user.id,
providerId: 'credential',
accountId: email.toLowerCase(),
password: this.hashPassword(password),
});
// Create session
const token = this.generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await this.database.insert(sessions).values({
userId: user.id,
token,
expiresAt,
});
return { user, token };
}
async signIn(email: string, password: string): Promise<{ user: User; token: string }> {
// Find user
const [user] = await this.database
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Find account
const [account] = await this.database
.select()
.from(accounts)
.where(
and(
eq(accounts.userId, user.id),
eq(accounts.providerId, 'credential'),
),
)
.limit(1);
if (!account || !account.password) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
if (!this.verifyPassword(password, account.password)) {
throw new UnauthorizedException('Invalid credentials');
}
// Create session
const token = this.generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await this.database.insert(sessions).values({
userId: user.id,
token,
expiresAt,
});
return { user, token };
}
async signOut(token: string): Promise<void> {
await this.database.delete(sessions).where(eq(sessions.token, token));
}
async validateSession(token: string): Promise<{ user: User; session: any } | null> {
const [session] = await this.database
.select()
.from(sessions)
.where(eq(sessions.token, token))
.limit(1);
if (!session || new Date(session.expiresAt) < new Date()) {
return null;
}
const [user] = await this.database
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
if (!user) {
return null;
}
return { user, session };
}
async getSession(token: string): Promise<{ user: User } | null> {
const result = await this.validateSession(token);
if (!result) return null;
return { user: result.user };
}
}

View file

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private categoriesService: CategoriesService) {}
@Get()
async getAllCategories() {
return this.categoriesService.getAllCategories();
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View file

@ -0,0 +1,31 @@
import { Injectable, Inject } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import { Database, categories, Category, asc } from '@manacore/news-database';
@Injectable()
export class CategoriesService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
async getAllCategories(): Promise<Category[]> {
return this.database
.select()
.from(categories)
.orderBy(asc(categories.priority));
}
async createCategory(data: {
name: string;
displayName: string;
description?: string;
icon?: string;
color?: string;
priority?: number;
}): Promise<Category> {
const [category] = await this.database
.insert(categories)
.values(data)
.returning();
return category;
}
}

View file

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../../auth/auth.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
try {
const session = await this.authService.validateSession(token);
if (!session) {
throw new UnauthorizedException('Invalid or expired session');
}
request.user = session.user;
request.session = session;
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

@ -0,0 +1,51 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ContentExtractionService } from './content-extraction.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
import { IsUrl } from 'class-validator';
class ExtractUrlDto {
@IsUrl()
url: string;
}
@Controller('extract')
export class ContentExtractionController {
constructor(private contentExtractionService: ContentExtractionService) {}
// Protected: Save article from URL
@Post('save')
@UseGuards(AuthGuard)
async saveFromUrl(@Body() body: ExtractUrlDto, @CurrentUser() user: User) {
const article = await this.contentExtractionService.saveArticleFromUrl(
user.id,
body.url,
);
return {
success: true,
article: {
id: article.id,
title: article.title,
createdAt: article.createdAt,
},
};
}
// Public: Preview URL extraction (without saving)
@Post('preview')
async previewUrl(@Body() body: ExtractUrlDto) {
const extracted = await this.contentExtractionService.extractFromUrl(
body.url,
);
return {
title: extracted.title,
excerpt: extracted.excerpt,
byline: extracted.byline,
siteName: extracted.siteName,
contentLength: extracted.content.length,
};
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ContentExtractionController } from './content-extraction.controller';
import { ContentExtractionService } from './content-extraction.service';
import { ArticlesModule } from '../articles/articles.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [ArticlesModule, AuthModule],
controllers: [ContentExtractionController],
providers: [ContentExtractionService],
exports: [ContentExtractionService],
})
export class ContentExtractionModule {}

View file

@ -0,0 +1,79 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { ArticlesService } from '../articles/articles.service';
export interface ExtractedContent {
title: string;
content: string;
htmlContent: string;
excerpt?: string;
byline?: string;
siteName?: string;
}
@Injectable()
export class ContentExtractionService {
constructor(private articlesService: ArticlesService) {}
async extractFromUrl(url: string): Promise<ExtractedContent> {
// Validate URL
try {
new URL(url);
} catch {
throw new BadRequestException('Invalid URL');
}
// Fetch the page
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
},
});
if (!response.ok) {
throw new BadRequestException(
`Failed to fetch URL: ${response.status} ${response.statusText}`,
);
}
const html = await response.text();
// Parse with JSDOM
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article) {
throw new BadRequestException(
'Could not extract article content from this page',
);
}
return {
title: article.title || 'Untitled',
content: article.textContent || '',
htmlContent: article.content || '',
excerpt: article.excerpt,
byline: article.byline,
siteName: article.siteName,
};
}
async saveArticleFromUrl(userId: string, url: string) {
const extracted = await this.extractFromUrl(url);
return this.articlesService.createSavedArticle({
userId,
title: extracted.title,
content: extracted.content,
parsedContent: extracted.htmlContent,
originalUrl: url,
author: extracted.byline,
});
}
}

View file

@ -0,0 +1,25 @@
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createDb } from '@manacore/news-database';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const databaseUrl = configService.get<string>('DATABASE_URL') ||
'postgresql://news:news_dev_password@localhost:5434/news_hub';
console.log('Connecting to database:', databaseUrl.replace(/:[^:@]+@/, ':****@'));
return createDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

37
news/apps/api/src/main.ts Normal file
View file

@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);
app.enableCors({
origin: [
'http://localhost:8081', // Expo web
'http://localhost:19006', // Expo web alt
'http://localhost:3000', // API itself (for testing)
/^exp:\/\/.*/, // Expo Go
],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
const port = process.env.API_PORT || 3000;
await app.listen(port, '0.0.0.0');
console.log(`API running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,59 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
import { IsOptional, IsString, IsArray, IsEnum, IsBoolean } from 'class-validator';
class UpdateUserDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsArray()
preferredCategories?: string[];
@IsOptional()
@IsArray()
blockedSources?: string[];
@IsOptional()
@IsEnum(['slow', 'normal', 'fast'])
readingSpeed?: 'slow' | 'normal' | 'fast';
@IsOptional()
@IsString()
notificationSettings?: string;
@IsOptional()
@IsBoolean()
onboardingCompleted?: boolean;
}
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get('me')
@UseGuards(AuthGuard)
async getCurrentUser(@CurrentUser() user: User) {
return this.usersService.getUserById(user.id);
}
@Patch('me')
@UseGuards(AuthGuard)
async updateCurrentUser(
@CurrentUser() user: User,
@Body() body: UpdateUserDto,
) {
return this.usersService.updateUser(user.id, body);
}
@Patch('me/onboarding')
@UseGuards(AuthGuard)
async completeOnboarding(@CurrentUser() user: User) {
await this.usersService.completeOnboarding(user.id);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import { Database, users, User, eq } from '@manacore/news-database';
@Injectable()
export class UsersService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
async getUserById(userId: string): Promise<User | null> {
const [user] = await this.database
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user || null;
}
async updateUser(
userId: string,
data: {
name?: string;
preferredCategories?: string[];
blockedSources?: string[];
readingSpeed?: 'slow' | 'normal' | 'fast';
notificationSettings?: string;
onboardingCompleted?: boolean;
},
): Promise<User> {
const [user] = await this.database
.update(users)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(users.id, userId))
.returning();
return user;
}
async completeOnboarding(userId: string): Promise<void> {
await this.database
.update(users)
.set({
onboardingCompleted: true,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,11 @@
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,80 @@
---
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" />
</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,86 @@
---
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" />
</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,47 @@
---
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,259 @@
---
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,39 @@
/** @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,2 @@
# News Hub Web App Configuration
PUBLIC_NEWS_API_URL=http://localhost:3000

View file

@ -0,0 +1,41 @@
{
"name": "@news/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
},
"dependencies": {
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"marked": "^17.0.0"
}
}

View file

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

33
news/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
interface NewsUser {
id: string;
email: string;
name?: string;
createdAt: string;
}
interface NewsSession {
token: string;
userId: string;
expiresAt: string;
}
declare global {
namespace App {
// interface Error {}
interface Locals {
session: NewsSession | null;
user: NewsUser | null;
}
interface PageData {
session: NewsSession | null;
user: NewsUser | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -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,95 @@
import { env } from '$env/dynamic/public';
const API_URL = env.PUBLIC_NEWS_API_URL || 'http://localhost:3000';
interface ApiResponse<T> {
data?: T;
error?: string;
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {},
token?: string
): Promise<ApiResponse<T>> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
return { error: errorText || `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
// Auth endpoints
export const authApi = {
login: (email: string, password: string) =>
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
signup: (email: string, password: string, name?: string) =>
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/signup', {
method: 'POST',
body: JSON.stringify({ email, password, name }),
}),
logout: (token: string) =>
apiRequest('/auth/logout', { method: 'POST' }, token),
me: (token: string) =>
apiRequest<App.Locals['user']>('/auth/me', {}, token),
};
// Articles endpoints
export const articlesApi = {
getArticles: (params?: { type?: string; categoryId?: string; limit?: number; offset?: number }, token?: string) => {
const searchParams = new URLSearchParams();
if (params?.type) searchParams.set('type', params.type);
if (params?.categoryId) searchParams.set('categoryId', params.categoryId);
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const query = searchParams.toString();
return apiRequest<any[]>(`/articles${query ? `?${query}` : ''}`, {}, token);
},
getArticle: (id: string, token?: string) =>
apiRequest<any>(`/articles/${id}`, {}, token),
getSavedArticles: (token: string) =>
apiRequest<any[]>('/articles/saved/list', {}, token),
archiveArticle: (id: string, token: string) =>
apiRequest(`/articles/${id}/archive`, { method: 'POST' }, token),
unarchiveArticle: (id: string, token: string) =>
apiRequest(`/articles/${id}/unarchive`, { method: 'POST' }, token),
deleteArticle: (id: string, token: string) =>
apiRequest(`/articles/${id}`, { method: 'DELETE' }, token),
};
// Categories endpoints
export const categoriesApi = {
getCategories: (token?: string) =>
apiRequest<any[]>('/categories', {}, token),
};

View file

@ -0,0 +1,81 @@
import { authApi } from '$lib/services/api';
class AuthStore {
user = $state<App.Locals['user']>(null);
session = $state<App.Locals['session']>(null);
loading = $state(false);
error = $state<string | null>(null);
get isAuthenticated() {
return !!this.session && !!this.user;
}
async login(email: string, password: string) {
this.loading = true;
this.error = null;
const { data, error } = await authApi.login(email, password);
if (error) {
this.error = error;
this.loading = false;
return false;
}
if (data) {
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
this.user = data.user;
// Store token in cookie/localStorage
if (typeof window !== 'undefined') {
document.cookie = `news_session=${data.token}; path=/; max-age=604800`; // 7 days
}
}
this.loading = false;
return true;
}
async signup(email: string, password: string, name?: string) {
this.loading = true;
this.error = null;
const { data, error } = await authApi.signup(email, password, name);
if (error) {
this.error = error;
this.loading = false;
return false;
}
if (data) {
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
this.user = data.user;
if (typeof window !== 'undefined') {
document.cookie = `news_session=${data.token}; path=/; max-age=604800`;
}
}
this.loading = false;
return true;
}
async logout() {
if (this.session?.token) {
await authApi.logout(this.session.token);
}
this.session = null;
this.user = null;
if (typeof window !== 'undefined') {
document.cookie = 'news_session=; path=/; max-age=0';
}
}
setSession(session: App.Locals['session'], user: App.Locals['user']) {
this.session = session;
this.user = user;
}
}
export const authStore = new AuthStore();

View file

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

View file

@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { articlesApi } from '$lib/services/api';
import { authStore } from '$lib/stores/auth.svelte';
let articles = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const { data, error: apiError } = await articlesApi.getArticles(
{ type: 'feed', limit: 20 },
authStore.session?.token
);
if (apiError) {
error = apiError;
} else if (data) {
articles = data;
}
loading = false;
});
</script>
<svelte:head>
<title>Feed - News Hub</title>
</svelte:head>
<div class="p-6">
<header class="mb-6">
<h1 class="text-2xl font-bold">Feed</h1>
<p class="text-text-secondary mt-1">Aktuelle Nachrichten im Überblick</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
{:else if error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{error}
</div>
{:else if articles.length === 0}
<div class="text-center py-12">
<svg class="w-16 h-16 text-text-muted mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
<p class="text-text-secondary">Noch keine Artikel vorhanden</p>
<p class="text-text-muted text-sm mt-1">Artikel werden automatisch generiert und erscheinen hier</p>
</div>
{:else}
<div class="space-y-4">
{#each articles as article}
<article class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors">
<a href="/article/{article.id}" class="block">
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
{article.title}
</h2>
{#if article.summary}
<p class="text-text-secondary mt-2 line-clamp-2">
{article.summary}
</p>
{/if}
<div class="flex items-center gap-4 mt-3 text-sm text-text-muted">
{#if article.category}
<span class="px-2 py-1 bg-primary/10 text-primary rounded">
{article.category.name}
</span>
{/if}
{#if article.createdAt}
<span>{new Date(article.createdAt).toLocaleDateString('de-DE')}</span>
{/if}
</div>
</a>
</article>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte';
import { articlesApi } from '$lib/services/api';
import { authStore } from '$lib/stores/auth.svelte';
let articles = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const { data, error: apiError } = await articlesApi.getArticles(
{ type: 'in_depth', limit: 20 },
authStore.session?.token
);
if (apiError) {
error = apiError;
} else if (data) {
articles = data;
}
loading = false;
});
</script>
<svelte:head>
<title>In-Depth - News Hub</title>
</svelte:head>
<div class="p-6">
<header class="mb-6">
<h1 class="text-2xl font-bold">In-Depth Artikel</h1>
<p class="text-text-secondary mt-1">Ausführliche Analysen zu wichtigen Themen</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
{:else if error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{error}
</div>
{:else if articles.length === 0}
<div class="text-center py-12">
<p class="text-text-secondary">Noch keine In-Depth Artikel vorhanden</p>
</div>
{:else}
<div class="space-y-6">
{#each articles as article}
<article class="p-6 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors">
<a href="/article/{article.id}" class="block">
<h2 class="font-bold text-xl hover:text-primary transition-colors">
{article.title}
</h2>
{#if article.summary}
<p class="text-text-secondary mt-3">
{article.summary}
</p>
{/if}
<div class="flex items-center gap-4 mt-4 text-sm text-text-muted">
<span>5-15 Min. Lesezeit</span>
</div>
</a>
</article>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from 'svelte';
import { articlesApi } from '$lib/services/api';
import { authStore } from '$lib/stores/auth.svelte';
let articles = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
if (!authStore.session?.token) {
error = 'Nicht angemeldet';
loading = false;
return;
}
const { data, error: apiError } = await articlesApi.getSavedArticles(authStore.session.token);
if (apiError) {
error = apiError;
} else if (data) {
articles = data;
}
loading = false;
});
</script>
<svelte:head>
<title>Gespeicherte Artikel - News Hub</title>
</svelte:head>
<div class="p-6">
<header class="mb-6">
<h1 class="text-2xl font-bold">Gespeicherte Artikel</h1>
<p class="text-text-secondary mt-1">Deine gespeicherten Artikel zum späteren Lesen</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
{:else if error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{error}
</div>
{:else if articles.length === 0}
<div class="text-center py-12">
<svg class="w-16 h-16 text-text-muted mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<p class="text-text-secondary">Noch keine Artikel gespeichert</p>
<p class="text-text-muted text-sm mt-1">Speichere Artikel mit der Browser-Extension oder aus dem Feed</p>
</div>
{:else}
<div class="space-y-4">
{#each articles as article}
<article class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors">
<a href="/article/{article.id}" class="block">
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
{article.title}
</h2>
{#if article.summary}
<p class="text-text-secondary mt-2 line-clamp-2">
{article.summary}
</p>
{/if}
</a>
</article>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { onMount } from 'svelte';
import { articlesApi } from '$lib/services/api';
import { authStore } from '$lib/stores/auth.svelte';
let articles = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const { data, error: apiError } = await articlesApi.getArticles(
{ type: 'summary', limit: 20 },
authStore.session?.token
);
if (apiError) {
error = apiError;
} else if (data) {
articles = data;
}
loading = false;
});
</script>
<svelte:head>
<title>Zusammenfassungen - News Hub</title>
</svelte:head>
<div class="p-6">
<header class="mb-6">
<h1 class="text-2xl font-bold">Tägliche Zusammenfassungen</h1>
<p class="text-text-secondary mt-1">Die wichtigsten Nachrichten des Tages kompakt zusammengefasst</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
{:else if error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{error}
</div>
{:else if articles.length === 0}
<div class="text-center py-12">
<p class="text-text-secondary">Noch keine Zusammenfassungen vorhanden</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2">
{#each articles as article}
<article class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors">
<a href="/article/{article.id}" class="block">
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
{article.title}
</h2>
{#if article.summary}
<p class="text-text-secondary mt-2 line-clamp-3">
{article.summary}
</p>
{/if}
</a>
</article>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import '../app.css';
import { authStore } from '$lib/stores/auth.svelte';
let { children, data } = $props();
// Initialize auth store with server data
$effect(() => {
if (data.session && data.user) {
authStore.setSession(data.session, data.user);
}
});
</script>
<div class="min-h-screen bg-background-page text-text-primary">
{@render children()}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
onMount(() => {
if (authStore.isAuthenticated) {
goto('/feed');
} else {
goto('/auth/login');
}
});
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="animate-pulse">
<svg class="w-12 h-12 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
</svg>
</div>
</div>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
async function handleSubmit(e: Event) {
e.preventDefault();
const success = await authStore.login(email, password);
if (success) {
goto('/feed');
}
}
</script>
<svelte:head>
<title>Login - News Hub</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<svg class="w-12 h-12 text-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
</svg>
<h1 class="text-2xl font-bold">News Hub</h1>
<p class="text-text-secondary mt-2">Anmelden</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
{#if authStore.error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{authStore.error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
<input
type="password"
id="password"
bind:value={password}
required
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={authStore.loading}
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
>
{authStore.loading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<p class="text-center text-text-secondary mt-6">
Noch kein Konto?
<a href="/auth/register" class="text-primary hover:underline">Registrieren</a>
</p>
</div>
</div>

View file

@ -0,0 +1,89 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let name = $state('');
async function handleSubmit(e: Event) {
e.preventDefault();
const success = await authStore.signup(email, password, name || undefined);
if (success) {
goto('/feed');
}
}
</script>
<svelte:head>
<title>Registrieren - News Hub</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<svg class="w-12 h-12 text-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
</svg>
<h1 class="text-2xl font-bold">News Hub</h1>
<p class="text-text-secondary mt-2">Konto erstellen</p>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
{#if authStore.error}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{authStore.error}
</div>
{/if}
<div>
<label for="name" class="block text-sm font-medium mb-2">Name (optional)</label>
<input
type="text"
id="name"
bind:value={name}
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
placeholder="Max Mustermann"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
<input
type="password"
id="password"
bind:value={password}
required
minlength="8"
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={authStore.loading}
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
>
{authStore.loading ? 'Wird registriert...' : 'Registrieren'}
</button>
</form>
<p class="text-center text-text-secondary mt-6">
Bereits ein Konto?
<a href="/auth/login" class="text-primary hover:underline">Anmelden</a>
</p>
</div>
</div>

View file

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

View file

@ -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,25 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: [
'marked',
'@manacore/shared-theme',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui'
]
},
optimizeDeps: {
exclude: [
'@manacore/shared-theme',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui'
]
}
});

View file

@ -0,0 +1,36 @@
services:
postgres:
image: postgres:16-alpine
container_name: news-hub-db
restart: unless-stopped
environment:
POSTGRES_USER: news
POSTGRES_PASSWORD: news_dev_password
POSTGRES_DB: news_hub
ports:
- "5434:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U news -d news_hub"]
interval: 5s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: news-hub-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:

6
news/docker/init.sql Normal file
View file

@ -0,0 +1,6 @@
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grants
GRANT ALL PRIVILEGES ON DATABASE news_hub TO news;

View file

@ -0,0 +1,125 @@
# Kokon Browser Extension
Eine Chrome/Firefox Browser-Erweiterung für die Kokon Read-Later App.
## Features
- **Ein-Klick Speichern**: Speichere jeden Artikel mit einem Klick
- **Automatische Content-Extraktion**: Nutzt die gleiche Mozilla Readability Engine wie die App
- **Session-Synchronisation**: Automatische Anmeldeerkennung mit der Web-App
- **Elegantes Design**: Moderne, responsive Benutzeroberfläche
- **Fehlerbehandlung**: Intelligente Fehlerbehandlung und Benutzerführung
## Installation (Development)
### Chrome/Edge
1. Öffne `chrome://extensions/`
2. Aktiviere "Entwicklermodus" (Developer mode)
3. Klicke "Ungepackte Erweiterung laden" (Load unpacked)
4. Wähle den `browser-extension` Ordner aus
### Firefox
1. Öffne `about:debugging`
2. Klicke "Dieses Firefox" (This Firefox)
3. Klicke "Temporäres Add-on laden" (Load Temporary Add-on)
4. Wähle die `manifest.json` Datei aus
## Verwendung
1. **Erste Einrichtung**:
- Installiere die Erweiterung
- Logge dich in der Kokon Web-App ein (wird automatisch geöffnet)
2. **Artikel speichern**:
- Navigiere zu einem beliebigen Artikel im Web
- Klicke auf das Kokon-Symbol in der Browser-Toolbar
- Klicke "Save Article"
- Der Artikel wird automatisch verarbeitet und in deiner Kokon-Liste gespeichert
## Technische Details
### Architektur
- **Manifest V3**: Moderne Chrome Extension API
- **Service Worker**: Background-Verarbeitung für Session-Management
- **Popup Interface**: Elegant gestaltetes Popup mit Echtzeit-Feedback
- **Chrome Storage API**: Synchronisation mit Web-App-Sessions
### Sicherheit
- **Minimale Berechtigungen**: Nur `activeTab` und `storage`
- **HTTPS Only**: Sichere Kommunikation mit Supabase
- **Token-basierte Auth**: Nutzt bestehende Supabase-Session
- **Domain-Validierung**: Verhindert Speichern von Browser-internen Seiten
### Integration
- Nutzt die gleiche `save-article` Edge Function wie die App
- Teilt sich die Session mit der Web-App über Chrome Storage
- Automatische Token-Erneuerung und Logout-Erkennung
## Datei-Struktur
```
browser-extension/
├── manifest.json # Extension-Konfiguration (Manifest V3)
├── popup.html # Popup-Interface HTML
├── popup.js # Popup-Logik und API-Calls
├── background.js # Service Worker für Background-Tasks
├── icons/ # Extension-Icons (TODO: Icons hinzufügen)
│ ├── icon-16.png
│ ├── icon-32.png
│ ├── icon-48.png
│ └── icon-128.png
└── README.md # Diese Datei
```
## TODO: Icons
Die Extension benötigt noch Icons in verschiedenen Größen:
- 16x16px (Toolbar)
- 32x32px (Extension-Management)
- 48x48px (Extension-Management)
- 128x128px (Chrome Web Store)
Icons sollten das Kokon-Logo (🥥) oder ein ähnliches Design verwenden.
## Chrome Web Store Deployment
Für die Veröffentlichung im Chrome Web Store:
1. **Icons hinzufügen** (siehe TODO oben)
2. **Version bumpen** in `manifest.json`
3. **Extension packen**:
```bash
zip -r kokon-extension.zip browser-extension/
```
4. **Chrome Developer Dashboard**: Upload auf [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
## Firefox Add-ons Deployment
Für Mozilla Add-ons:
1. **Firefox-spezifische Anpassungen** (falls nötig)
2. **Signierung** über [Mozilla Add-on Developer Hub](https://addons.mozilla.org/developers/)
## Entwicklung
### Testing
1. Lade die Extension im Entwicklermodus
2. Öffne eine beliebige Webseite
3. Teste das Popup und die Save-Funktionalität
4. Überprüfe die Browser-Konsole für Fehler
### Debugging
- **Popup debuggen**: Rechtsklick auf Extension-Icon → "Inspect popup"
- **Background Script**: In `chrome://extensions/` → "Inspect views: background page"
- **Storage prüfen**: Chrome DevTools → Application → Storage → Extension
## Kompatibilität
- **Chrome**: Version 88+ (Manifest V3 Support)
- **Edge**: Version 88+ (Chromium-basiert)
- **Firefox**: Version 109+ (Manifest V3 Support)
- **Safari**: Benötigt Anpassungen für Safari Web Extensions
## Lizenz
Teil des Kokon-Projekts - siehe Haupt-Repository für Lizenzdetails.

View file

@ -0,0 +1,64 @@
// Background service worker for Kokon Browser Extension
// Installation handler
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('Kokon extension installed');
// Optionally open the web app on first install
chrome.tabs.create({
url: 'http://localhost:8081' // Local Expo web development server
});
}
});
// Handle extension icon click (this is mainly handled by the popup, but kept for completeness)
chrome.action.onClicked.addListener((tab) => {
// This won't fire if popup.html is defined in manifest, but keeping for fallback
console.log('Extension icon clicked for tab:', tab.url);
});
// Listen for messages from content scripts (if needed in the future)
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('Background received message:', request);
// Handle any background tasks here
if (request.action === 'saveArticle') {
// This could be used for context menu integration in the future
console.log('Save article request for:', request.url);
}
return true; // Keep message channel open for async response
});
// Sync storage with web app (for session management)
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local') {
console.log('Storage changed:', changes);
// Monitor auth state changes
if (changes['supabase.auth.token']) {
const newToken = changes['supabase.auth.token'].newValue;
if (newToken) {
console.log('User logged in');
// Could update badge or perform other actions
} else {
console.log('User logged out');
}
}
}
});
// Handle context menu (optional future feature)
// chrome.contextMenus.create({
// id: "saveToKokon",
// title: "Save to Kokon",
// contexts: ["page", "link"]
// });
// chrome.contextMenus.onClicked.addListener((info, tab) => {
// if (info.menuItemId === "saveToKokon") {
// const url = info.linkUrl || tab.url;
// // Handle saving the article
// }
// });

View file

@ -0,0 +1,85 @@
// Content script to sync localStorage with Chrome storage
console.log('🥥 Kokon content script loaded on:', window.location.href);
// Function to sync localStorage to Chrome storage
function syncToChrome(key, value) {
if (chrome && chrome.storage) {
chrome.storage.local.set({ [key]: value }).then(() => {
console.log('Content script: Successfully synced to Chrome storage:', key);
}).catch((error) => {
console.error('Content script: Failed to sync to Chrome storage:', error);
});
}
}
// Function to sync removal from localStorage to Chrome storage
function removeFromChrome(key) {
if (chrome && chrome.storage) {
chrome.storage.local.remove([key]).then(() => {
console.log('Content script: Successfully removed from Chrome storage:', key);
}).catch((error) => {
console.error('Content script: Failed to remove from Chrome storage:', error);
});
}
}
// Listen for localStorage changes and sync to Chrome storage
function setupStorageSync() {
console.log('🥥 Setting up storage sync...');
// The actual Supabase auth token key
const SUPABASE_AUTH_KEY = 'sb-hepsjdbvpkumaoabbycd-auth-token';
// Override localStorage.setItem to sync
const originalSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
console.log('🥥 localStorage.setItem called:', key);
originalSetItem.call(this, key, value);
if (key === SUPABASE_AUTH_KEY) {
console.log('🥥 Detected supabase token change, syncing...');
// Store with standardized key for extension
syncToChrome('supabase.auth.token', value);
}
};
// Override localStorage.removeItem to sync
const originalRemoveItem = localStorage.removeItem;
localStorage.removeItem = function(key) {
console.log('🥥 localStorage.removeItem called:', key);
originalRemoveItem.call(this, key);
if (key === SUPABASE_AUTH_KEY) {
console.log('🥥 Detected supabase token removal, syncing...');
removeFromChrome('supabase.auth.token');
}
};
// Check for existing token on page load
const existingToken = localStorage.getItem(SUPABASE_AUTH_KEY);
console.log('🥥 Checking for existing token:', existingToken ? 'Found' : 'Not found');
if (existingToken) {
console.log('🥥 Found existing token, syncing...');
syncToChrome('supabase.auth.token', existingToken);
}
// Also check all localStorage keys
console.log('🥥 All localStorage keys:', Object.keys(localStorage));
}
// Set up the sync when the page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupStorageSync);
} else {
setupStorageSync();
}
// Also listen for storage events (in case other tabs make changes)
window.addEventListener('storage', (e) => {
if (e.key === 'sb-hepsjdbvpkumaoabbycd-auth-token') {
console.log('🥥 Storage event detected for supabase token');
if (e.newValue) {
syncToChrome('supabase.auth.token', e.newValue);
} else {
removeFromChrome('supabase.auth.token');
}
}
});

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>Debug Extension Storage</title>
<style>
body { font-family: Arial; padding: 20px; }
.section { margin: 20px 0; }
button { padding: 10px; margin: 5px; }
pre { background: #f5f5f5; padding: 10px; border-radius: 5px; max-height: 400px; overflow-y: auto; }
</style>
</head>
<body>
<h1>Kokon Extension Debug</h1>
<div class="section">
<button id="checkBtn">Check Chrome Storage</button>
<button id="clearBtn">Clear Chrome Storage</button>
<button id="testBtn">Set Test Data</button>
</div>
<div class="section">
<h3>Chrome Storage Contents:</h3>
<pre id="storageContent">Loading...</pre>
</div>
<script src="debug.js"></script>
</body>
</html>

View file

@ -0,0 +1,51 @@
// Debug script for Extension Storage
async function checkStorage() {
try {
const result = await chrome.storage.local.get(null);
document.getElementById('storageContent').textContent = JSON.stringify(result, null, 2);
console.log('Chrome Storage contents:', result);
} catch (error) {
document.getElementById('storageContent').textContent = 'Error: ' + error.message;
console.error('Error checking storage:', error);
}
}
async function clearStorage() {
try {
await chrome.storage.local.clear();
document.getElementById('storageContent').textContent = 'Storage cleared';
console.log('Chrome Storage cleared');
} catch (error) {
console.error('Error clearing storage:', error);
}
}
async function setTestData() {
try {
const testSession = {
access_token: 'test-token',
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
refresh_token: 'test-refresh'
};
await chrome.storage.local.set({
'supabase.auth.token': JSON.stringify(testSession)
});
document.getElementById('storageContent').textContent = 'Test data set';
console.log('Test data set in Chrome Storage');
} catch (error) {
console.error('Error setting test data:', error);
}
}
// Set up event listeners when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('checkBtn').addEventListener('click', checkStorage);
document.getElementById('clearBtn').addEventListener('click', clearStorage);
document.getElementById('testBtn').addEventListener('click', setTestData);
// Auto-check on load
checkStorage();
});

View file

@ -0,0 +1,35 @@
{
"manifest_version": 3,
"name": "News Hub - Save Article",
"version": "1.0.0",
"description": "Save articles from any website to your News Hub library",
"permissions": [
"activeTab",
"storage"
],
"host_permissions": [
"http://localhost:3000/*"
],
"action": {
"default_popup": "popup.html",
"default_title": "Save to News Hub"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://localhost:*/*"],
"js": ["content.js"],
"run_at": "document_start"
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

View file

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>News Hub - Save Article</title>
<style>
body {
width: 350px;
padding: 20px;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
color: white;
}
.container {
text-align: center;
}
.logo {
font-size: 22px;
font-weight: bold;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.subtitle {
font-size: 12px;
opacity: 0.8;
margin-bottom: 20px;
}
.current-page {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
text-align: left;
}
.page-title {
font-weight: 600;
margin-bottom: 4px;
font-size: 13px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.page-url {
font-size: 11px;
opacity: 0.7;
word-break: break-all;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.save-button {
background: #3b82f6;
border: none;
color: white;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
width: 100%;
}
.save-button:hover {
background: #2563eb;
transform: translateY(-1px);
}
.save-button:active {
transform: translateY(0);
}
.save-button:disabled {
background: #475569;
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.status {
margin-top: 12px;
font-size: 12px;
min-height: 16px;
}
.status.success {
color: #4ade80;
}
.status.error {
color: #f87171;
}
.login-notice {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
font-size: 12px;
}
.login-link {
color: #60a5fa;
text-decoration: underline;
cursor: pointer;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="logo">NEWS HUB</div>
<div class="subtitle">Save Article to Library</div>
<div id="loginNotice" class="login-notice" style="display: none;">
<div>Please log in to News Hub first:</div>
<div style="margin-top: 8px;">
<a href="#" id="loginLink" class="login-link">Open News Hub App</a>
</div>
</div>
<div id="currentPage" class="current-page">
<div class="page-title" id="pageTitle">Loading page info...</div>
<div class="page-url" id="pageUrl"></div>
</div>
<button id="saveButton" class="save-button" disabled>
<span id="buttonText">Ready to Save</span>
</button>
<div id="status" class="status"></div>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,178 @@
// Browser Extension Popup Script for News Hub
document.addEventListener('DOMContentLoaded', async () => {
const pageTitle = document.getElementById('pageTitle');
const pageUrl = document.getElementById('pageUrl');
const saveButton = document.getElementById('saveButton');
const buttonText = document.getElementById('buttonText');
const status = document.getElementById('status');
const loginNotice = document.getElementById('loginNotice');
const loginLink = document.getElementById('loginLink');
// API Configuration
const API_URL = 'http://localhost:3000';
const APP_URL = 'http://localhost:8081';
let currentTab = null;
let authToken = null;
// Get current tab info
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = tab;
pageTitle.textContent = tab.title || 'Untitled Page';
pageUrl.textContent = tab.url;
} catch (error) {
console.error('Error getting tab info:', error);
pageTitle.textContent = 'Error loading page info';
status.textContent = 'Failed to get page information';
status.className = 'status error';
return;
}
// Check if user is logged in by looking for stored token in Chrome storage
try {
const result = await chrome.storage.local.get(['news_hub_auth_token']);
authToken = result['news_hub_auth_token'];
console.log('Checking Chrome storage for token...', authToken ? 'Found' : 'Not found');
if (authToken) {
// Verify token is still valid by calling session endpoint
try {
const response = await fetch(`${API_URL}/auth/session`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (response.ok) {
saveButton.disabled = false;
loginNotice.style.display = 'none';
// Auto-save article immediately
saveArticle();
} else {
// Token is invalid
await chrome.storage.local.remove(['news_hub_auth_token']);
authToken = null;
showLoginNotice();
}
} catch (error) {
console.error('Error verifying token:', error);
showLoginNotice();
}
} else {
showLoginNotice();
}
} catch (error) {
console.error('Error checking login status:', error);
showLoginNotice();
}
function showLoginNotice() {
loginNotice.style.display = 'block';
saveButton.disabled = true;
status.textContent = 'Please log in to News Hub first';
status.className = 'status error';
}
// Handle login link click
loginLink.addEventListener('click', (e) => {
e.preventDefault();
chrome.tabs.create({ url: APP_URL });
window.close();
});
// Save article function
async function saveArticle() {
if (!currentTab || !authToken) {
status.textContent = 'Please log in first';
status.className = 'status error';
return;
}
// Validate URL
const url = currentTab.url;
if (
!url ||
url.startsWith('chrome://') ||
url.startsWith('chrome-extension://') ||
url.startsWith('about:')
) {
status.textContent = 'Cannot save this type of page';
status.className = 'status error';
return;
}
// Show loading state
saveButton.disabled = true;
buttonText.innerHTML = '<span class="loading"></span>Saving...';
status.textContent = 'Saving article...';
status.className = 'status';
try {
const response = await fetch(`${API_URL}/extract/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ url: url }),
});
const result = await response.json();
if (response.ok && result.success) {
status.textContent = 'Article saved!';
status.className = 'status success';
// Show success for a moment, then close
setTimeout(() => {
window.close();
}, 1500);
} else {
throw new Error(result.message || 'Failed to save article');
}
} catch (error) {
console.error('Error saving article:', error);
let errorMessage = 'Failed to save article';
if (error.message.includes('fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Network error - is the API running?';
} else if (error.message.includes('401') || error.message.includes('Unauthorized')) {
errorMessage = 'Session expired - please log in again';
showLoginNotice();
} else if (error.message) {
errorMessage = error.message;
}
status.textContent = errorMessage;
status.className = 'status error';
} finally {
// Reset button state
saveButton.disabled = authToken ? false : true;
buttonText.textContent = 'Try Again';
}
}
// Handle save button click (manual save if auto-save failed)
saveButton.addEventListener('click', saveArticle);
// Listen for storage changes (if user logs in/out in another tab)
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes['news_hub_auth_token']) {
const newValue = changes['news_hub_auth_token'].newValue;
if (newValue) {
authToken = newValue;
saveButton.disabled = false;
loginNotice.style.display = 'none';
status.textContent = '';
status.className = 'status';
} else {
authToken = null;
showLoginNotice();
}
}
});
});

View file

@ -2,7 +2,7 @@
"name": "manacore-monorepo", "name": "manacore-monorepo",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, and nutriphi", "description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, nutriphi, and news",
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"build": "turbo run build", "build": "turbo run build",
@ -58,7 +58,16 @@
"dev:nutriphi:web": "pnpm --filter @nutriphi/web dev", "dev:nutriphi:web": "pnpm --filter @nutriphi/web dev",
"dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev", "dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev",
"dev:nutriphi:backend": "pnpm --filter @nutriphi/backend start:dev", "dev:nutriphi:backend": "pnpm --filter @nutriphi/backend start:dev",
"dev:nutriphi:app": "turbo run dev --filter=@nutriphi/web --filter=@nutriphi/backend" "dev:nutriphi:app": "turbo run dev --filter=@nutriphi/web --filter=@nutriphi/backend",
"news:dev": "turbo run dev --filter=news...",
"dev:news:mobile": "pnpm --filter @news/mobile dev",
"dev:news:web": "pnpm --filter @news/web dev",
"dev:news:landing": "pnpm --filter @news/landing dev",
"dev:news:api": "pnpm --filter @news/api start:dev",
"dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api",
"news:db:push": "pnpm --filter @manacore/news-database db:push",
"news:db:studio": "pnpm --filter @manacore/news-database db:studio"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.3.3", "prettier": "^3.3.3",

View file

@ -0,0 +1,15 @@
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
import { resolve } from 'path';
// Load .env from monorepo root
config({ path: resolve(__dirname, '../../.env') });
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://news:news_dev_password@localhost:5434/news_hub',
},
});

View file

@ -0,0 +1,43 @@
{
"name": "@manacore/news-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0"
}
}

View file

@ -0,0 +1,19 @@
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Re-export schema and types
export * from './schema';
export { sql, eq, and, or, desc, asc, isNull, isNotNull, inArray } from 'drizzle-orm';
// Export schema for use in drizzle initialization
export { schema };
// Type for the database instance with schema
export type Database = PostgresJsDatabase<typeof schema>;
// Helper to create a new database connection
export function createDb(url: string): Database {
const client = postgres(url);
return drizzle(client, { schema });
}

View file

@ -0,0 +1,64 @@
import { pgTable, uuid, text, timestamp, boolean, integer, real, pgEnum, index } from 'drizzle-orm/pg-core';
import { users } from './users';
import { categories } from './categories';
export const articleTypeEnum = pgEnum('article_type', ['feed', 'summary', 'in_depth', 'saved']);
export const articleSourceEnum = pgEnum('article_source', ['ai', 'user_saved']);
export const summaryPeriodEnum = pgEnum('summary_period', ['morning', 'noon', 'evening', 'night']);
export const articles = pgTable('articles', {
id: uuid('id').primaryKey().defaultRandom(),
// Core fields
type: articleTypeEnum('type').notNull(),
sourceOrigin: articleSourceEnum('source_origin').default('ai').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
summary: text('summary'),
// For user-saved articles
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
originalUrl: text('original_url'),
parsedContent: text('parsed_content'),
isArchived: boolean('is_archived').default(false),
// Metadata
categoryId: uuid('category_id').references(() => categories.id),
sourceUrl: text('source_url'),
sourceName: text('source_name'),
sourceDomain: text('source_domain'),
author: text('author'),
imageUrl: text('image_url'),
// AI-generated metadata
aiTags: text('ai_tags').array(),
sentimentScore: real('sentiment_score'),
// Reading metrics
readingTimeMinutes: integer('reading_time_minutes'),
wordCount: integer('word_count'),
// Summary-specific fields
summaryDate: timestamp('summary_date'),
summaryPeriod: summaryPeriodEnum('summary_period'),
includedArticleIds: uuid('included_article_ids').array(),
// In-depth specific fields
keyInsights: text('key_insights'), // JSON string
dataVisualizations: text('data_visualizations'), // JSON string
relatedArticleIds: uuid('related_article_ids').array(),
// Timestamps
publishedAt: timestamp('published_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('articles_type_idx').on(table.type),
index('articles_user_idx').on(table.userId),
index('articles_source_origin_idx').on(table.sourceOrigin),
index('articles_published_at_idx').on(table.publishedAt),
index('articles_category_idx').on(table.categoryId),
]);
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;

View file

@ -0,0 +1,47 @@
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { users } from './users';
// Better Auth Sessions
export const sessions = pgTable('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Better Auth Accounts (for OAuth providers)
export const accounts = pgTable('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
providerId: text('provider_id').notNull(), // 'credential', 'google', 'apple', etc.
accountId: text('account_id').notNull(), // Provider's user ID or email for credential
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'), // Hashed, only for credential provider
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Better Auth Verification Tokens
export const verifications = pgTable('verifications', {
id: uuid('id').primaryKey().defaultRandom(),
identifier: text('identifier').notNull(), // email or other identifier
value: text('value').notNull(), // the token
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;
export type Verification = typeof verifications.$inferSelect;
export type NewVerification = typeof verifications.$inferInsert;

View file

@ -0,0 +1,16 @@
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const categories = pgTable('categories', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
displayName: text('display_name').notNull(),
description: text('description'),
icon: text('icon'),
color: text('color'),
priority: integer('priority').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type Category = typeof categories.$inferSelect;
export type NewCategory = typeof categories.$inferInsert;

View file

@ -0,0 +1,5 @@
export * from './users';
export * from './categories';
export * from './articles';
export * from './interactions';
export * from './auth';

View file

@ -0,0 +1,31 @@
import { pgTable, uuid, timestamp, boolean, real, integer, index, unique } from 'drizzle-orm/pg-core';
import { users } from './users';
import { articles } from './articles';
export const userArticleInteractions = pgTable('user_article_interactions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
articleId: uuid('article_id').references(() => articles.id, { onDelete: 'cascade' }).notNull(),
// Interaction states
isRead: boolean('is_read').default(false).notNull(),
isSaved: boolean('is_saved').default(false).notNull(),
readProgress: real('read_progress').default(0), // 0.0 to 1.0
rating: integer('rating'), // 1-5
shareCount: integer('share_count').default(0).notNull(),
// Timestamps
openedAt: timestamp('opened_at'),
readAt: timestamp('read_at'),
savedAt: timestamp('saved_at'),
ratedAt: timestamp('rated_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
unique('user_article_unique').on(table.userId, table.articleId),
index('interactions_user_idx').on(table.userId),
index('interactions_article_idx').on(table.articleId),
]);
export type UserArticleInteraction = typeof userArticleInteractions.$inferSelect;
export type NewUserArticleInteraction = typeof userArticleInteractions.$inferInsert;

View file

@ -0,0 +1,29 @@
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
export const userTierEnum = pgEnum('user_tier', ['free', 'premium', 'enterprise']);
export const readingSpeedEnum = pgEnum('reading_speed', ['slow', 'normal', 'fast']);
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name'),
avatarUrl: text('avatar_url'),
emailVerified: boolean('email_verified').default(false).notNull(),
// Preferences
tier: userTierEnum('tier').default('free').notNull(),
readingSpeed: readingSpeedEnum('reading_speed').default('normal').notNull(),
preferredCategories: text('preferred_categories').array(),
blockedSources: text('blocked_sources').array(),
// Settings
onboardingCompleted: boolean('onboarding_completed').default(false).notNull(),
notificationSettings: text('notification_settings'), // JSON string
// Timestamps
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

2099
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ packages:
- 'uload' - 'uload'
- 'chat' - 'chat'
- 'nutriphi' - 'nutriphi'
- 'news'
# Sub-apps within projects # Sub-apps within projects
- 'maerchenzauber/apps/*' - 'maerchenzauber/apps/*'
@ -24,6 +25,8 @@ packages:
- 'chat/packages/*' - 'chat/packages/*'
- 'nutriphi/apps/*' - 'nutriphi/apps/*'
- 'nutriphi/backend' - 'nutriphi/backend'
- 'news/apps/*'
- 'news/packages/*'
# Shared packages # Shared packages
- 'packages/*' - 'packages/*'

View file

@ -14,6 +14,7 @@
"@astrojs/mdx": "^4.0.8", "@astrojs/mdx": "^4.0.8",
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^6.0.2", "@astrojs/tailwind": "^6.0.2",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.1.1", "astro": "^5.1.1",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
}, },

View file

@ -1,76 +0,0 @@
---
const features = [
{
icon: '🔗',
title: 'URL-Verkürzung',
description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick. Perfekt für Social Media und Marketing.'
},
{
icon: '📊',
title: 'Detaillierte Analytics',
description: 'Verfolgen Sie Klicks, geografische Herkunft, Geräte und Engagement Ihrer Links in Echtzeit.'
},
{
icon: '🎨',
title: 'QR-Code Generator',
description: 'Erstellen Sie anpassbare QR-Codes in verschiedenen Farben und Formaten für jeden Link.'
},
{
icon: '💳',
title: 'Digitale Visitenkarten',
description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes und Kontaktinformationen.'
},
{
icon: '🔒',
title: 'Passwortschutz',
description: 'Schützen Sie Ihre Links mit Passwörtern und setzen Sie Ablaufdaten für zeitlich begrenzte Aktionen.'
},
{
icon: '🏷️',
title: 'Tag-System',
description: 'Organisieren Sie Ihre Links mit Tags und Kategorien für eine bessere Übersicht und Filterung.'
},
{
icon: '👥',
title: 'Team Workspaces',
description: 'Arbeiten Sie im Team zusammen mit gemeinsamen Workspaces und granularen Berechtigungen.'
},
{
icon: '⚡',
title: 'Blitzschnell',
description: 'Unsere Links sind weltweit über ein CDN verteilt für minimale Ladezeiten und maximale Verfügbarkeit.'
},
{
icon: '🔌',
title: 'API Zugang',
description: 'Integrieren Sie uLoad in Ihre Anwendungen mit unserer RESTful API für automatisierte Workflows.'
}
];
---
<section id="features" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center mb-16">
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
Alles was du für professionelles Link-Management brauchst
</h2>
<p class="mx-auto max-w-2xl text-lg text-gray-600">
Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration uLoad bietet alle Features die du brauchst.
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{features.map(feature => (
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl hover:border-primary-200">
<div class="mb-4 text-4xl">{feature.icon}</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
{feature.title}
</h3>
<p class="text-gray-600">
{feature.description}
</p>
</div>
))}
</div>
</div>
</section>

View file

@ -1,185 +0,0 @@
---
const appUrl = 'https://app.ulo.ad';
const plans = [
{
id: 'free',
name: 'Free',
price: 0,
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
'10 Links pro Monat',
'Basis Analytics',
'QR-Code Generator',
'Link Anpassung',
'Standard Support'
],
cta: 'Kostenlos starten',
highlighted: false,
href: `${appUrl}/register`
},
{
id: 'pro-monthly',
name: 'Pro',
price: 4.99,
period: '/Monat',
description: 'Für Freelancer & Creators',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'API Zugang'
],
cta: 'Pro wählen',
highlighted: false,
href: `${appUrl}/register?plan=pro`
},
{
id: 'pro-yearly',
name: 'Pro Jährlich',
price: 3.33,
period: '/Monat',
description: 'Beste Wahl für Power User',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'API Zugang'
],
cta: 'Jährlich sparen',
highlighted: true,
badge: 'Spare 20€/Jahr',
href: `${appUrl}/register?plan=pro-yearly`
},
{
id: 'lifetime',
name: 'Pro Lifetime',
price: 129.99,
period: 'einmalig',
description: 'Einmalig zahlen, für immer nutzen',
features: [
'Alle Pro Features',
'Lebenslanger Zugang',
'Alle zukünftigen Features',
'Early Access',
'Priority Support'
],
cta: 'Lifetime sichern',
highlighted: false,
badge: 'Einmalig',
href: `${appUrl}/register?plan=lifetime`
}
];
function formatPrice(price: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: price % 1 === 0 ? 0 : 2
}).format(price);
}
---
<section id="pricing" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24 bg-gray-50">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
Transparente Preise, keine versteckten Kosten
</h2>
<p class="mx-auto mb-12 max-w-2xl text-lg text-gray-600">
Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
</p>
</div>
<!-- Pricing Cards -->
<div class="grid gap-8 lg:grid-cols-4">
{plans.map(plan => (
<div
class:list={[
"relative rounded-xl border-2 bg-white transition-all duration-300",
plan.highlighted
? "border-primary-500 shadow-2xl scale-105"
: "border-gray-200 hover:border-primary-300 hover:shadow-xl"
]}
>
{plan.badge && (
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
<span class="rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white">
{plan.badge}
</span>
</div>
)}
<div class="p-6">
<h3 class="mb-2 text-xl font-bold text-gray-900">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-500">{plan.description}</p>
<div class="mb-6">
<div class="flex items-baseline">
<span class="text-4xl font-bold text-gray-900">
{formatPrice(plan.price)}
</span>
<span class="ml-2 text-gray-500">{plan.period}</span>
</div>
</div>
<a
href={plan.href}
class:list={[
"mb-6 block w-full rounded-lg py-3 font-semibold text-center transition",
plan.highlighted
? "bg-primary-600 text-white hover:bg-primary-700"
: "border-2 border-gray-200 text-gray-900 hover:border-primary-500 hover:bg-primary-50"
]}
>
{plan.cta}
</a>
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Inklusive:
</p>
{plan.features.map(feature => (
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
<!-- Benefits -->
<div class="mt-16 rounded-xl border border-gray-200 bg-white p-8">
<div class="grid gap-8 lg:grid-cols-3">
<div>
<h4 class="mb-2 font-semibold text-gray-900">💳 Keine Kreditkarte erforderlich</h4>
<p class="text-sm text-gray-600">
Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-gray-900">🔄 Jederzeit kündbar</h4>
<p class="text-sm text-gray-600">
Keine Vertragsbindung. Kündige monatlich ohne Probleme.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-gray-900">🚀 Sofort startklar</h4>
<p class="text-sm text-gray-600">
Nach der Anmeldung kannst du sofort alle Features nutzen.
</p>
</div>
</div>
</div>
</div>
</section>

View file

@ -1,12 +1,223 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HeroSection from '../components/HeroSection.astro'; import HeroSection from '../components/HeroSection.astro';
import FeaturesSection from '../components/FeaturesSection.astro';
import PricingSection from '../components/PricingSection.astro'; // Shared components
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
const appUrl = 'https://app.ulo.ad';
// Feature data
const features = [
{
icon: '🔗',
title: 'Smart Links',
description: 'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.'
},
{
icon: '📊',
title: 'Detaillierte Analytics',
description: 'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.'
},
{
icon: '🎨',
title: 'QR-Code Generator',
description: 'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.'
},
{
icon: '💳',
title: 'Profile Cards',
description: 'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.'
},
{
icon: '👥',
title: 'Team Workspaces',
description: 'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.'
},
{
icon: '🔌',
title: 'API & Integrationen',
description: 'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.'
}
];
// Steps data
const steps = [
{
number: '1',
title: 'Link einfügen',
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
image: '/screenshots/paste.png'
},
{
number: '2',
title: 'Anpassen',
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
image: '/screenshots/customize.png'
},
{
number: '3',
title: 'Teilen & Tracken',
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
image: '/screenshots/share.png'
}
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '10 Links pro Monat', included: true },
{ text: 'Basis Analytics', included: true },
{ text: 'QR-Code Generator', included: true },
{ text: 'Link Anpassung', included: true },
{ text: 'Unbegrenzte Links', included: false },
{ text: 'Team Features', included: false }
],
cta: {
text: 'Kostenlos starten',
href: `${appUrl}/register`
}
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Für Freelancer & Creators',
features: [
{ text: 'Unbegrenzte Links', included: true },
{ text: 'Erweiterte Analytics', included: true },
{ text: 'Custom QR Codes', included: true },
{ text: 'API Zugang', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Passwortschutz', included: true }
],
cta: {
text: 'Pro wählen',
href: `${appUrl}/register?plan=pro`
}
},
{
name: 'Pro Jährlich',
price: '3,33',
period: '/Monat',
description: 'Spare 20€ pro Jahr',
features: [
{ text: 'Alle Pro Features', included: true },
{ text: 'Unbegrenzte Links', included: true },
{ text: 'Erweiterte Analytics', included: true },
{ text: 'Custom QR Codes', included: true },
{ text: 'API Zugang', included: true },
{ text: 'Priority Support', included: true }
],
cta: {
text: 'Jährlich sparen',
href: `${appUrl}/register?plan=pro-yearly`
},
highlighted: true,
badge: 'Spare 20€'
},
{
name: 'Lifetime',
price: '129,99',
period: 'einmalig',
description: 'Einmal zahlen, für immer nutzen',
features: [
{ text: 'Alle Pro Features', included: true },
{ text: 'Lebenslanger Zugang', included: true },
{ text: 'Alle zukünftigen Features', included: true },
{ text: 'Early Access', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Keine Abo-Gebühren', included: true }
],
cta: {
text: 'Lifetime sichern',
href: `${appUrl}/register?plan=lifetime`
},
badge: 'Einmalig'
}
];
// FAQ data
const faqs = [
{
question: 'Wie lange bleiben meine Links aktiv?',
answer: 'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.'
},
{
question: 'Kann ich meine eigene Domain verwenden?',
answer: 'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).'
},
{
question: 'Wie funktionieren die Analytics?',
answer: 'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.'
},
{
question: 'Was sind Profile Cards?',
answer: 'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.'
},
{
question: 'Gibt es eine API?',
answer: 'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.'
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer: 'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.'
}
];
--- ---
<BaseLayout title="Intelligenter URL-Shortener"> <BaseLayout title="Intelligenter URL-Shortener">
<HeroSection /> <HeroSection />
<FeaturesSection />
<PricingSection /> <FeatureSection
id="features"
title="Alles was du für professionelles Link-Management brauchst"
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
features={features}
columns={3}
variant="cards"
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten zum perfekten Link"
subtitle="So einfach funktioniert uLoad"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-gray-50"
/>
<PricingSection
id="pricing"
title="Transparente Preise, keine versteckten Kosten"
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
plans={pricingPlans}
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über uLoad wissen musst"
faqs={faqs}
class="bg-gray-50"
/>
<CTASection
id="cta"
title="Bereit für smarte Links?"
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
variant="default"
/>
</BaseLayout> </BaseLayout>

View file

@ -2,41 +2,84 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { /* uLoad Theme CSS Variables - Professional Blue (Light Theme) */
:root { :root {
--color-primary: #3b82f6; /* Primary colors - uLoad Blue */
--color-primary-dark: #2563eb; --color-primary: #3b82f6;
--color-background: #ffffff; --color-primary-hover: #2563eb;
--color-background-secondary: #f9fafb; --color-primary-glow: rgba(59, 130, 246, 0.2);
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
}
.dark { /* Text colors (Light theme) */
--color-background: #111827; --color-text-primary: #111827;
--color-background-secondary: #1f2937; --color-text-secondary: #4b5563;
--color-text: #f9fafb; --color-text-muted: #6b7280;
--color-text-secondary: #9ca3af;
--color-border: #374151;
}
html { /* Background colors (Light theme) */
scroll-behavior: smooth; --color-background-page: #ffffff;
} --color-background-card: #f9fafb;
--color-background-card-hover: #f3f4f6;
body { /* Border colors */
@apply bg-white text-gray-900 antialiased; --color-border: #e5e7eb;
--color-border-hover: #d1d5db;
}
/* 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;
-webkit-font-smoothing: antialiased;
}
/* 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, #3b82f6 0%, #8b5cf6 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;
} }
@layer components { @layer components {
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors duration-200; @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors duration-200 shadow-lg hover:shadow-xl;
} }
.btn-secondary { .btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200; @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border-2 border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all duration-200;
} }
.container-custom { .container-custom {

View file

@ -1,9 +1,13 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
// uLoad Professional Blue Theme (Light)
primary: { primary: {
50: '#eff6ff', 50: '#eff6ff',
100: '#dbeafe', 100: '#dbeafe',
@ -15,7 +19,24 @@ export default {
700: '#1d4ed8', 700: '#1d4ed8',
800: '#1e40af', 800: '#1e40af',
900: '#1e3a8a', 900: '#1e3a8a',
950: '#172554' 950: '#172554',
DEFAULT: '#3b82f6',
hover: '#2563eb',
glow: 'rgba(59, 130, 246, 0.2)'
},
background: {
page: '#ffffff',
card: '#f9fafb',
'card-hover': '#f3f4f6'
},
text: {
primary: '#111827',
secondary: '#4b5563',
muted: '#6b7280'
},
border: {
DEFAULT: '#e5e7eb',
hover: '#d1d5db'
} }
}, },
fontFamily: { fontFamily: {