First Commit

This commit is contained in:
Esteban
2026-07-03 16:27:34 +02:00
commit 7a7440daab
8955 changed files with 1117958 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app', ['title' => $article->title.' — BloomFeed'])
@section('content')
<div class="reader-shell">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<a href="{{ url()->previous(route('dashboard')) }}" class="back-link">&larr; Retour aux articles</a>
<article class="card reader-article">
<h1 class="reader-title">{{ $article->title }}</h1>
<div class="reader-meta">
<span>{{ $article->feed->displayTitle() }}</span>
@if ($article->published_at)
<span>&middot; {{ $article->published_at->diffForHumans() }}</span>
@endif
<a href="{{ $article->link }}" target="_blank" rel="noopener" class="reader-source-link">Voir la source originale &#8599;</a>
</div>
@if ($article->full_content_error)
<div class="reader-error">
<p>Impossible de récupérer le contenu complet de cet article ({{ $article->full_content_error }}).</p>
<form method="POST" action="{{ route('articles.retry', $article) }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Réessayer</button>
</form>
@if ($article->excerpt)
<p class="mt-2">{{ $article->excerpt }}</p>
@endif
</div>
@elseif ($contentHtml)
<div class="prose">{!! $contentHtml !!}</div>
@else
<p class="text-muted">Récupération du contenu en cours…</p>
@endif
</article>
</div>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.guest', ['title' => 'Connexion — BloomFeed'])
@section('content')
<div class="auth-card">
<div class="logo-row">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</div>
<h1>Connexion</h1>
<p class="subtitle">Accédez à vos flux et à votre lecture approfondie</p>
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
@if ($errors->any())
<div class="form-error mt-1">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('login') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="email">Adresse e-mail</label>
<input type="email" name="email" id="email" class="form-input" value="{{ old('email') }}" required autofocus>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" name="password" id="password" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Se connecter</button>
</form>
@unless (\App\Models\User::query()->exists())
<div class="switch-link">
Pas encore de compte ? <a href="{{ route('register') }}">Créer un compte</a>
</div>
@endunless
</div>
@endsection
+41
View File
@@ -0,0 +1,41 @@
@extends('layouts.guest', ['title' => 'Créer un compte — BloomFeed'])
@section('content')
<div class="auth-card">
<div class="logo-row">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</div>
<h1>Configurer votre instance</h1>
<p class="subtitle">Ce compte sera le seul compte propriétaire de cette instance BloomFeed</p>
@if ($errors->any())
<div class="form-error mt-1">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('register') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="name">Nom</label>
<input type="text" name="name" id="name" class="form-input" value="{{ old('name') }}" required autofocus>
</div>
<div class="form-group">
<label for="email">Adresse e-mail</label>
<input type="email" name="email" id="email" class="form-input" value="{{ old('email') }}" required>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" name="password" id="password" class="form-input" required>
</div>
<div class="form-group">
<label for="password_confirmation">Confirmer le mot de passe</label>
<input type="password" name="password_confirmation" id="password_confirmation" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Créer mon compte</button>
</form>
<div class="switch-link">
Déjà un compte ? <a href="{{ route('login') }}">Se connecter</a>
</div>
</div>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app', ['title' => 'Articles — BloomFeed'])
@section('content')
<div class="sidebar">
@include('partials.feed-sidebar')
</div>
<div class="main-column">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<div class="flex-between">
<h2 style="margin:0;">Derniers articles</h2>
<div class="flex" style="gap:8px;">
<a href="{{ route('dashboard', array_filter(['feed' => request('feed'), 'unread' => request('unread') ? null : 1])) }}"
class="btn btn-ghost btn-sm">
{{ request('unread') ? 'Tout afficher' : 'Non lus uniquement' }}
</a>
</div>
</div>
@forelse ($articles as $article)
@include('partials.article-card', ['article' => $article])
@empty
<div class="card empty-state">
@if ($feeds->isEmpty())
Ajoutez votre premier flux RSS depuis la barre latérale pour commencer.
@else
Aucun article pour le moment. Essayez d'actualiser vos flux.
@endif
</div>
@endforelse
<div class="pagination">
{{ $articles->links() }}
</div>
</div>
@endsection
+55
View File
@@ -0,0 +1,55 @@
@extends('layouts.app', ['title' => 'Mes flux — BloomFeed'])
@section('content')
<div class="main-column" style="width:100%;">
@if (session('status'))
<div class="form-status">{{ session('status') }}</div>
@endif
<div class="card">
<h2>Ajouter un flux</h2>
<form method="POST" action="{{ route('feeds.store') }}" class="inline-form">
@csrf
<input type="url" name="url" class="form-input" placeholder="https://exemple.com/rss.xml" required>
<input type="text" name="title" class="form-input" placeholder="Titre personnalisé (optionnel)" style="max-width:220px;">
<button type="submit" class="btn btn-primary">Ajouter</button>
</form>
@error('url')
<div class="form-error mt-1">{{ $message }}</div>
@enderror
</div>
<h2 class="mt-3">Mes flux</h2>
@forelse ($feeds as $feed)
<div class="card flex-between">
<div>
<div style="font-weight:600;">{{ $feed->displayTitle() }}</div>
<div class="text-sm text-muted">{{ $feed->url }}</div>
<div class="text-sm text-muted mt-1">
{{ $feed->articles_count }} article(s)
@if ($feed->last_fetched_at)
&middot; actualisé {{ $feed->last_fetched_at->diffForHumans() }}
@endif
@if ($feed->last_fetch_status && $feed->last_fetch_status !== 'ok')
&middot; <span style="color:var(--danger);">{{ $feed->last_fetch_status }}</span>
@endif
</div>
</div>
<div class="flex" style="gap:8px;">
<form method="POST" action="{{ route('feeds.refresh', $feed) }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Actualiser</button>
</form>
<form method="POST" action="{{ route('feeds.destroy', $feed) }}">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm" data-confirm="Supprimer ce flux et tous ses articles ?">Supprimer</button>
</form>
</div>
</div>
@empty
<div class="card empty-state">Vous n'avez pas encore ajouté de flux.</div>
@endforelse
</div>
@endsection
+40
View File
@@ -0,0 +1,40 @@
@extends('layouts.marketing', ['title' => 'BloomFeed — votre agrégateur RSS auto-hébergé'])
@section('content')
<section class="hero">
<div class="hero-inner">
<span class="eyebrow">Auto-hébergé &middot; Vie privée</span>
<h1>Vos flux RSS, lus en profondeur, sans jamais quitter BloomFeed.</h1>
<p class="hero-lede">
BloomFeed centralise vos flux préférés et en extrait le contenu complet à la demande,
directement sur votre propre serveur. Aucune donnée envoyée à des tiers.
</p>
<div class="hero-cta">
@if ($hasOwner)
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">Se connecter</a>
@else
<a href="{{ route('register') }}" class="btn btn-primary btn-lg">Créer un compte</a>
@endif
<a href="{{ route('self-hosting') }}" class="btn btn-ghost btn-lg">Comment l'auto-héberger ?</a>
</div>
</div>
</section>
<section class="feature-grid">
<div class="feature-card">
<div class="feature-icon">📰</div>
<h3>Flux personnalisés</h3>
<p>Ajoutez n'importe quel flux RSS ou Atom, organisez-les et suivez vos non-lus en un coup d'œil.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📖</div>
<h3>Lecture approfondie intégrée</h3>
<p>BloomFeed récupère la page source, en extrait le contenu principal et l'affiche proprement plus besoin d'ouvrir dix onglets.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>Chez vous, pour vous</h3>
<p>Une instance BloomFeed = un propriétaire. Pas d'inscriptions publiques, pas de tracking, votre serveur, vos données.</p>
</div>
</section>
@endsection
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
@include('partials.navbar')
<div class="app-shell">
@yield('content')
</div>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
<script src="{{ asset('js/app.js') }}" defer></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<div class="centered-shell">
@yield('content')
</div>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'BloomFeed' }}</title>
<meta name="description" content="BloomFeed est un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.">
<link rel="icon" href="{{ asset('images/icon.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('css/theme.css') }}">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script>
(function () {
var stored = localStorage.getItem('bloomfeed-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<nav class="navbar navbar-marketing">
<a href="{{ route('home') }}" class="navbar-brand">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</a>
<div class="navbar-links">
<a href="{{ route('self-hosting') }}" class="{{ request()->routeIs('self-hosting') ? 'active' : '' }}">Self-hosting</a>
<button type="button" class="theme-toggle" data-theme-toggle title="Changer de thème"></button>
@if (\App\Models\User::query()->exists())
<a href="{{ route('login') }}" class="btn btn-primary btn-sm">Se connecter</a>
@else
<a href="{{ route('register') }}" class="btn btn-primary btn-sm">Créer un compte</a>
@endif
</div>
</nav>
<main>
@yield('content')
</main>
<footer class="site-footer">
<span>BloomFeed projet open-source, auto-hébergé.</span>
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">Code source</a>
</footer>
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
</body>
</html>
@@ -0,0 +1,22 @@
<article class="article-card {{ $article->is_read ? 'is-read' : '' }}">
<div class="article-card-top">
<div>
<a href="{{ route('articles.show', $article) }}" class="article-title">
{{ $article->title }}
</a>
<div class="article-meta">
{{ $article->feed->displayTitle() }}
@if ($article->published_at)
&middot; {{ $article->published_at->diffForHumans() }}
@endif
</div>
</div>
<button type="button" class="favorite-btn {{ $article->is_favorite ? 'is-favorite' : '' }}" data-favorite-url="{{ route('articles.toggleFavorite', $article) }}" title="Favori">
</button>
</div>
@if ($article->excerpt)
<p class="article-excerpt">{{ $article->excerpt }}</p>
@endif
</article>
@@ -0,0 +1,38 @@
<div class="card">
<div class="sidebar-title">Flux</div>
<ul class="feed-list">
<li>
<a href="{{ route('dashboard') }}" class="{{ ! request('feed') ? 'active' : '' }}">
Tous les articles
</a>
</li>
@foreach ($feeds as $feed)
<li>
<a href="{{ route('dashboard', ['feed' => $feed->id]) }}" class="{{ request('feed') == $feed->id ? 'active' : '' }}">
<span>{{ $feed->displayTitle() }}</span>
@if ($feed->unread_count > 0)
<span class="pill">{{ $feed->unread_count }}</span>
@endif
</a>
</li>
@endforeach
</ul>
<form method="POST" action="{{ route('feeds.store') }}" class="mt-2">
@csrf
<div class="form-group">
<label for="sidebar-url">Ajouter un flux</label>
<input type="url" name="url" id="sidebar-url" class="form-input" placeholder="https://exemple.com/rss.xml" required>
</div>
<button type="submit" class="btn btn-primary btn-block btn-sm">Ajouter</button>
</form>
<form method="POST" action="{{ route('feeds.refreshAll') }}" class="mt-2">
@csrf
<button type="submit" class="btn btn-ghost btn-block btn-sm">Tout actualiser</button>
</form>
<a href="{{ route('feeds.index') }}" class="text-sm text-muted mt-1" style="display:block; text-align:center;">
Gérer mes flux
</a>
</div>
+15
View File
@@ -0,0 +1,15 @@
<nav class="navbar">
<a href="{{ route('dashboard') }}" class="navbar-brand">
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
</a>
<div class="navbar-links">
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">Articles</a>
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">Mes flux</a>
<button type="button" class="theme-toggle" data-theme-toggle title="Changer de thème"></button>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">Déconnexion</button>
</form>
</div>
</nav>
+58
View File
@@ -0,0 +1,58 @@
@extends('layouts.marketing', ['title' => 'Auto-héberger BloomFeed'])
@section('content')
<section class="docs-shell">
<h1>Auto-héberger BloomFeed</h1>
<p class="hero-lede">
BloomFeed est pensé pour être installé sur votre propre serveur, pour vous (ou votre foyer).
Une instance = un seul compte propriétaire. Tout tourne dans Docker : l'application
et la base de données MariaDB.
</p>
<div class="card mt-3">
<h2>Prérequis</h2>
<ul class="docs-list">
<li>Un serveur Linux avec <strong>Docker</strong> et le plugin <strong>Docker Compose</strong>.</li>
<li>Le port <strong>7081</strong> libre (modifiable dans <code>docker-compose.yml</code>).</li>
<li>Optionnel : un nom de domaine et un reverse proxy si vous voulez du HTTPS.</li>
</ul>
</div>
<div class="card mt-2">
<h2>Installation</h2>
<ol class="docs-list docs-list-numbered">
<li>Clonez le dépôt sur votre serveur.</li>
<li>
Lancez le script de déploiement :
<pre class="code-block">bash scriptsite.sh</pre>
Il crée automatiquement un fichier <code>.env</code> (à partir de <code>.env.example</code>) et y génère
des secrets aléatoires (<code>APP_KEY</code>, mots de passe de base de données) s'ils sont absents
ils ne sont jamais régénérés lors des déploiements suivants.
</li>
<li>Le script construit les images et démarre les conteneurs (application, planificateur, MariaDB).</li>
<li>Ouvrez <code>http://votre-serveur:7081</code> et créez le compte propriétaire cette option disparaît ensuite définitivement.</li>
</ol>
</div>
<div class="card mt-2">
<h2>Mettre à jour</h2>
<p>Relancez simplement <code>bash scriptsite.sh</code> : il récupère les derniers changements et reconstruit la stack sans toucher à vos données.</p>
</div>
<div class="card mt-2">
<h2>Désinstaller</h2>
<p>
Le script <code>uninstall.sh</code> arrête et supprime proprement la stack Docker.
Il demande une confirmation explicite avant de supprimer vos données (base de données)
et ne supprime jamais le code source sans confirmation séparée.
</p>
</div>
<div class="card mt-2">
<h2>Code source</h2>
<p>
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">git.eldorianet.work/esteban/RSS-Eldoria-Network</a>
</p>
</div>
</section>
@endsection