BloomFeed Upgrade v0.21
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Esteban
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,7 +1,8 @@
|
||||
<p align="center"><img src="public/images/logo.png" width="360" alt="BloomFeed"></p>
|
||||
|
||||
<p align="center">
|
||||
Un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.
|
||||
Un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.<br>
|
||||
<em>A personal, self-hosted RSS aggregator with a built-in reader.</em>
|
||||
</p>
|
||||
|
||||
<p align="center"><img src="docs/screenshots/home-light.png" width="800" alt="Page d'accueil de BloomFeed"></p>
|
||||
@@ -12,8 +13,9 @@ BloomFeed centralise vos flux RSS/Atom préférés dans une interface simple. Qu
|
||||
intéresse, BloomFeed récupère la page source, en extrait le contenu principal et l'affiche
|
||||
directement dans l'application — plus besoin d'ouvrir le site d'origine.
|
||||
|
||||
BloomFeed est pensé pour être **auto-hébergé par une seule personne ou un foyer** : une instance
|
||||
n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
|
||||
BloomFeed est pensé pour être **auto-hébergé par une personne ou un foyer** : le premier compte
|
||||
créé devient le compte propriétaire, qui peut ensuite inviter des proches (création directe de
|
||||
comptes) ou ouvrir les inscriptions publiques depuis le menu Paramètres.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
@@ -23,9 +25,11 @@ n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
|
||||
quitter BloomFeed.
|
||||
- Catalogue de flux façon "market" : une sélection de flux embarquée avec l'application
|
||||
([resources/data/bloomflux.json](resources/data/bloomflux.json)), à ajouter en un clic.
|
||||
- Multi-comptes : le propriétaire crée des comptes ou ouvre/ferme les inscriptions d'un clic.
|
||||
- Interface disponible en **français, anglais, espagnol et allemand** (sélecteur en haut de page,
|
||||
préférence mémorisée par utilisateur).
|
||||
- Récupération périodique des flux via une tâche planifiée.
|
||||
- Thème clair/sombre.
|
||||
- Inscription fermée après la création du compte propriétaire — pas de SaaS ouvert.
|
||||
|
||||
## Aperçu
|
||||
|
||||
@@ -44,6 +48,84 @@ n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
|
||||
- CSS/JS écrits à la main, sans étape de build (pas de Node/Vite/Tailwind)
|
||||
- Docker Compose pour le déploiement
|
||||
|
||||
## Installation (auto-hébergement)
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Un serveur Linux avec **Docker** et le plugin **Docker Compose**.
|
||||
- Le port **7081** libre (modifiable dans `docker-compose.yml`).
|
||||
- Optionnel : un nom de domaine + reverse proxy (Nginx, Caddy, Traefik…) pour le HTTPS —
|
||||
l'application détecte automatiquement les en-têtes `X-Forwarded-*`.
|
||||
|
||||
### Étapes
|
||||
|
||||
```bash
|
||||
git clone https://git.eldorianet.work/esteban/BloomFeed-RSS bloomfeed
|
||||
cd bloomfeed
|
||||
bash scriptsite.sh
|
||||
```
|
||||
|
||||
Le script de déploiement :
|
||||
|
||||
1. libère le port 7081 si nécessaire ;
|
||||
2. clone ou met à jour le code dans `/opt/bloomfeed` ;
|
||||
3. crée le `.env` depuis `.env.example` s'il n'existe pas, et y génère les secrets manquants
|
||||
(`APP_KEY`, `DB_PASSWORD`, `DB_ROOT_PASSWORD`) — ils ne sont **jamais** régénérés ensuite ;
|
||||
4. construit les images et démarre les conteneurs (application, planificateur, MariaDB) ;
|
||||
5. attend que les migrations de l'entrypoint soient terminées et vérifie que l'application répond.
|
||||
|
||||
Ouvrez ensuite `http://votre-serveur:7081` et créez le **compte propriétaire** — les inscriptions
|
||||
publiques se referment automatiquement après.
|
||||
|
||||
### Mise à jour
|
||||
|
||||
Relancez simplement `bash scriptsite.sh` : il récupère les derniers changements et reconstruit
|
||||
la stack sans toucher à vos données.
|
||||
|
||||
### Désinstallation
|
||||
|
||||
```bash
|
||||
bash uninstall.sh # arrête et supprime les conteneurs (données conservées)
|
||||
bash uninstall.sh --purge # supprime aussi la base de données et le storage (confirmation demandée)
|
||||
bash uninstall.sh --purge --purge-source # supprime aussi /opt/bloomfeed et le .env
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Comptes et inscriptions
|
||||
|
||||
Le compte propriétaire dispose d'un menu **Paramètres** dans la barre de navigation :
|
||||
|
||||
- **Créer un compte** directement (nom, e-mail, mot de passe) pour un proche, sans rien ouvrir
|
||||
au public ;
|
||||
- **Ouvrir / fermer les inscriptions publiques** d'un clic — quand elles sont ouvertes, le bouton
|
||||
"Créer un compte" réapparaît sur la page d'accueil et la page de connexion.
|
||||
|
||||
### Langue
|
||||
|
||||
Chaque visiteur ou utilisateur choisit sa langue (FR, EN, ES, DE) via le sélecteur en haut de
|
||||
page. Le choix est mémorisé en session pour les visiteurs et enregistré sur le profil pour les
|
||||
utilisateurs connectés. La langue par défaut de l'instance se règle avec `APP_LOCALE` dans `.env`.
|
||||
|
||||
### Variables d'environnement principales
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
|---|---|---|
|
||||
| `APP_URL` | URL publique de l'instance (importante derrière un reverse proxy HTTPS) | `http://localhost:7081` |
|
||||
| `APP_LOCALE` | Langue par défaut (`fr`, `en`, `es`, `de`) | `fr` |
|
||||
| `DB_*` | Connexion MariaDB (générés par le script) | — |
|
||||
|
||||
### Catalogue de flux
|
||||
|
||||
L'onglet **Catalogue** propose une sélection de flux pré-faits (actualités, tech, développement,
|
||||
IT/sysadmin, sécurité, sciences…) à ajouter en un clic. La liste vit dans
|
||||
[resources/data/bloomflux.json](resources/data/bloomflux.json), versionnée avec le code : pour
|
||||
l'enrichir, éditez le fichier et redéployez — aucun service externe n'est nécessaire.
|
||||
|
||||
```json
|
||||
{ "title": "Nom du flux", "url": "https://exemple.com/rss.xml", "category": "Tech", "lang": "fr", "description": "Courte description." }
|
||||
```
|
||||
|
||||
## Développement local
|
||||
|
||||
Prérequis : PHP 8.3+ et [Composer](https://getcomposer.org).
|
||||
@@ -58,41 +140,6 @@ php artisan migrate
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
## Auto-hébergement
|
||||
|
||||
BloomFeed est fait pour tourner sur votre propre serveur via Docker Compose (application,
|
||||
planificateur, MariaDB). Voir la page **[/self-hosting](/self-hosting)** une fois
|
||||
l'application lancée, ou directement :
|
||||
|
||||
```bash
|
||||
git clone https://git.eldorianet.work/esteban/BloomFeed-RSS bloomfeed
|
||||
cd bloomfeed
|
||||
bash scriptsite.sh
|
||||
```
|
||||
|
||||
Le script crée automatiquement un `.env` avec des secrets générés aléatoirement s'il n'existe
|
||||
pas encore, construit les images et démarre la stack. L'application écoute par défaut sur le
|
||||
port `7081`.
|
||||
|
||||
Pour désinstaller proprement (avec confirmation avant toute suppression de données) :
|
||||
|
||||
```bash
|
||||
bash uninstall.sh
|
||||
```
|
||||
|
||||
## Catalogue de flux (BloomFlux)
|
||||
|
||||
L'onglet **Catalogue** propose une sélection de flux pré-faits (actualités, tech, développement,
|
||||
IT/sysadmin, sécurité, sciences…) à ajouter en un clic. La liste vit dans
|
||||
[resources/data/bloomflux.json](resources/data/bloomflux.json), versionnée avec le code : pour
|
||||
l'enrichir, éditez le fichier et déployez — aucun service externe n'est nécessaire.
|
||||
|
||||
Format d'une entrée :
|
||||
|
||||
```json
|
||||
{ "title": "Nom du flux", "url": "https://exemple.com/rss.xml", "category": "Tech", "lang": "fr", "description": "Courte description." }
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
[MIT](LICENSE).
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -11,11 +12,15 @@ use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private function registrationsAllowed(): bool
|
||||
{
|
||||
return ! User::query()->exists() || Setting::registrationsOpen();
|
||||
}
|
||||
|
||||
public function showRegister(): View|RedirectResponse
|
||||
{
|
||||
if (User::query()->exists()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
|
||||
if (! $this->registrationsAllowed()) {
|
||||
return redirect()->route('login')->with('status', __('app.registrations_closed'));
|
||||
}
|
||||
|
||||
return view('auth.register');
|
||||
@@ -23,9 +28,8 @@ class AuthController extends Controller
|
||||
|
||||
public function register(Request $request): RedirectResponse
|
||||
{
|
||||
if (User::query()->exists()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
|
||||
if (! $this->registrationsAllowed()) {
|
||||
return redirect()->route('login')->with('status', __('app.registrations_closed'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -38,6 +42,8 @@ class AuthController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'is_owner' => ! User::query()->exists(),
|
||||
'locale' => session('locale'),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
@@ -60,7 +66,7 @@ class AuthController extends Controller
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors([
|
||||
'email' => 'Ces identifiants ne correspondent à aucun compte.',
|
||||
'email' => __('app.invalid_credentials'),
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ class FeedController extends Controller
|
||||
$result = $service->fetchFeed($feed);
|
||||
|
||||
if ($result['error'] !== null) {
|
||||
return back()->with('status', "Flux ajouté, mais la première récupération a échoué : {$result['error']}");
|
||||
return back()->with('status', __('app.feed_added_error', ['error' => $result['error']]));
|
||||
}
|
||||
|
||||
return back()->with('status', "Flux ajouté avec {$result['new']} article(s).");
|
||||
return back()->with('status', __('app.feed_added', ['count' => $result['new']]));
|
||||
}
|
||||
|
||||
public function destroy(Feed $feed): RedirectResponse
|
||||
@@ -44,7 +44,7 @@ class FeedController extends Controller
|
||||
|
||||
$feed->delete();
|
||||
|
||||
return back()->with('status', 'Flux supprimé.');
|
||||
return back()->with('status', __('app.feed_deleted'));
|
||||
}
|
||||
|
||||
public function refresh(Feed $feed, FeedFetcherService $service): RedirectResponse
|
||||
@@ -54,10 +54,10 @@ class FeedController extends Controller
|
||||
$result = $service->fetchFeed($feed);
|
||||
|
||||
if ($result['error'] !== null) {
|
||||
return back()->with('status', "Échec de la récupération : {$result['error']}");
|
||||
return back()->with('status', __('app.refresh_failed', ['error' => $result['error']]));
|
||||
}
|
||||
|
||||
return back()->with('status', "{$result['new']} nouvel(aux) article(s).");
|
||||
return back()->with('status', __('app.new_articles', ['count' => $result['new']]));
|
||||
}
|
||||
|
||||
public function refreshAll(Request $request, FeedFetcherService $service): RedirectResponse
|
||||
@@ -69,6 +69,6 @@ class FeedController extends Controller
|
||||
$totalNew += $service->fetchFeed($feed)['new'];
|
||||
}
|
||||
|
||||
return back()->with('status', "{$totalNew} nouvel(aux) article(s) sur {$feeds->count()} flux.");
|
||||
return back()->with('status', __('app.new_articles_across', ['count' => $totalNew, 'feeds' => $feeds->count()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\FluxCatalogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
private function ensureOwner(Request $request): void
|
||||
{
|
||||
abort_unless($request->user()->is_owner, 403);
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->ensureOwner($request);
|
||||
|
||||
return view('settings', [
|
||||
'registrationsOpen' => Setting::registrationsOpen(),
|
||||
'users' => User::query()->orderBy('created_at')->get(),
|
||||
'catalogSources' => Setting::catalogSources(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addCatalogSource(Request $request, FluxCatalogService $catalog): RedirectResponse
|
||||
{
|
||||
$this->ensureOwner($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'source_url' => ['required', 'url', 'max:2048'],
|
||||
]);
|
||||
|
||||
$sources = Setting::catalogSources();
|
||||
|
||||
if (in_array($validated['source_url'], $sources, true)) {
|
||||
return redirect()->route('settings')->with('status', __('app.source_already_added'));
|
||||
}
|
||||
|
||||
$count = $catalog->validateSource($validated['source_url']);
|
||||
|
||||
if ($count === null) {
|
||||
return redirect()->route('settings')->withErrors(['source_url' => __('app.source_invalid')]);
|
||||
}
|
||||
|
||||
$sources[] = $validated['source_url'];
|
||||
Setting::setCatalogSources($sources);
|
||||
|
||||
return redirect()->route('settings')->with('status', __('app.source_added', ['count' => $count]));
|
||||
}
|
||||
|
||||
public function removeCatalogSource(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureOwner($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'source_url' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
Setting::setCatalogSources(array_filter(
|
||||
Setting::catalogSources(),
|
||||
fn (string $url) => $url !== $validated['source_url']
|
||||
));
|
||||
|
||||
return redirect()->route('settings')->with('status', __('app.source_removed'));
|
||||
}
|
||||
|
||||
public function toggleRegistrations(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureOwner($request);
|
||||
|
||||
$open = ! Setting::registrationsOpen();
|
||||
Setting::set('registrations_open', $open ? '1' : '0');
|
||||
|
||||
return redirect()->route('settings')
|
||||
->with('status', $open ? __('app.registrations_now_open') : __('app.registrations_now_closed'));
|
||||
}
|
||||
|
||||
public function createUser(Request $request): RedirectResponse
|
||||
{
|
||||
$this->ensureOwner($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('status', __('app.user_created'));
|
||||
}
|
||||
|
||||
public function updateLocale(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'locale' => ['required', 'in:fr,en,es,de'],
|
||||
]);
|
||||
|
||||
session(['locale' => $validated['locale']]);
|
||||
|
||||
if ($request->user()) {
|
||||
$request->user()->update(['locale' => $validated['locale']]);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public const SUPPORTED = ['fr', 'en', 'es', 'de'];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $request->user()?->locale
|
||||
?? $request->session()->get('locale')
|
||||
?? config('app.locale');
|
||||
|
||||
if (in_array($locale, self::SUPPORTED, true)) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'value'])]
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return static::query()->find($key)?->value ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
|
||||
public static function registrationsOpen(): bool
|
||||
{
|
||||
return static::get('registrations_open', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function catalogSources(): array
|
||||
{
|
||||
$sources = json_decode(static::get('catalog_sources', '[]'), true);
|
||||
|
||||
return is_array($sources) ? array_values(array_filter($sources, 'is_string')) : [];
|
||||
}
|
||||
|
||||
public static function setCatalogSources(array $sources): void
|
||||
{
|
||||
static::set('catalog_sources', json_encode(array_values($sources)));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'password', 'is_owner', 'locale'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -33,6 +33,7 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_owner' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,129 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class FluxCatalogService
|
||||
{
|
||||
/**
|
||||
* Retourne le catalogue de flux embarqué (resources/data/bloomflux.json).
|
||||
* La liste est versionnée avec le code : la mettre à jour = éditer le fichier et commit.
|
||||
* Retourne le catalogue complet : le fichier embarqué (resources/data/bloomflux.json)
|
||||
* fusionné avec les sources externes configurées (URLs de fichiers bloomflux.json
|
||||
* hébergés dans d'autres dépôts). Dédoublonné par URL de flux, l'embarqué gagne.
|
||||
*
|
||||
* @return list<array{title: string, url: string, category: string, lang: string, description: ?string}>
|
||||
*/
|
||||
public function getCatalog(): array
|
||||
{
|
||||
$feeds = $this->loadEmbedded();
|
||||
|
||||
foreach (Setting::catalogSources() as $sourceUrl) {
|
||||
$feeds = array_merge($feeds, $this->loadRemoteSource($sourceUrl));
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
$unique = [];
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
if (! isset($seen[$feed['url']])) {
|
||||
$seen[$feed['url']] = true;
|
||||
$unique[] = $feed;
|
||||
}
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une source distante et retourne le nombre de flux qu'elle contient,
|
||||
* ou null si elle est injoignable ou invalide.
|
||||
*/
|
||||
public function validateSource(string $sourceUrl): ?int
|
||||
{
|
||||
$feeds = $this->fetchSource($sourceUrl);
|
||||
|
||||
return $feeds === null ? null : count($feeds);
|
||||
}
|
||||
|
||||
private function loadEmbedded(): array
|
||||
{
|
||||
try {
|
||||
$raw = file_get_contents(resource_path('data/bloomflux.json'));
|
||||
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $data['feeds'] ?? [];
|
||||
return $this->normalize($data['feeds'] ?? []);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Catalogue bloomflux.json illisible', ['error' => $e->getMessage()]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge une source externe : réseau si joignable (et met le cache à jour),
|
||||
* sinon dernière copie en cache.
|
||||
*/
|
||||
private function loadRemoteSource(string $sourceUrl): array
|
||||
{
|
||||
$cacheKey = 'bloomflux.source.'.md5($sourceUrl);
|
||||
|
||||
$feeds = $this->fetchSource($sourceUrl);
|
||||
|
||||
if ($feeds !== null) {
|
||||
Cache::forever($cacheKey, $feeds);
|
||||
|
||||
return $feeds;
|
||||
}
|
||||
|
||||
Log::warning('Source de catalogue injoignable, utilisation du cache', ['source' => $sourceUrl]);
|
||||
|
||||
return Cache::get($cacheKey, []);
|
||||
}
|
||||
|
||||
private function fetchSource(string $sourceUrl): ?array
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(6)
|
||||
->withHeaders(['User-Agent' => 'BloomFeed/1.0'])
|
||||
->get($sourceUrl);
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! isset($data['feeds']) || ! is_array($data['feeds'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalize($data['feeds']);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalize(array $feeds): array
|
||||
{
|
||||
$clean = [];
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
if (! is_array($feed) || empty($feed['title']) || empty($feed['url'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean[] = [
|
||||
'title' => (string) $feed['title'],
|
||||
'url' => (string) $feed['url'],
|
||||
'category' => (string) ($feed['category'] ?? 'Divers'),
|
||||
'lang' => strtolower((string) ($feed['lang'] ?? 'en')),
|
||||
'description' => isset($feed['description']) ? (string) $feed['description'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_owner')->default(false);
|
||||
$table->string('locale', 5)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_owner', 'locale']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Navigation
|
||||
'articles' => 'Artikel',
|
||||
'catalog' => 'Katalog',
|
||||
'my_feeds' => 'Meine Feeds',
|
||||
'settings' => 'Einstellungen',
|
||||
'logout' => 'Abmelden',
|
||||
'change_theme' => 'Design wechseln',
|
||||
'language' => 'Sprache',
|
||||
'self_hosting' => 'Self-Hosting',
|
||||
'login' => 'Anmelden',
|
||||
'create_account' => 'Konto erstellen',
|
||||
'source_code' => 'Quellcode',
|
||||
'footer_tagline' => 'BloomFeed — Open-Source-Projekt, selbst gehostet.',
|
||||
'meta_description' => 'BloomFeed ist ein persönlicher, selbst gehosteter RSS-Aggregator mit integriertem Reader.',
|
||||
|
||||
// Startseite
|
||||
'hero_eyebrow' => 'Selbst gehostet · Privatsphäre',
|
||||
'hero_title' => 'Deine RSS-Feeds, gründlich gelesen, ohne BloomFeed je zu verlassen.',
|
||||
'hero_lede' => 'BloomFeed bündelt deine Lieblings-Feeds und extrahiert deren vollständigen Inhalt auf Abruf, direkt auf deinem eigenen Server. Keine Daten an Dritte.',
|
||||
'how_to_self_host' => 'Wie hostet man es selbst?',
|
||||
'feature_feeds_title' => 'Deine Feeds, dein Stil',
|
||||
'feature_feeds_text' => 'Füge beliebige RSS- oder Atom-Feeds hinzu, organisiere sie und behalte Ungelesenes im Blick.',
|
||||
'feature_reader_title' => 'Integrierter Reader',
|
||||
'feature_reader_text' => 'BloomFeed ruft die Originalseite ab, extrahiert den Hauptinhalt und zeigt ihn sauber an — Schluss mit zehn offenen Tabs.',
|
||||
'feature_home_title' => 'Bei dir, für dich',
|
||||
'feature_home_text' => 'Eine BloomFeed-Instanz = ein Besitzer. Keine öffentlichen Registrierungen standardmäßig, kein Tracking, dein Server, deine Daten.',
|
||||
|
||||
// Authentifizierung
|
||||
'login_title' => 'Anmelden',
|
||||
'login_subtitle' => 'Zugriff auf deine Feeds und deine Lektüre',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'password' => 'Passwort',
|
||||
'confirm_password' => 'Passwort bestätigen',
|
||||
'name' => 'Name',
|
||||
'no_account_yet' => 'Noch kein Konto?',
|
||||
'already_account' => 'Bereits ein Konto?',
|
||||
'register_owner_title' => 'Instanz einrichten',
|
||||
'register_owner_subtitle' => 'Dieses Konto wird das Besitzerkonto dieser BloomFeed-Instanz',
|
||||
'register_title' => 'Konto erstellen',
|
||||
'register_subtitle' => 'Tritt dieser BloomFeed-Instanz bei',
|
||||
'register_button' => 'Mein Konto erstellen',
|
||||
'registrations_closed' => 'Registrierungen sind auf dieser Instanz geschlossen. Bitte melde dich an.',
|
||||
'invalid_credentials' => 'Diese Zugangsdaten passen zu keinem Konto.',
|
||||
|
||||
// Übersicht
|
||||
'latest_articles' => 'Neueste Artikel',
|
||||
'unread_only' => 'Nur Ungelesene',
|
||||
'show_all' => 'Alle anzeigen',
|
||||
'empty_no_feeds' => 'Füge deinen ersten RSS-Feed über die Seitenleiste hinzu, um loszulegen.',
|
||||
'empty_no_articles' => 'Noch keine Artikel. Aktualisiere deine Feeds.',
|
||||
|
||||
// Seitenleiste
|
||||
'feeds_label' => 'Feeds',
|
||||
'all_articles' => 'Alle Artikel',
|
||||
'add_feed' => 'Feed hinzufügen',
|
||||
'add' => 'Hinzufügen',
|
||||
'refresh_all' => 'Alle aktualisieren',
|
||||
'manage_feeds' => 'Meine Feeds verwalten',
|
||||
'feed_url_placeholder' => 'https://beispiel.de/rss.xml',
|
||||
|
||||
// Feed-Verwaltung
|
||||
'custom_title_optional' => 'Eigener Titel (optional)',
|
||||
'articles_count' => ':count Artikel',
|
||||
'refreshed_ago' => 'aktualisiert :time',
|
||||
'refresh' => 'Aktualisieren',
|
||||
'delete' => 'Löschen',
|
||||
'confirm_delete_feed' => 'Diesen Feed und alle seine Artikel löschen?',
|
||||
'no_feeds_yet' => 'Du hast noch keine Feeds hinzugefügt.',
|
||||
'feed_added' => 'Feed mit :count Artikel(n) hinzugefügt.',
|
||||
'feed_added_error' => 'Feed hinzugefügt, aber der erste Abruf schlug fehl: :error',
|
||||
'feed_deleted' => 'Feed gelöscht.',
|
||||
'refresh_failed' => 'Abruf fehlgeschlagen: :error',
|
||||
'new_articles' => ':count neue(r) Artikel.',
|
||||
'new_articles_across' => ':count neue(r) Artikel aus :feeds Feeds.',
|
||||
|
||||
// Reader
|
||||
'back_to_articles' => 'Zurück zu den Artikeln',
|
||||
'view_original' => 'Originalquelle ansehen',
|
||||
'fetch_error' => 'Der vollständige Inhalt dieses Artikels konnte nicht abgerufen werden (:error).',
|
||||
'retry' => 'Erneut versuchen',
|
||||
'fetching' => 'Inhalt wird abgerufen…',
|
||||
|
||||
// Katalog
|
||||
'catalog_title' => 'Feed-Katalog',
|
||||
'catalog_lede' => 'Eine kuratierte Auswahl an RSS-Feeds, die mit BloomFeed mitgeliefert wird — mit einem Klick zu deiner Sammlung hinzufügen.',
|
||||
'search_placeholder' => 'Feed suchen… (Name, Beschreibung, Kategorie)',
|
||||
'all' => 'Alle',
|
||||
'added' => 'Hinzugefügt',
|
||||
'catalog_empty' => 'Der Katalog ist derzeit leer.',
|
||||
'no_match' => 'Kein Feed entspricht deiner Suche.',
|
||||
|
||||
// Einstellungen
|
||||
'settings_title' => 'Instanz-Einstellungen',
|
||||
'registrations_section' => 'Öffentliche Registrierungen',
|
||||
'registrations_desc' => 'Erlaubt jedem Besucher, über die Anmeldeseite ein Konto auf dieser Instanz zu erstellen.',
|
||||
'registrations_state_open' => 'Registrierungen sind derzeit geöffnet.',
|
||||
'registrations_state_closed' => 'Registrierungen sind derzeit geschlossen.',
|
||||
'open_registrations' => 'Registrierungen öffnen',
|
||||
'close_registrations' => 'Registrierungen schließen',
|
||||
'registrations_now_open' => 'Registrierungen sind jetzt geöffnet.',
|
||||
'registrations_now_closed' => 'Registrierungen sind jetzt geschlossen.',
|
||||
'create_user_section' => 'Konto erstellen',
|
||||
'create_user_desc' => 'Erstelle direkt ein Konto für eine vertraute Person, ohne öffentliche Registrierungen zu öffnen.',
|
||||
'user_created' => 'Konto erstellt.',
|
||||
'users_section' => 'Vorhandene Konten',
|
||||
'owner_badge' => 'Besitzer',
|
||||
|
||||
// Self-Hosting-Seite
|
||||
'selfhost_title' => 'BloomFeed selbst hosten',
|
||||
'selfhost_intro' => 'BloomFeed ist dafür gemacht, auf deinem eigenen Server zu laufen — für dich (oder deinen Haushalt). Alles läuft in Docker: die Anwendung, der Scheduler und die MariaDB-Datenbank.',
|
||||
'prereq_title' => 'Voraussetzungen',
|
||||
'prereq_docker' => 'Ein Linux-Server mit Docker und dem Docker-Compose-Plugin.',
|
||||
'prereq_port' => 'Port 7081 frei (konfigurierbar in docker-compose.yml).',
|
||||
'prereq_proxy' => 'Optional: ein Domainname und ein Reverse Proxy für HTTPS.',
|
||||
'install_title' => 'Installation',
|
||||
'install_step_clone' => 'Klone das Repository auf deinen Server.',
|
||||
'install_step_script' => 'Führe das Deployment-Skript aus:',
|
||||
'install_env_note' => 'Es erstellt automatisch eine .env-Datei (aus .env.example) und generiert fehlende Zufallsgeheimnisse (APP_KEY, Datenbank-Passwörter) — sie werden bei späteren Deployments nie neu generiert.',
|
||||
'install_step_containers' => 'Das Skript baut die Images und startet die Container (Anwendung, Scheduler, MariaDB).',
|
||||
'install_step_owner' => 'Öffne http://dein-server:7081 und erstelle das Besitzerkonto — Registrierungen schließen sich danach automatisch.',
|
||||
'config_title' => 'Konfiguration',
|
||||
'config_registrations' => 'Über das Einstellungsmenü kann das Besitzerkonto direkt Konten für vertraute Personen erstellen oder öffentliche Registrierungen mit einem Klick öffnen/schließen.',
|
||||
'config_language' => 'Die Oberfläche ist auf Französisch, Englisch, Spanisch und Deutsch verfügbar — jeder Nutzer wählt seine Sprache über den Selektor oben auf der Seite.',
|
||||
'config_catalog' => 'Der integrierte Feed-Katalog lässt sich durch Bearbeiten von resources/data/bloomflux.json und erneutes Deployment anpassen.',
|
||||
'update_title' => 'Aktualisieren',
|
||||
'update_text' => 'Führe einfach das Deployment-Skript erneut aus: Es holt die neuesten Änderungen und baut den Stack neu, ohne deine Daten anzutasten.',
|
||||
'uninstall_title' => 'Deinstallieren',
|
||||
'uninstall_text' => 'Das Skript uninstall.sh stoppt und entfernt den Docker-Stack sauber. Es fragt vor dem Löschen deiner Daten explizit nach und entfernt den Quellcode nie ohne separate Bestätigung.',
|
||||
'source_title' => 'Quellcode',
|
||||
|
||||
// Katalogquellen & Sprachfilter
|
||||
'all_languages' => 'Alle Sprachen',
|
||||
'catalog_sources_section' => 'Katalogquellen',
|
||||
'catalog_sources_desc' => 'Füge weitere Feed-Kataloge hinzu, indem du die Raw-URL einer bloomflux.json-Datei aus einem anderen Git-Repository angibst. Die Feeds werden mit dem integrierten Katalog zusammengeführt; ist die Quelle nicht erreichbar, wird die letzte bekannte Kopie verwendet.',
|
||||
'source_url_label' => 'URL der bloomflux.json-Datei',
|
||||
'add_source' => 'Quelle hinzufügen',
|
||||
'remove' => 'Entfernen',
|
||||
'source_added' => 'Quelle hinzugefügt (:count Feeds).',
|
||||
'source_already_added' => 'Diese Quelle ist bereits registriert.',
|
||||
'source_removed' => 'Quelle entfernt.',
|
||||
'source_invalid' => 'Diese Quelle konnte nicht gelesen werden (gültige bloomflux.json-Datei erwartet).',
|
||||
'no_sources' => 'Noch keine externen Quellen.',
|
||||
];
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Navigation
|
||||
'articles' => 'Articles',
|
||||
'catalog' => 'Catalog',
|
||||
'my_feeds' => 'My feeds',
|
||||
'settings' => 'Settings',
|
||||
'logout' => 'Log out',
|
||||
'change_theme' => 'Switch theme',
|
||||
'language' => 'Language',
|
||||
'self_hosting' => 'Self-hosting',
|
||||
'login' => 'Log in',
|
||||
'create_account' => 'Create an account',
|
||||
'source_code' => 'Source code',
|
||||
'footer_tagline' => 'BloomFeed — open-source, self-hosted project.',
|
||||
'meta_description' => 'BloomFeed is a personal, self-hosted RSS aggregator with a built-in reader.',
|
||||
|
||||
// Home
|
||||
'hero_eyebrow' => 'Self-hosted · Privacy first',
|
||||
'hero_title' => 'Your RSS feeds, read in depth, without ever leaving BloomFeed.',
|
||||
'hero_lede' => 'BloomFeed gathers your favorite feeds and extracts their full content on demand, right on your own server. No data ever sent to third parties.',
|
||||
'how_to_self_host' => 'How to self-host it?',
|
||||
'feature_feeds_title' => 'Your feeds, your way',
|
||||
'feature_feeds_text' => 'Add any RSS or Atom feed, organize them and keep track of your unread items at a glance.',
|
||||
'feature_reader_title' => 'Built-in deep reader',
|
||||
'feature_reader_text' => 'BloomFeed fetches the source page, extracts the main content and displays it cleanly — no more juggling ten tabs.',
|
||||
'feature_home_title' => 'Your home, your rules',
|
||||
'feature_home_text' => 'One BloomFeed instance = one owner. No public sign-ups by default, no tracking, your server, your data.',
|
||||
|
||||
// Authentication
|
||||
'login_title' => 'Log in',
|
||||
'login_subtitle' => 'Access your feeds and your reading list',
|
||||
'email' => 'Email address',
|
||||
'password' => 'Password',
|
||||
'confirm_password' => 'Confirm password',
|
||||
'name' => 'Name',
|
||||
'no_account_yet' => 'No account yet?',
|
||||
'already_account' => 'Already have an account?',
|
||||
'register_owner_title' => 'Set up your instance',
|
||||
'register_owner_subtitle' => 'This account will be the owner of this BloomFeed instance',
|
||||
'register_title' => 'Create an account',
|
||||
'register_subtitle' => 'Join this BloomFeed instance',
|
||||
'register_button' => 'Create my account',
|
||||
'registrations_closed' => 'Registrations are closed on this instance. Please log in.',
|
||||
'invalid_credentials' => 'These credentials do not match any account.',
|
||||
|
||||
// Dashboard
|
||||
'latest_articles' => 'Latest articles',
|
||||
'unread_only' => 'Unread only',
|
||||
'show_all' => 'Show all',
|
||||
'empty_no_feeds' => 'Add your first RSS feed from the sidebar to get started.',
|
||||
'empty_no_articles' => 'No articles yet. Try refreshing your feeds.',
|
||||
|
||||
// Sidebar
|
||||
'feeds_label' => 'Feeds',
|
||||
'all_articles' => 'All articles',
|
||||
'add_feed' => 'Add a feed',
|
||||
'add' => 'Add',
|
||||
'refresh_all' => 'Refresh all',
|
||||
'manage_feeds' => 'Manage my feeds',
|
||||
'feed_url_placeholder' => 'https://example.com/rss.xml',
|
||||
|
||||
// Feed management
|
||||
'custom_title_optional' => 'Custom title (optional)',
|
||||
'articles_count' => ':count article(s)',
|
||||
'refreshed_ago' => 'refreshed :time',
|
||||
'refresh' => 'Refresh',
|
||||
'delete' => 'Delete',
|
||||
'confirm_delete_feed' => 'Delete this feed and all its articles?',
|
||||
'no_feeds_yet' => "You haven't added any feeds yet.",
|
||||
'feed_added' => 'Feed added with :count article(s).',
|
||||
'feed_added_error' => 'Feed added, but the first fetch failed: :error',
|
||||
'feed_deleted' => 'Feed deleted.',
|
||||
'refresh_failed' => 'Fetch failed: :error',
|
||||
'new_articles' => ':count new article(s).',
|
||||
'new_articles_across' => ':count new article(s) across :feeds feeds.',
|
||||
|
||||
// Reader
|
||||
'back_to_articles' => 'Back to articles',
|
||||
'view_original' => 'View original source',
|
||||
'fetch_error' => 'Could not fetch the full content of this article (:error).',
|
||||
'retry' => 'Retry',
|
||||
'fetching' => 'Fetching content…',
|
||||
|
||||
// Catalog
|
||||
'catalog_title' => 'Feed catalog',
|
||||
'catalog_lede' => 'A curated selection of RSS feeds shipped with BloomFeed — add them to your collection in one click.',
|
||||
'search_placeholder' => 'Search feeds… (name, description, category)',
|
||||
'all' => 'All',
|
||||
'added' => 'Added',
|
||||
'catalog_empty' => 'The catalog is empty for now.',
|
||||
'no_match' => 'No feed matches your search.',
|
||||
|
||||
// Settings
|
||||
'settings_title' => 'Instance settings',
|
||||
'registrations_section' => 'Public registrations',
|
||||
'registrations_desc' => 'Allow any visitor to create an account on this instance from the login page.',
|
||||
'registrations_state_open' => 'Registrations are currently open.',
|
||||
'registrations_state_closed' => 'Registrations are currently closed.',
|
||||
'open_registrations' => 'Open registrations',
|
||||
'close_registrations' => 'Close registrations',
|
||||
'registrations_now_open' => 'Registrations are now open.',
|
||||
'registrations_now_closed' => 'Registrations are now closed.',
|
||||
'create_user_section' => 'Create an account',
|
||||
'create_user_desc' => 'Create an account directly for someone you trust, without opening public registrations.',
|
||||
'user_created' => 'Account created.',
|
||||
'users_section' => 'Existing accounts',
|
||||
'owner_badge' => 'Owner',
|
||||
|
||||
// Self-hosting page
|
||||
'selfhost_title' => 'Self-host BloomFeed',
|
||||
'selfhost_intro' => 'BloomFeed is designed to run on your own server, for you (or your household). Everything runs in Docker: the application, the scheduler and the MariaDB database.',
|
||||
'prereq_title' => 'Prerequisites',
|
||||
'prereq_docker' => 'A Linux server with Docker and the Docker Compose plugin.',
|
||||
'prereq_port' => 'Port 7081 available (configurable in docker-compose.yml).',
|
||||
'prereq_proxy' => 'Optional: a domain name and a reverse proxy if you want HTTPS.',
|
||||
'install_title' => 'Installation',
|
||||
'install_step_clone' => 'Clone the repository on your server.',
|
||||
'install_step_script' => 'Run the deployment script:',
|
||||
'install_env_note' => 'It automatically creates a .env file (from .env.example) and generates random secrets (APP_KEY, database passwords) when missing — they are never regenerated on subsequent deployments.',
|
||||
'install_step_containers' => 'The script builds the images and starts the containers (application, scheduler, MariaDB).',
|
||||
'install_step_owner' => 'Open http://your-server:7081 and create the owner account — registrations close automatically afterwards.',
|
||||
'config_title' => 'Configuration',
|
||||
'config_registrations' => 'From the Settings menu, the owner account can directly create accounts for trusted people, or open/close public registrations with one click.',
|
||||
'config_language' => 'The interface is available in French, English, Spanish and German — each user picks their language from the selector at the top of the page.',
|
||||
'config_catalog' => 'The built-in feed catalog can be customized by editing resources/data/bloomflux.json and redeploying.',
|
||||
'update_title' => 'Update',
|
||||
'update_text' => 'Simply re-run the deployment script: it pulls the latest changes and rebuilds the stack without touching your data.',
|
||||
'uninstall_title' => 'Uninstall',
|
||||
'uninstall_text' => 'The uninstall.sh script cleanly stops and removes the Docker stack. It asks for explicit confirmation before deleting your data and never removes the source code without a separate confirmation.',
|
||||
'source_title' => 'Source code',
|
||||
|
||||
// Catalog sources & language filter
|
||||
'all_languages' => 'All languages',
|
||||
'catalog_sources_section' => 'Catalog sources',
|
||||
'catalog_sources_desc' => 'Add more feed catalogs by providing the raw URL of a bloomflux.json file hosted in another git repository. Feeds are merged with the built-in catalog, and the last known copy is served if the source is unreachable.',
|
||||
'source_url_label' => 'URL of the bloomflux.json file',
|
||||
'add_source' => 'Add source',
|
||||
'remove' => 'Remove',
|
||||
'source_added' => 'Source added (:count feeds).',
|
||||
'source_already_added' => 'This source is already registered.',
|
||||
'source_removed' => 'Source removed.',
|
||||
'source_invalid' => 'Could not read this source (a valid bloomflux.json file is expected).',
|
||||
'no_sources' => 'No external sources yet.',
|
||||
];
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Navegación
|
||||
'articles' => 'Artículos',
|
||||
'catalog' => 'Catálogo',
|
||||
'my_feeds' => 'Mis fuentes',
|
||||
'settings' => 'Ajustes',
|
||||
'logout' => 'Cerrar sesión',
|
||||
'change_theme' => 'Cambiar de tema',
|
||||
'language' => 'Idioma',
|
||||
'self_hosting' => 'Autoalojamiento',
|
||||
'login' => 'Iniciar sesión',
|
||||
'create_account' => 'Crear una cuenta',
|
||||
'source_code' => 'Código fuente',
|
||||
'footer_tagline' => 'BloomFeed — proyecto open source y autoalojado.',
|
||||
'meta_description' => 'BloomFeed es un agregador RSS personal y autoalojado, con lector integrado.',
|
||||
|
||||
// Inicio
|
||||
'hero_eyebrow' => 'Autoalojado · Privacidad',
|
||||
'hero_title' => 'Tus fuentes RSS, leídas en profundidad, sin salir nunca de BloomFeed.',
|
||||
'hero_lede' => 'BloomFeed centraliza tus fuentes favoritas y extrae su contenido completo bajo demanda, directamente en tu propio servidor. Ningún dato se envía a terceros.',
|
||||
'how_to_self_host' => '¿Cómo autoalojarlo?',
|
||||
'feature_feeds_title' => 'Fuentes personalizadas',
|
||||
'feature_feeds_text' => 'Añade cualquier fuente RSS o Atom, organízalas y sigue tus no leídos de un vistazo.',
|
||||
'feature_reader_title' => 'Lector integrado',
|
||||
'feature_reader_text' => 'BloomFeed recupera la página original, extrae el contenido principal y lo muestra limpiamente — se acabó abrir diez pestañas.',
|
||||
'feature_home_title' => 'En tu casa, para ti',
|
||||
'feature_home_text' => 'Una instancia de BloomFeed = un propietario. Sin registros públicos por defecto, sin rastreo, tu servidor, tus datos.',
|
||||
|
||||
// Autenticación
|
||||
'login_title' => 'Iniciar sesión',
|
||||
'login_subtitle' => 'Accede a tus fuentes y a tu lectura',
|
||||
'email' => 'Correo electrónico',
|
||||
'password' => 'Contraseña',
|
||||
'confirm_password' => 'Confirmar la contraseña',
|
||||
'name' => 'Nombre',
|
||||
'no_account_yet' => '¿Aún no tienes cuenta?',
|
||||
'already_account' => '¿Ya tienes una cuenta?',
|
||||
'register_owner_title' => 'Configura tu instancia',
|
||||
'register_owner_subtitle' => 'Esta cuenta será la cuenta propietaria de esta instancia de BloomFeed',
|
||||
'register_title' => 'Crear una cuenta',
|
||||
'register_subtitle' => 'Únete a esta instancia de BloomFeed',
|
||||
'register_button' => 'Crear mi cuenta',
|
||||
'registrations_closed' => 'Los registros están cerrados en esta instancia. Inicia sesión.',
|
||||
'invalid_credentials' => 'Estas credenciales no corresponden a ninguna cuenta.',
|
||||
|
||||
// Panel
|
||||
'latest_articles' => 'Últimos artículos',
|
||||
'unread_only' => 'Solo no leídos',
|
||||
'show_all' => 'Mostrar todo',
|
||||
'empty_no_feeds' => 'Añade tu primera fuente RSS desde la barra lateral para empezar.',
|
||||
'empty_no_articles' => 'Todavía no hay artículos. Prueba a actualizar tus fuentes.',
|
||||
|
||||
// Barra lateral
|
||||
'feeds_label' => 'Fuentes',
|
||||
'all_articles' => 'Todos los artículos',
|
||||
'add_feed' => 'Añadir una fuente',
|
||||
'add' => 'Añadir',
|
||||
'refresh_all' => 'Actualizar todo',
|
||||
'manage_feeds' => 'Gestionar mis fuentes',
|
||||
'feed_url_placeholder' => 'https://ejemplo.com/rss.xml',
|
||||
|
||||
// Gestión de fuentes
|
||||
'custom_title_optional' => 'Título personalizado (opcional)',
|
||||
'articles_count' => ':count artículo(s)',
|
||||
'refreshed_ago' => 'actualizado :time',
|
||||
'refresh' => 'Actualizar',
|
||||
'delete' => 'Eliminar',
|
||||
'confirm_delete_feed' => '¿Eliminar esta fuente y todos sus artículos?',
|
||||
'no_feeds_yet' => 'Todavía no has añadido ninguna fuente.',
|
||||
'feed_added' => 'Fuente añadida con :count artículo(s).',
|
||||
'feed_added_error' => 'Fuente añadida, pero la primera recuperación falló: :error',
|
||||
'feed_deleted' => 'Fuente eliminada.',
|
||||
'refresh_failed' => 'Fallo al recuperar: :error',
|
||||
'new_articles' => ':count artículo(s) nuevo(s).',
|
||||
'new_articles_across' => ':count artículo(s) nuevo(s) en :feeds fuentes.',
|
||||
|
||||
// Lector
|
||||
'back_to_articles' => 'Volver a los artículos',
|
||||
'view_original' => 'Ver la fuente original',
|
||||
'fetch_error' => 'No se pudo recuperar el contenido completo de este artículo (:error).',
|
||||
'retry' => 'Reintentar',
|
||||
'fetching' => 'Recuperando el contenido…',
|
||||
|
||||
// Catálogo
|
||||
'catalog_title' => 'Catálogo de fuentes',
|
||||
'catalog_lede' => 'Una selección de fuentes RSS incluida con BloomFeed — añádelas a tu colección con un clic.',
|
||||
'search_placeholder' => 'Buscar una fuente… (nombre, descripción, categoría)',
|
||||
'all' => 'Todo',
|
||||
'added' => 'Añadida',
|
||||
'catalog_empty' => 'El catálogo está vacío por ahora.',
|
||||
'no_match' => 'Ninguna fuente coincide con tu búsqueda.',
|
||||
|
||||
// Ajustes
|
||||
'settings_title' => 'Ajustes de la instancia',
|
||||
'registrations_section' => 'Registros públicos',
|
||||
'registrations_desc' => 'Permite que cualquier visitante cree una cuenta en esta instancia desde la página de inicio de sesión.',
|
||||
'registrations_state_open' => 'Los registros están actualmente abiertos.',
|
||||
'registrations_state_closed' => 'Los registros están actualmente cerrados.',
|
||||
'open_registrations' => 'Abrir los registros',
|
||||
'close_registrations' => 'Cerrar los registros',
|
||||
'registrations_now_open' => 'Los registros están ahora abiertos.',
|
||||
'registrations_now_closed' => 'Los registros están ahora cerrados.',
|
||||
'create_user_section' => 'Crear una cuenta',
|
||||
'create_user_desc' => 'Crea directamente una cuenta para alguien de confianza, sin abrir los registros públicos.',
|
||||
'user_created' => 'Cuenta creada.',
|
||||
'users_section' => 'Cuentas existentes',
|
||||
'owner_badge' => 'Propietario',
|
||||
|
||||
// Página de autoalojamiento
|
||||
'selfhost_title' => 'Autoalojar BloomFeed',
|
||||
'selfhost_intro' => 'BloomFeed está pensado para instalarse en tu propio servidor, para ti (o tu hogar). Todo funciona en Docker: la aplicación, el planificador y la base de datos MariaDB.',
|
||||
'prereq_title' => 'Requisitos previos',
|
||||
'prereq_docker' => 'Un servidor Linux con Docker y el plugin Docker Compose.',
|
||||
'prereq_port' => 'El puerto 7081 libre (configurable en docker-compose.yml).',
|
||||
'prereq_proxy' => 'Opcional: un nombre de dominio y un proxy inverso si quieres HTTPS.',
|
||||
'install_title' => 'Instalación',
|
||||
'install_step_clone' => 'Clona el repositorio en tu servidor.',
|
||||
'install_step_script' => 'Ejecuta el script de despliegue:',
|
||||
'install_env_note' => 'Crea automáticamente un archivo .env (a partir de .env.example) y genera secretos aleatorios (APP_KEY, contraseñas de base de datos) si faltan — nunca se regeneran en despliegues posteriores.',
|
||||
'install_step_containers' => 'El script construye las imágenes y arranca los contenedores (aplicación, planificador, MariaDB).',
|
||||
'install_step_owner' => 'Abre http://tu-servidor:7081 y crea la cuenta propietaria — los registros se cierran automáticamente después.',
|
||||
'config_title' => 'Configuración',
|
||||
'config_registrations' => 'Desde el menú Ajustes, la cuenta propietaria puede crear cuentas directamente para personas de confianza, o abrir/cerrar los registros públicos con un clic.',
|
||||
'config_language' => 'La interfaz está disponible en francés, inglés, español y alemán — cada usuario elige su idioma con el selector de la parte superior.',
|
||||
'config_catalog' => 'El catálogo de fuentes integrado se personaliza editando resources/data/bloomflux.json y volviendo a desplegar.',
|
||||
'update_title' => 'Actualizar',
|
||||
'update_text' => 'Simplemente vuelve a ejecutar el script de despliegue: recupera los últimos cambios y reconstruye la pila sin tocar tus datos.',
|
||||
'uninstall_title' => 'Desinstalar',
|
||||
'uninstall_text' => 'El script uninstall.sh detiene y elimina limpiamente la pila Docker. Pide confirmación explícita antes de borrar tus datos y nunca elimina el código fuente sin una confirmación separada.',
|
||||
'source_title' => 'Código fuente',
|
||||
|
||||
// Fuentes de catálogo y filtro de idioma
|
||||
'all_languages' => 'Todos los idiomas',
|
||||
'catalog_sources_section' => 'Fuentes del catálogo',
|
||||
'catalog_sources_desc' => 'Añade otros catálogos de fuentes indicando la URL directa (raw) de un archivo bloomflux.json alojado en otro repositorio git. Las fuentes se fusionan con el catálogo integrado, y se sirve la última copia conocida si la fuente no está disponible.',
|
||||
'source_url_label' => 'URL del archivo bloomflux.json',
|
||||
'add_source' => 'Añadir la fuente',
|
||||
'remove' => 'Quitar',
|
||||
'source_added' => 'Fuente añadida (:count fuentes).',
|
||||
'source_already_added' => 'Esta fuente ya está registrada.',
|
||||
'source_removed' => 'Fuente eliminada.',
|
||||
'source_invalid' => 'No se pudo leer esta fuente (se espera un archivo bloomflux.json válido).',
|
||||
'no_sources' => 'Ninguna fuente externa por ahora.',
|
||||
];
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Navigation
|
||||
'articles' => 'Articles',
|
||||
'catalog' => 'Catalogue',
|
||||
'my_feeds' => 'Mes flux',
|
||||
'settings' => 'Paramètres',
|
||||
'logout' => 'Déconnexion',
|
||||
'change_theme' => 'Changer de thème',
|
||||
'language' => 'Langue',
|
||||
'self_hosting' => 'Self-hosting',
|
||||
'login' => 'Se connecter',
|
||||
'create_account' => 'Créer un compte',
|
||||
'source_code' => 'Code source',
|
||||
'footer_tagline' => 'BloomFeed — projet open-source, auto-hébergé.',
|
||||
'meta_description' => 'BloomFeed est un agrégateur RSS personnel et auto-hébergé, avec lecture approfondie intégrée.',
|
||||
|
||||
// Accueil
|
||||
'hero_eyebrow' => 'Auto-hébergé · Vie privée',
|
||||
'hero_title' => 'Vos flux RSS, lus en profondeur, sans jamais quitter BloomFeed.',
|
||||
'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.',
|
||||
'how_to_self_host' => "Comment l'auto-héberger ?",
|
||||
'feature_feeds_title' => 'Flux personnalisés',
|
||||
'feature_feeds_text' => "Ajoutez n'importe quel flux RSS ou Atom, organisez-les et suivez vos non-lus en un coup d'œil.",
|
||||
'feature_reader_title' => 'Lecture approfondie intégrée',
|
||||
'feature_reader_text' => "BloomFeed récupère la page source, en extrait le contenu principal et l'affiche proprement — plus besoin d'ouvrir dix onglets.",
|
||||
'feature_home_title' => 'Chez vous, pour vous',
|
||||
'feature_home_text' => "Une instance BloomFeed = un propriétaire. Pas d'inscriptions publiques par défaut, pas de tracking, votre serveur, vos données.",
|
||||
|
||||
// Authentification
|
||||
'login_title' => 'Connexion',
|
||||
'login_subtitle' => 'Accédez à vos flux et à votre lecture approfondie',
|
||||
'email' => 'Adresse e-mail',
|
||||
'password' => 'Mot de passe',
|
||||
'confirm_password' => 'Confirmer le mot de passe',
|
||||
'name' => 'Nom',
|
||||
'no_account_yet' => 'Pas encore de compte ?',
|
||||
'already_account' => 'Déjà un compte ?',
|
||||
'register_owner_title' => 'Configurer votre instance',
|
||||
'register_owner_subtitle' => 'Ce compte sera le compte propriétaire de cette instance BloomFeed',
|
||||
'register_title' => 'Créer un compte',
|
||||
'register_subtitle' => 'Rejoignez cette instance BloomFeed',
|
||||
'register_button' => 'Créer mon compte',
|
||||
'registrations_closed' => 'Les inscriptions sont fermées sur cette instance. Connectez-vous.',
|
||||
'invalid_credentials' => 'Ces identifiants ne correspondent à aucun compte.',
|
||||
|
||||
// Tableau de bord
|
||||
'latest_articles' => 'Derniers articles',
|
||||
'unread_only' => 'Non lus uniquement',
|
||||
'show_all' => 'Tout afficher',
|
||||
'empty_no_feeds' => 'Ajoutez votre premier flux RSS depuis la barre latérale pour commencer.',
|
||||
'empty_no_articles' => "Aucun article pour le moment. Essayez d'actualiser vos flux.",
|
||||
|
||||
// Barre latérale
|
||||
'feeds_label' => 'Flux',
|
||||
'all_articles' => 'Tous les articles',
|
||||
'add_feed' => 'Ajouter un flux',
|
||||
'add' => 'Ajouter',
|
||||
'refresh_all' => 'Tout actualiser',
|
||||
'manage_feeds' => 'Gérer mes flux',
|
||||
'feed_url_placeholder' => 'https://exemple.com/rss.xml',
|
||||
|
||||
// Gestion des flux
|
||||
'custom_title_optional' => 'Titre personnalisé (optionnel)',
|
||||
'articles_count' => ':count article(s)',
|
||||
'refreshed_ago' => 'actualisé :time',
|
||||
'refresh' => 'Actualiser',
|
||||
'delete' => 'Supprimer',
|
||||
'confirm_delete_feed' => 'Supprimer ce flux et tous ses articles ?',
|
||||
'no_feeds_yet' => "Vous n'avez pas encore ajouté de flux.",
|
||||
'feed_added' => 'Flux ajouté avec :count article(s).',
|
||||
'feed_added_error' => 'Flux ajouté, mais la première récupération a échoué : :error',
|
||||
'feed_deleted' => 'Flux supprimé.',
|
||||
'refresh_failed' => 'Échec de la récupération : :error',
|
||||
'new_articles' => ':count nouvel(aux) article(s).',
|
||||
'new_articles_across' => ':count nouvel(aux) article(s) sur :feeds flux.',
|
||||
|
||||
// Lecteur
|
||||
'back_to_articles' => 'Retour aux articles',
|
||||
'view_original' => 'Voir la source originale',
|
||||
'fetch_error' => 'Impossible de récupérer le contenu complet de cet article (:error).',
|
||||
'retry' => 'Réessayer',
|
||||
'fetching' => 'Récupération du contenu en cours…',
|
||||
|
||||
// Catalogue
|
||||
'catalog_title' => 'Catalogue de flux',
|
||||
'catalog_lede' => 'Une sélection de flux RSS livrée avec BloomFeed — ajoutez-les à votre collection en un clic.',
|
||||
'search_placeholder' => 'Rechercher un flux… (nom, description, catégorie)',
|
||||
'all' => 'Tout',
|
||||
'added' => 'Ajouté',
|
||||
'catalog_empty' => 'Le catalogue est vide pour le moment.',
|
||||
'no_match' => 'Aucun flux ne correspond à votre recherche.',
|
||||
|
||||
// Paramètres
|
||||
'settings_title' => "Paramètres de l'instance",
|
||||
'registrations_section' => 'Inscriptions publiques',
|
||||
'registrations_desc' => "Autorise n'importe quel visiteur à créer un compte sur cette instance depuis la page de connexion.",
|
||||
'registrations_state_open' => 'Les inscriptions sont actuellement ouvertes.',
|
||||
'registrations_state_closed' => 'Les inscriptions sont actuellement fermées.',
|
||||
'open_registrations' => 'Ouvrir les inscriptions',
|
||||
'close_registrations' => 'Fermer les inscriptions',
|
||||
'registrations_now_open' => 'Les inscriptions sont maintenant ouvertes.',
|
||||
'registrations_now_closed' => 'Les inscriptions sont maintenant fermées.',
|
||||
'create_user_section' => 'Créer un compte',
|
||||
'create_user_desc' => 'Créez directement un compte pour un proche, sans ouvrir les inscriptions publiques.',
|
||||
'user_created' => 'Compte créé.',
|
||||
'users_section' => 'Comptes existants',
|
||||
'owner_badge' => 'Propriétaire',
|
||||
|
||||
// Page self-hosting
|
||||
'selfhost_title' => 'Auto-héberger BloomFeed',
|
||||
'selfhost_intro' => "BloomFeed est pensé pour être installé sur votre propre serveur, pour vous (ou votre foyer). Tout tourne dans Docker : l'application, le planificateur et la base de données MariaDB.",
|
||||
'prereq_title' => 'Prérequis',
|
||||
'prereq_docker' => 'Un serveur Linux avec Docker et le plugin Docker Compose.',
|
||||
'prereq_port' => 'Le port 7081 libre (modifiable dans docker-compose.yml).',
|
||||
'prereq_proxy' => 'Optionnel : un nom de domaine et un reverse proxy si vous voulez du HTTPS.',
|
||||
'install_title' => 'Installation',
|
||||
'install_step_clone' => 'Clonez le dépôt sur votre serveur.',
|
||||
'install_step_script' => 'Lancez le script de déploiement :',
|
||||
'install_env_note' => "Il crée automatiquement un fichier .env (à partir de .env.example) et y génère des secrets aléatoires (APP_KEY, 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.",
|
||||
'install_step_containers' => 'Le script construit les images et démarre les conteneurs (application, planificateur, MariaDB).',
|
||||
'install_step_owner' => 'Ouvrez http://votre-serveur:7081 et créez le compte propriétaire — les inscriptions se referment ensuite automatiquement.',
|
||||
'config_title' => 'Configuration',
|
||||
'config_registrations' => "Depuis le menu Paramètres, le compte propriétaire peut créer directement des comptes pour ses proches, ou ouvrir/fermer les inscriptions publiques d'un clic.",
|
||||
'config_language' => "L'interface est disponible en français, anglais, espagnol et allemand — chaque utilisateur choisit sa langue via le sélecteur en haut de page.",
|
||||
'config_catalog' => "Le catalogue de flux intégré se personnalise en éditant resources/data/bloomflux.json puis en redéployant.",
|
||||
'update_title' => 'Mettre à jour',
|
||||
'update_text' => 'Relancez simplement le script de déploiement : il récupère les derniers changements et reconstruit la stack sans toucher à vos données.',
|
||||
'uninstall_title' => 'Désinstaller',
|
||||
'uninstall_text' => "Le script uninstall.sh arrête et supprime proprement la stack Docker. Il demande une confirmation explicite avant de supprimer vos données et ne supprime jamais le code source sans confirmation séparée.",
|
||||
'source_title' => 'Code source',
|
||||
|
||||
// Sources de catalogue & filtre langue
|
||||
'all_languages' => 'Toutes les langues',
|
||||
'catalog_sources_section' => 'Sources du catalogue',
|
||||
'catalog_sources_desc' => "Ajoutez d'autres catalogues de flux en indiquant l'URL brute (raw) d'un fichier bloomflux.json hébergé dans un autre dépôt git. Les flux sont fusionnés avec le catalogue intégré, et la dernière copie connue est servie si la source est injoignable.",
|
||||
'source_url_label' => 'URL du fichier bloomflux.json',
|
||||
'add_source' => 'Ajouter la source',
|
||||
'remove' => 'Retirer',
|
||||
'source_added' => 'Source ajoutée (:count flux).',
|
||||
'source_already_added' => 'Cette source est déjà enregistrée.',
|
||||
'source_removed' => 'Source retirée.',
|
||||
'source_invalid' => 'Impossible de lire cette source (fichier bloomflux.json valide attendu).',
|
||||
'no_sources' => 'Aucune source externe pour le moment.',
|
||||
];
|
||||
@@ -1008,10 +1008,35 @@ h1, h2, h3 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.market-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.market-search {
|
||||
font-size: var(--font-base);
|
||||
padding: 13px 18px;
|
||||
border-radius: var(--radius);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.market-lang-select {
|
||||
width: auto;
|
||||
min-width: 170px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.market-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.market-lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.market-chips {
|
||||
@@ -1129,6 +1154,41 @@ h1, h2, h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- Sélecteur de langue ---------- */
|
||||
|
||||
.locale-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.locale-select {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
|
||||
.locale-select:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.guest-topbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
+8
-1
@@ -40,18 +40,21 @@
|
||||
}
|
||||
|
||||
var search = document.getElementById('market-search');
|
||||
var langSelect = document.getElementById('market-lang');
|
||||
var chips = document.getElementById('market-chips');
|
||||
var emptyNotice = document.getElementById('market-empty');
|
||||
var activeCategory = '';
|
||||
|
||||
function applyFilters() {
|
||||
var query = (search.value || '').toLowerCase().trim();
|
||||
var activeLang = langSelect ? langSelect.value : '';
|
||||
var visible = 0;
|
||||
|
||||
grid.querySelectorAll('.market-card').forEach(function (card) {
|
||||
var matchesCategory = !activeCategory || card.getAttribute('data-category') === activeCategory;
|
||||
var matchesLang = !activeLang || card.getAttribute('data-lang') === activeLang;
|
||||
var matchesQuery = !query || card.getAttribute('data-search').indexOf(query) !== -1;
|
||||
var show = matchesCategory && matchesQuery;
|
||||
var show = matchesCategory && matchesLang && matchesQuery;
|
||||
card.hidden = !show;
|
||||
if (show) {
|
||||
visible++;
|
||||
@@ -63,6 +66,10 @@
|
||||
|
||||
search.addEventListener('input', applyFilters);
|
||||
|
||||
if (langSelect) {
|
||||
langSelect.addEventListener('change', applyFilters);
|
||||
}
|
||||
|
||||
chips.addEventListener('click', function (event) {
|
||||
var chip = event.target.closest('.chip');
|
||||
if (!chip) {
|
||||
|
||||
+542
-45
@@ -1,48 +1,545 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated_at": "2026-07-03",
|
||||
"version": 2,
|
||||
"updated_at": "2026-07-04",
|
||||
"feeds": [
|
||||
{ "title": "Le Monde", "url": "https://www.lemonde.fr/rss/une.xml", "category": "Actualités", "lang": "fr", "description": "La une du journal Le Monde." },
|
||||
{ "title": "France Info", "url": "https://www.franceinfo.fr/titres.rss", "category": "Actualités", "lang": "fr", "description": "Les titres de France Info en continu." },
|
||||
{ "title": "Courrier International", "url": "https://www.courrierinternational.com/feed/all/rss.xml", "category": "Actualités", "lang": "fr", "description": "L'actualité internationale vue par la presse étrangère." },
|
||||
{ "title": "BBC News", "url": "https://feeds.bbci.co.uk/news/rss.xml", "category": "Actualités", "lang": "en", "description": "Fil d'actualité mondial de la BBC." },
|
||||
|
||||
{ "title": "Korben", "url": "https://korben.info/feed", "category": "Tech", "lang": "fr", "description": "Actu tech, astuces et bidouilles." },
|
||||
{ "title": "Numerama", "url": "https://www.numerama.com/feed/", "category": "Tech", "lang": "fr", "description": "Société numérique, tech et sciences." },
|
||||
{ "title": "Next", "url": "https://next.ink/feed/", "category": "Tech", "lang": "fr", "description": "Actualité informatique et numérique de fond." },
|
||||
{ "title": "Journal du hacker", "url": "https://www.journalduhacker.net/rss", "category": "Tech", "lang": "fr", "description": "Liens tech partagés par la communauté francophone." },
|
||||
{ "title": "Ars Technica", "url": "https://feeds.arstechnica.com/arstechnica/index", "category": "Tech", "lang": "en", "description": "Analyses tech approfondies." },
|
||||
{ "title": "Hacker News", "url": "https://news.ycombinator.com/rss", "category": "Tech", "lang": "en", "description": "Les liens les plus discutés de la communauté HN." },
|
||||
{ "title": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "category": "Tech", "lang": "en", "description": "Tech, science et culture numérique." },
|
||||
|
||||
{ "title": "Dev.to", "url": "https://dev.to/feed", "category": "Développement", "lang": "en", "description": "Articles de la communauté dev.to." },
|
||||
{ "title": "GitHub Blog", "url": "https://github.blog/feed/", "category": "Développement", "lang": "en", "description": "Nouveautés et ingénierie GitHub." },
|
||||
{ "title": "Laravel News", "url": "https://feed.laravel-news.com/", "category": "Développement", "lang": "en", "description": "L'écosystème Laravel au quotidien." },
|
||||
{ "title": "Stack Overflow Blog", "url": "https://stackoverflow.blog/feed/", "category": "Développement", "lang": "en", "description": "Le blog officiel de Stack Overflow." },
|
||||
{ "title": "freeCodeCamp", "url": "https://www.freecodecamp.org/news/rss/", "category": "Développement", "lang": "en", "description": "Tutoriels et articles pour apprendre à coder." },
|
||||
{ "title": "CSS-Tricks", "url": "https://css-tricks.com/feed/", "category": "Développement", "lang": "en", "description": "Tout sur le CSS et le front-end." },
|
||||
{ "title": "Smashing Magazine", "url": "https://www.smashingmagazine.com/feed/", "category": "Développement", "lang": "en", "description": "Design et développement web." },
|
||||
{ "title": "Développez.com", "url": "https://www.developpez.com/index/rss", "category": "Développement", "lang": "fr", "description": "L'actualité des développeurs francophones." },
|
||||
{ "title": "Human Coders News", "url": "https://news.humancoders.com/items/feed", "category": "Développement", "lang": "fr", "description": "Veille dev partagée par la communauté francophone." },
|
||||
|
||||
{ "title": "IT-Connect", "url": "https://www.it-connect.fr/feed/", "category": "IT / Sysadmin", "lang": "fr", "description": "Tutoriels et actus sysadmin, Windows et Linux." },
|
||||
{ "title": "ZDNet France", "url": "https://www.zdnet.fr/feeds/rss/actualites/", "category": "IT / Sysadmin", "lang": "fr", "description": "L'actualité IT pour les professionnels." },
|
||||
{ "title": "Silicon", "url": "https://www.silicon.fr/feed", "category": "IT / Sysadmin", "lang": "fr", "description": "L'actualité IT et cloud pour les DSI." },
|
||||
{ "title": "The Register", "url": "https://www.theregister.com/headlines.atom", "category": "IT / Sysadmin", "lang": "en", "description": "L'IT sans langue de bois." },
|
||||
{ "title": "ServeTheHome", "url": "https://www.servethehome.com/feed/", "category": "IT / Sysadmin", "lang": "en", "description": "Serveurs, stockage, réseau et homelab." },
|
||||
|
||||
{ "title": "Phoronix", "url": "https://www.phoronix.com/rss.php", "category": "Open source", "lang": "en", "description": "Linux, matériel et open source." },
|
||||
{ "title": "LinuxFr", "url": "https://linuxfr.org/news.atom", "category": "Open source", "lang": "fr", "description": "L'actualité du logiciel libre en français." },
|
||||
{ "title": "It's FOSS", "url": "https://itsfoss.com/rss/", "category": "Open source", "lang": "en", "description": "Tutoriels et actus Linux/open source." },
|
||||
|
||||
{ "title": "CERT-FR", "url": "https://www.cert.ssi.gouv.fr/feed/", "category": "Sécurité", "lang": "fr", "description": "Alertes et avis de sécurité de l'ANSSI." },
|
||||
{ "title": "Krebs on Security", "url": "https://krebsonsecurity.com/feed/", "category": "Sécurité", "lang": "en", "description": "Investigations cybersécurité de Brian Krebs." },
|
||||
{ "title": "Zataz", "url": "https://www.zataz.com/feed/", "category": "Sécurité", "lang": "fr", "description": "Actualité cybersécurité et fuites de données." },
|
||||
|
||||
{ "title": "Futura Sciences", "url": "https://www.futura-sciences.com/rss/actualites.xml", "category": "Sciences", "lang": "fr", "description": "Actualités scientifiques tous domaines." },
|
||||
{ "title": "CNRS Le Journal", "url": "https://lejournal.cnrs.fr/rss", "category": "Sciences", "lang": "fr", "description": "Le journal de la recherche française." },
|
||||
|
||||
{ "title": "Gamekult", "url": "https://www.gamekult.com/feed.xml", "category": "Jeux vidéo", "lang": "fr", "description": "Actualité et critiques jeux vidéo." },
|
||||
{ "title": "Rock Paper Shotgun", "url": "https://www.rockpapershotgun.com/feed", "category": "Jeux vidéo", "lang": "en", "description": "Jeux PC, tests et actus." }
|
||||
{
|
||||
"title": "Le Monde",
|
||||
"url": "https://www.lemonde.fr/rss/une.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "fr",
|
||||
"description": "La une du journal Le Monde."
|
||||
},
|
||||
{
|
||||
"title": "France Info",
|
||||
"url": "https://www.franceinfo.fr/titres.rss",
|
||||
"category": "Actualités",
|
||||
"lang": "fr",
|
||||
"description": "Les titres de France Info en continu."
|
||||
},
|
||||
{
|
||||
"title": "Courrier International",
|
||||
"url": "https://www.courrierinternational.com/feed/all/rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité internationale vue par la presse étrangère."
|
||||
},
|
||||
{
|
||||
"title": "BBC News",
|
||||
"url": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "Fil d'actualité mondial de la BBC."
|
||||
},
|
||||
{
|
||||
"title": "Korben",
|
||||
"url": "https://korben.info/feed",
|
||||
"category": "Tech",
|
||||
"lang": "fr",
|
||||
"description": "Actu tech, astuces et bidouilles."
|
||||
},
|
||||
{
|
||||
"title": "Numerama",
|
||||
"url": "https://www.numerama.com/feed/",
|
||||
"category": "Tech",
|
||||
"lang": "fr",
|
||||
"description": "Société numérique, tech et sciences."
|
||||
},
|
||||
{
|
||||
"title": "Next",
|
||||
"url": "https://next.ink/feed/",
|
||||
"category": "Tech",
|
||||
"lang": "fr",
|
||||
"description": "Actualité informatique et numérique de fond."
|
||||
},
|
||||
{
|
||||
"title": "Journal du hacker",
|
||||
"url": "https://www.journalduhacker.net/rss",
|
||||
"category": "Tech",
|
||||
"lang": "fr",
|
||||
"description": "Liens tech partagés par la communauté francophone."
|
||||
},
|
||||
{
|
||||
"title": "Ars Technica",
|
||||
"url": "https://feeds.arstechnica.com/arstechnica/index",
|
||||
"category": "Tech",
|
||||
"lang": "en",
|
||||
"description": "Analyses tech approfondies."
|
||||
},
|
||||
{
|
||||
"title": "Hacker News",
|
||||
"url": "https://news.ycombinator.com/rss",
|
||||
"category": "Tech",
|
||||
"lang": "en",
|
||||
"description": "Les liens les plus discutés de la communauté HN."
|
||||
},
|
||||
{
|
||||
"title": "The Verge",
|
||||
"url": "https://www.theverge.com/rss/index.xml",
|
||||
"category": "Tech",
|
||||
"lang": "en",
|
||||
"description": "Tech, science et culture numérique."
|
||||
},
|
||||
{
|
||||
"title": "Dev.to",
|
||||
"url": "https://dev.to/feed",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Articles de la communauté dev.to."
|
||||
},
|
||||
{
|
||||
"title": "GitHub Blog",
|
||||
"url": "https://github.blog/feed/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Nouveautés et ingénierie GitHub."
|
||||
},
|
||||
{
|
||||
"title": "Laravel News",
|
||||
"url": "https://feed.laravel-news.com/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "L'écosystème Laravel au quotidien."
|
||||
},
|
||||
{
|
||||
"title": "Stack Overflow Blog",
|
||||
"url": "https://stackoverflow.blog/feed/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Le blog officiel de Stack Overflow."
|
||||
},
|
||||
{
|
||||
"title": "freeCodeCamp",
|
||||
"url": "https://www.freecodecamp.org/news/rss/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Tutoriels et articles pour apprendre à coder."
|
||||
},
|
||||
{
|
||||
"title": "CSS-Tricks",
|
||||
"url": "https://css-tricks.com/feed/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Tout sur le CSS et le front-end."
|
||||
},
|
||||
{
|
||||
"title": "Smashing Magazine",
|
||||
"url": "https://www.smashingmagazine.com/feed/",
|
||||
"category": "Développement",
|
||||
"lang": "en",
|
||||
"description": "Design et développement web."
|
||||
},
|
||||
{
|
||||
"title": "Développez.com",
|
||||
"url": "https://www.developpez.com/index/rss",
|
||||
"category": "Développement",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité des développeurs francophones."
|
||||
},
|
||||
{
|
||||
"title": "Human Coders News",
|
||||
"url": "https://news.humancoders.com/items/feed",
|
||||
"category": "Développement",
|
||||
"lang": "fr",
|
||||
"description": "Veille dev partagée par la communauté francophone."
|
||||
},
|
||||
{
|
||||
"title": "IT-Connect",
|
||||
"url": "https://www.it-connect.fr/feed/",
|
||||
"category": "IT / Sysadmin",
|
||||
"lang": "fr",
|
||||
"description": "Tutoriels et actus sysadmin, Windows et Linux."
|
||||
},
|
||||
{
|
||||
"title": "ZDNet France",
|
||||
"url": "https://www.zdnet.fr/feeds/rss/actualites/",
|
||||
"category": "IT / Sysadmin",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité IT pour les professionnels."
|
||||
},
|
||||
{
|
||||
"title": "Silicon",
|
||||
"url": "https://www.silicon.fr/feed",
|
||||
"category": "IT / Sysadmin",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité IT et cloud pour les DSI."
|
||||
},
|
||||
{
|
||||
"title": "The Register",
|
||||
"url": "https://www.theregister.com/headlines.atom",
|
||||
"category": "IT / Sysadmin",
|
||||
"lang": "en",
|
||||
"description": "L'IT sans langue de bois."
|
||||
},
|
||||
{
|
||||
"title": "ServeTheHome",
|
||||
"url": "https://www.servethehome.com/feed/",
|
||||
"category": "IT / Sysadmin",
|
||||
"lang": "en",
|
||||
"description": "Serveurs, stockage, réseau et homelab."
|
||||
},
|
||||
{
|
||||
"title": "Phoronix",
|
||||
"url": "https://www.phoronix.com/rss.php",
|
||||
"category": "Open source",
|
||||
"lang": "en",
|
||||
"description": "Linux, matériel et open source."
|
||||
},
|
||||
{
|
||||
"title": "LinuxFr",
|
||||
"url": "https://linuxfr.org/news.atom",
|
||||
"category": "Open source",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité du logiciel libre en français."
|
||||
},
|
||||
{
|
||||
"title": "It's FOSS",
|
||||
"url": "https://itsfoss.com/rss/",
|
||||
"category": "Open source",
|
||||
"lang": "en",
|
||||
"description": "Tutoriels et actus Linux/open source."
|
||||
},
|
||||
{
|
||||
"title": "CERT-FR",
|
||||
"url": "https://www.cert.ssi.gouv.fr/feed/",
|
||||
"category": "Sécurité",
|
||||
"lang": "fr",
|
||||
"description": "Alertes et avis de sécurité de l'ANSSI."
|
||||
},
|
||||
{
|
||||
"title": "Krebs on Security",
|
||||
"url": "https://krebsonsecurity.com/feed/",
|
||||
"category": "Sécurité",
|
||||
"lang": "en",
|
||||
"description": "Investigations cybersécurité de Brian Krebs."
|
||||
},
|
||||
{
|
||||
"title": "Zataz",
|
||||
"url": "https://www.zataz.com/feed/",
|
||||
"category": "Sécurité",
|
||||
"lang": "fr",
|
||||
"description": "Actualité cybersécurité et fuites de données."
|
||||
},
|
||||
{
|
||||
"title": "Futura Sciences",
|
||||
"url": "https://www.futura-sciences.com/rss/actualites.xml",
|
||||
"category": "Sciences",
|
||||
"lang": "fr",
|
||||
"description": "Actualités scientifiques tous domaines."
|
||||
},
|
||||
{
|
||||
"title": "CNRS Le Journal",
|
||||
"url": "https://lejournal.cnrs.fr/rss",
|
||||
"category": "Sciences",
|
||||
"lang": "fr",
|
||||
"description": "Le journal de la recherche française."
|
||||
},
|
||||
{
|
||||
"title": "Gamekult",
|
||||
"url": "https://www.gamekult.com/feed.xml",
|
||||
"category": "Jeux vidéo",
|
||||
"lang": "fr",
|
||||
"description": "Actualité et critiques jeux vidéo."
|
||||
},
|
||||
{
|
||||
"title": "Rock Paper Shotgun",
|
||||
"url": "https://www.rockpapershotgun.com/feed",
|
||||
"category": "Jeux vidéo",
|
||||
"lang": "en",
|
||||
"description": "Jeux PC, tests et actus."
|
||||
},
|
||||
{
|
||||
"title": "Le Figaro",
|
||||
"url": "https://www.lefigaro.fr/rss/figaro_actualites.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité en continu du Figaro."
|
||||
},
|
||||
{
|
||||
"title": "France 24",
|
||||
"url": "https://www.france24.com/fr/rss",
|
||||
"category": "Actualités",
|
||||
"lang": "fr",
|
||||
"description": "L'actualité internationale en français."
|
||||
},
|
||||
{
|
||||
"title": "The New York Times",
|
||||
"url": "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "La une du New York Times (États-Unis)."
|
||||
},
|
||||
{
|
||||
"title": "NPR",
|
||||
"url": "https://feeds.npr.org/1001/rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "La radio publique américaine."
|
||||
},
|
||||
{
|
||||
"title": "The Guardian",
|
||||
"url": "https://www.theguardian.com/world/rss",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "L'actualité mondiale du Guardian (Royaume-Uni)."
|
||||
},
|
||||
{
|
||||
"title": "Al Jazeera English",
|
||||
"url": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "L'actualité mondiale vue du Qatar."
|
||||
},
|
||||
{
|
||||
"title": "CBC News",
|
||||
"url": "https://www.cbc.ca/webfeed/rss/rss-topstories",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "Les grands titres canadiens."
|
||||
},
|
||||
{
|
||||
"title": "ABC News Australia",
|
||||
"url": "https://www.abc.net.au/news/feed/51120/rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "L'actualité australienne."
|
||||
},
|
||||
{
|
||||
"title": "El País",
|
||||
"url": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/portada",
|
||||
"category": "Actualités",
|
||||
"lang": "es",
|
||||
"description": "La portada d'El País (Espagne)."
|
||||
},
|
||||
{
|
||||
"title": "El Mundo",
|
||||
"url": "https://e00-elmundo.uecdn.es/elmundo/rss/portada.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "es",
|
||||
"description": "L'actualité espagnole d'El Mundo."
|
||||
},
|
||||
{
|
||||
"title": "Der Spiegel",
|
||||
"url": "https://www.spiegel.de/schlagzeilen/index.rss",
|
||||
"category": "Actualités",
|
||||
"lang": "de",
|
||||
"description": "Les gros titres du Spiegel (Allemagne)."
|
||||
},
|
||||
{
|
||||
"title": "Die Zeit",
|
||||
"url": "https://newsfeed.zeit.de/index",
|
||||
"category": "Actualités",
|
||||
"lang": "de",
|
||||
"description": "L'hebdomadaire allemand de référence."
|
||||
},
|
||||
{
|
||||
"title": "Tagesschau",
|
||||
"url": "https://www.tagesschau.de/index~rss2.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "de",
|
||||
"description": "Le journal télévisé allemand ARD."
|
||||
},
|
||||
{
|
||||
"title": "ANSA",
|
||||
"url": "https://www.ansa.it/sito/ansait_rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "it",
|
||||
"description": "L'agence de presse italienne."
|
||||
},
|
||||
{
|
||||
"title": "La Repubblica",
|
||||
"url": "https://www.repubblica.it/rss/homepage/rss2.0.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "it",
|
||||
"description": "La une de La Repubblica (Italie)."
|
||||
},
|
||||
{
|
||||
"title": "G1 Globo",
|
||||
"url": "https://g1.globo.com/rss/g1/",
|
||||
"category": "Actualités",
|
||||
"lang": "pt",
|
||||
"description": "L'actualité brésilienne de Globo."
|
||||
},
|
||||
{
|
||||
"title": "Público",
|
||||
"url": "https://feeds.publico.pt/rss/home",
|
||||
"category": "Actualités",
|
||||
"lang": "pt",
|
||||
"description": "Le quotidien portugais Público."
|
||||
},
|
||||
{
|
||||
"title": "NU.nl",
|
||||
"url": "https://www.nu.nl/rss/Algemeen",
|
||||
"category": "Actualités",
|
||||
"lang": "nl",
|
||||
"description": "L'actualité néerlandaise en continu."
|
||||
},
|
||||
{
|
||||
"title": "NHK News",
|
||||
"url": "https://www3.nhk.or.jp/rss/news/cat0.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "ja",
|
||||
"description": "Les grands titres de la NHK (Japon)."
|
||||
},
|
||||
{
|
||||
"title": "Meduza",
|
||||
"url": "https://meduza.io/rss/all",
|
||||
"category": "Actualités",
|
||||
"lang": "ru",
|
||||
"description": "Média russe indépendant (en exil)."
|
||||
},
|
||||
{
|
||||
"title": "SVT Nyheter",
|
||||
"url": "https://www.svt.se/nyheter/rss.xml",
|
||||
"category": "Actualités",
|
||||
"lang": "sv",
|
||||
"description": "L'actualité de la télévision publique suédoise."
|
||||
},
|
||||
{
|
||||
"title": "The Hindu",
|
||||
"url": "https://www.thehindu.com/news/national/feeder/default.rss",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "L'actualité nationale indienne."
|
||||
},
|
||||
{
|
||||
"title": "Times of India",
|
||||
"url": "https://timesofindia.indiatimes.com/rssfeedstopstories.cms",
|
||||
"category": "Actualités",
|
||||
"lang": "en",
|
||||
"description": "Les grands titres du Times of India."
|
||||
},
|
||||
{
|
||||
"title": "Wired",
|
||||
"url": "https://www.wired.com/feed/rss",
|
||||
"category": "Tech",
|
||||
"lang": "en",
|
||||
"description": "Tech, science et société (États-Unis)."
|
||||
},
|
||||
{
|
||||
"title": "TechCrunch",
|
||||
"url": "https://techcrunch.com/feed/",
|
||||
"category": "Tech",
|
||||
"lang": "en",
|
||||
"description": "Startups et industrie tech."
|
||||
},
|
||||
{
|
||||
"title": "Xataka",
|
||||
"url": "https://www.xataka.com/feedburner.xml",
|
||||
"category": "Tech",
|
||||
"lang": "es",
|
||||
"description": "Le grand média tech hispanophone."
|
||||
},
|
||||
{
|
||||
"title": "Genbeta",
|
||||
"url": "https://www.genbeta.com/feedburner.xml",
|
||||
"category": "Tech",
|
||||
"lang": "es",
|
||||
"description": "Logiciels et web (Espagne)."
|
||||
},
|
||||
{
|
||||
"title": "heise online",
|
||||
"url": "https://www.heise.de/rss/heise-atom.xml",
|
||||
"category": "Tech",
|
||||
"lang": "de",
|
||||
"description": "La référence tech allemande."
|
||||
},
|
||||
{
|
||||
"title": "Golem.de",
|
||||
"url": "https://rss.golem.de/rss.php?feed=RSS2.0",
|
||||
"category": "Tech",
|
||||
"lang": "de",
|
||||
"description": "IT et tech pour professionnels (Allemagne)."
|
||||
},
|
||||
{
|
||||
"title": "HDblog",
|
||||
"url": "https://www.hdblog.it/feed/",
|
||||
"category": "Tech",
|
||||
"lang": "it",
|
||||
"description": "Tech et mobile (Italie)."
|
||||
},
|
||||
{
|
||||
"title": "Tecnoblog",
|
||||
"url": "https://tecnoblog.net/feed/",
|
||||
"category": "Tech",
|
||||
"lang": "pt",
|
||||
"description": "La tech vue du Brésil."
|
||||
},
|
||||
{
|
||||
"title": "Tweakers",
|
||||
"url": "https://feeds.feedburner.com/tweakers/mixed",
|
||||
"category": "Tech",
|
||||
"lang": "nl",
|
||||
"description": "La communauté tech néerlandaise."
|
||||
},
|
||||
{
|
||||
"title": "ITmedia",
|
||||
"url": "https://rss.itmedia.co.jp/rss/2.0/news_bursts.xml",
|
||||
"category": "Tech",
|
||||
"lang": "ja",
|
||||
"description": "L'actualité IT japonaise."
|
||||
},
|
||||
{
|
||||
"title": "Niebezpiecznik",
|
||||
"url": "https://niebezpiecznik.pl/feed/",
|
||||
"category": "Sécurité",
|
||||
"lang": "pl",
|
||||
"description": "La cybersécurité vue de Pologne."
|
||||
},
|
||||
{
|
||||
"title": "Nature",
|
||||
"url": "https://www.nature.com/nature.rss",
|
||||
"category": "Sciences",
|
||||
"lang": "en",
|
||||
"description": "La revue scientifique de référence."
|
||||
},
|
||||
{
|
||||
"title": "NASA",
|
||||
"url": "https://www.nasa.gov/feed/",
|
||||
"category": "Sciences",
|
||||
"lang": "en",
|
||||
"description": "Les actualités de la NASA."
|
||||
},
|
||||
{
|
||||
"title": "Ars Technica Science",
|
||||
"url": "https://feeds.arstechnica.com/arstechnica/science",
|
||||
"category": "Sciences",
|
||||
"lang": "en",
|
||||
"description": "La rubrique science d'Ars Technica."
|
||||
},
|
||||
{
|
||||
"title": "L'Équipe",
|
||||
"url": "https://dwh.lequipe.fr/api/edito/rss?path=/",
|
||||
"category": "Sport",
|
||||
"lang": "fr",
|
||||
"description": "Toute l'actualité sportive française."
|
||||
},
|
||||
{
|
||||
"title": "ESPN",
|
||||
"url": "https://www.espn.com/espn/rss/news",
|
||||
"category": "Sport",
|
||||
"lang": "en",
|
||||
"description": "Le sport américain et mondial."
|
||||
},
|
||||
{
|
||||
"title": "Marca",
|
||||
"url": "https://e00-marca.uecdn.es/rss/portada.xml",
|
||||
"category": "Sport",
|
||||
"lang": "es",
|
||||
"description": "Le quotidien sportif espagnol."
|
||||
},
|
||||
{
|
||||
"title": "kicker",
|
||||
"url": "https://newsfeed.kicker.de/news/aktuell",
|
||||
"category": "Sport",
|
||||
"lang": "de",
|
||||
"description": "Le football allemand et international."
|
||||
},
|
||||
{
|
||||
"title": "Open Culture",
|
||||
"url": "https://www.openculture.com/feed",
|
||||
"category": "Culture",
|
||||
"lang": "en",
|
||||
"description": "Culture libre : cours, films et livres gratuits."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="form-status">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ url()->previous(route('dashboard')) }}" class="back-link">← Retour aux articles</a>
|
||||
<a href="{{ url()->previous(route('dashboard')) }}" class="back-link">← {{ __('app.back_to_articles') }}</a>
|
||||
|
||||
<article class="card reader-article">
|
||||
<h1 class="reader-title">{{ $article->title }}</h1>
|
||||
@@ -15,15 +15,15 @@
|
||||
@if ($article->published_at)
|
||||
<span>· {{ $article->published_at->diffForHumans() }}</span>
|
||||
@endif
|
||||
<a href="{{ $article->link }}" target="_blank" rel="noopener" class="reader-source-link">Voir la source originale ↗</a>
|
||||
<a href="{{ $article->link }}" target="_blank" rel="noopener" class="reader-source-link">{{ __('app.view_original') }} ↗</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>
|
||||
<p>{{ __('app.fetch_error', ['error' => $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>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ __('app.retry') }}</button>
|
||||
</form>
|
||||
@if ($article->excerpt)
|
||||
<p class="mt-2">{{ $article->excerpt }}</p>
|
||||
@@ -32,7 +32,7 @@
|
||||
@elseif ($contentHtml)
|
||||
<div class="prose">{!! $contentHtml !!}</div>
|
||||
@else
|
||||
<p class="text-muted">Récupération du contenu en cours…</p>
|
||||
<p class="text-muted">{{ __('app.fetching') }}</p>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.guest', ['title' => 'Connexion — BloomFeed'])
|
||||
@extends('layouts.guest', ['title' => __('app.login_title').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="auth-card">
|
||||
@@ -6,8 +6,8 @@
|
||||
<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>
|
||||
<h1>{{ __('app.login_title') }}</h1>
|
||||
<p class="subtitle">{{ __('app.login_subtitle') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="form-status">{{ session('status') }}</div>
|
||||
@@ -20,20 +20,20 @@
|
||||
<form method="POST" action="{{ route('login') }}" class="mt-2">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse e-mail</label>
|
||||
<label for="email">{{ __('app.email') }}</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>
|
||||
<label for="password">{{ __('app.password') }}</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>
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ __('app.login') }}</button>
|
||||
</form>
|
||||
|
||||
@unless (\App\Models\User::query()->exists())
|
||||
@if (! \App\Models\User::query()->exists() || \App\Models\Setting::registrationsOpen())
|
||||
<div class="switch-link">
|
||||
Pas encore de compte ? <a href="{{ route('register') }}">Créer un compte</a>
|
||||
{{ __('app.no_account_yet') }} <a href="{{ route('register') }}">{{ __('app.create_account') }}</a>
|
||||
</div>
|
||||
@endunless
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@extends('layouts.guest', ['title' => 'Créer un compte — BloomFeed'])
|
||||
@extends('layouts.guest', ['title' => __('app.create_account').' — BloomFeed'])
|
||||
|
||||
@php($isFirstUser = ! \App\Models\User::query()->exists())
|
||||
|
||||
@section('content')
|
||||
<div class="auth-card">
|
||||
@@ -6,8 +8,8 @@
|
||||
<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>
|
||||
<h1>{{ $isFirstUser ? __('app.register_owner_title') : __('app.register_title') }}</h1>
|
||||
<p class="subtitle">{{ $isFirstUser ? __('app.register_owner_subtitle') : __('app.register_subtitle') }}</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="form-error mt-1">{{ $errors->first() }}</div>
|
||||
@@ -16,26 +18,26 @@
|
||||
<form method="POST" action="{{ route('register') }}" class="mt-2">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label for="name">Nom</label>
|
||||
<label for="name">{{ __('app.name') }}</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>
|
||||
<label for="email">{{ __('app.email') }}</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>
|
||||
<label for="password">{{ __('app.password') }}</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>
|
||||
<label for="password_confirmation">{{ __('app.confirm_password') }}</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>
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ __('app.register_button') }}</button>
|
||||
</form>
|
||||
|
||||
<div class="switch-link">
|
||||
Déjà un compte ? <a href="{{ route('login') }}">Se connecter</a>
|
||||
{{ __('app.already_account') }} <a href="{{ route('login') }}">{{ __('app.login') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.app', ['title' => 'Catalogue — BloomFeed'])
|
||||
@extends('layouts.app', ['title' => __('app.catalog').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="main-column" style="width:100%;">
|
||||
@@ -8,22 +8,37 @@
|
||||
|
||||
<div class="market-header">
|
||||
<div>
|
||||
<h2 style="margin:0;">Catalogue de flux</h2>
|
||||
<h2 style="margin:0;">{{ __('app.catalog_title') }}</h2>
|
||||
<p class="text-muted text-sm" style="margin:4px 0 0;">
|
||||
Une sélection de flux RSS livrée avec BloomFeed — ajoutez-les à votre collection en un clic.
|
||||
{{ __('app.catalog_lede') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($feeds->isEmpty())
|
||||
<div class="card empty-state">
|
||||
Le catalogue est vide pour le moment.
|
||||
{{ __('app.catalog_empty') }}
|
||||
</div>
|
||||
@else
|
||||
<input type="search" id="market-search" class="form-input market-search" placeholder="Rechercher un flux… (nom, description, catégorie)">
|
||||
@php($languageNames = [
|
||||
'fr' => 'Français', 'en' => 'English', 'es' => 'Español', 'de' => 'Deutsch',
|
||||
'it' => 'Italiano', 'pt' => 'Português', 'nl' => 'Nederlands', 'ja' => '日本語',
|
||||
'ru' => 'Русский', 'pl' => 'Polski', 'sv' => 'Svenska',
|
||||
])
|
||||
@php($languages = $feeds->flatten(1)->pluck('lang')->unique()->sort()->values())
|
||||
|
||||
<div class="market-toolbar">
|
||||
<input type="search" id="market-search" class="form-input market-search" placeholder="{{ __('app.search_placeholder') }}">
|
||||
<select id="market-lang" class="form-input market-lang-select">
|
||||
<option value="">{{ __('app.all_languages') }}</option>
|
||||
@foreach ($languages as $lang)
|
||||
<option value="{{ $lang }}">{{ $languageNames[$lang] ?? strtoupper($lang) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="market-chips" id="market-chips">
|
||||
<button type="button" class="chip active" data-category="">Tout</button>
|
||||
<button type="button" class="chip active" data-category="">{{ __('app.all') }}</button>
|
||||
@foreach ($feeds->keys() as $category)
|
||||
<button type="button" class="chip" data-category="{{ $category }}">{{ $category }}</button>
|
||||
@endforeach
|
||||
@@ -32,7 +47,7 @@
|
||||
<div class="market-grid" id="market-grid">
|
||||
@foreach ($feeds as $category => $items)
|
||||
@foreach ($items as $item)
|
||||
<div class="market-card" data-category="{{ $category }}" data-search="{{ Str::lower($item['title'].' '.$item['description'].' '.$category) }}">
|
||||
<div class="market-card" data-category="{{ $category }}" data-lang="{{ $item['lang'] }}" data-search="{{ Str::lower($item['title'].' '.$item['description'].' '.$category) }}">
|
||||
<div class="market-card-head">
|
||||
<span class="market-card-title">{{ $item['title'] }}</span>
|
||||
<span class="chip chip-static">{{ $category }}</span>
|
||||
@@ -41,13 +56,13 @@
|
||||
<div class="market-card-foot">
|
||||
<span class="market-lang">{{ strtoupper($item['lang']) }}</span>
|
||||
@if (in_array($item['url'], $subscribedUrls))
|
||||
<span class="market-added">✓ Ajouté</span>
|
||||
<span class="market-added">✓ {{ __('app.added') }}</span>
|
||||
@else
|
||||
<form method="POST" action="{{ route('feeds.store') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="url" value="{{ $item['url'] }}">
|
||||
<input type="hidden" name="title" value="{{ $item['title'] }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">+ Ajouter</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">+ {{ __('app.add') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -56,7 +71,7 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="card empty-state" id="market-empty" hidden>Aucun flux ne correspond à votre recherche.</div>
|
||||
<div class="card empty-state" id="market-empty" hidden>{{ __('app.no_match') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.app', ['title' => 'Articles — BloomFeed'])
|
||||
@extends('layouts.app', ['title' => __('app.articles').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="sidebar">
|
||||
@@ -11,11 +11,11 @@
|
||||
@endif
|
||||
|
||||
<div class="flex-between">
|
||||
<h2 style="margin:0;">Derniers articles</h2>
|
||||
<h2 style="margin:0;">{{ __('app.latest_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' }}
|
||||
{{ request('unread') ? __('app.show_all') : __('app.unread_only') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,9 +25,9 @@
|
||||
@empty
|
||||
<div class="card empty-state">
|
||||
@if ($feeds->isEmpty())
|
||||
Ajoutez votre premier flux RSS depuis la barre latérale pour commencer.
|
||||
{{ __('app.empty_no_feeds') }}
|
||||
@else
|
||||
Aucun article pour le moment. Essayez d'actualiser vos flux.
|
||||
{{ __('app.empty_no_articles') }}
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.app', ['title' => 'Mes flux — BloomFeed'])
|
||||
@extends('layouts.app', ['title' => __('app.my_feeds').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="main-column" style="width:100%;">
|
||||
@@ -7,19 +7,19 @@
|
||||
@endif
|
||||
|
||||
<div class="card">
|
||||
<h2>Ajouter un flux</h2>
|
||||
<h2>{{ __('app.add_feed') }}</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>
|
||||
<input type="url" name="url" class="form-input" placeholder="{{ __('app.feed_url_placeholder') }}" required>
|
||||
<input type="text" name="title" class="form-input" placeholder="{{ __('app.custom_title_optional') }}" style="max-width:220px;">
|
||||
<button type="submit" class="btn btn-primary">{{ __('app.add') }}</button>
|
||||
</form>
|
||||
@error('url')
|
||||
<div class="form-error mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<h2 class="mt-3">Mes flux</h2>
|
||||
<h2 class="mt-3">{{ __('app.my_feeds') }}</h2>
|
||||
|
||||
@forelse ($feeds as $feed)
|
||||
<div class="card flex-between">
|
||||
@@ -27,9 +27,9 @@
|
||||
<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)
|
||||
{{ __('app.articles_count', ['count' => $feed->articles_count]) }}
|
||||
@if ($feed->last_fetched_at)
|
||||
· actualisé {{ $feed->last_fetched_at->diffForHumans() }}
|
||||
· {{ __('app.refreshed_ago', ['time' => $feed->last_fetched_at->diffForHumans()]) }}
|
||||
@endif
|
||||
@if ($feed->last_fetch_status && $feed->last_fetch_status !== 'ok')
|
||||
· <span style="color:var(--danger);">{{ $feed->last_fetch_status }}</span>
|
||||
@@ -39,17 +39,17 @@
|
||||
<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>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ __('app.refresh') }}</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>
|
||||
<button type="submit" class="btn btn-danger btn-sm" data-confirm="{{ __('app.confirm_delete_feed') }}">{{ __('app.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card empty-state">Vous n'avez pas encore ajouté de flux.</div>
|
||||
<div class="card empty-state">{{ __('app.no_feeds_yet') }}</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
@extends('layouts.marketing', ['title' => 'BloomFeed — votre agrégateur RSS auto-hébergé'])
|
||||
@extends('layouts.marketing', ['title' => 'BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<span class="eyebrow">Auto-hébergé · 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>
|
||||
<span class="eyebrow">{{ __('app.hero_eyebrow') }}</span>
|
||||
<h1>{{ __('app.hero_title') }}</h1>
|
||||
<p class="hero-lede">{{ __('app.hero_lede') }}</p>
|
||||
<div class="hero-cta">
|
||||
@if ($hasOwner)
|
||||
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">Se connecter</a>
|
||||
@if ($hasOwner && ! \App\Models\Setting::registrationsOpen())
|
||||
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">{{ __('app.login') }}</a>
|
||||
@else
|
||||
<a href="{{ route('register') }}" class="btn btn-primary btn-lg">Créer un compte</a>
|
||||
<a href="{{ route('register') }}" class="btn btn-primary btn-lg">{{ __('app.create_account') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('self-hosting') }}" class="btn btn-ghost btn-lg">Comment l'auto-héberger ?</a>
|
||||
<a href="{{ route('self-hosting') }}" class="btn btn-ghost btn-lg">{{ __('app.how_to_self_host') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -23,18 +20,18 @@
|
||||
<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>
|
||||
<h3>{{ __('app.feature_feeds_title') }}</h3>
|
||||
<p>{{ __('app.feature_feeds_text') }}</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>
|
||||
<h3>{{ __('app.feature_reader_title') }}</h3>
|
||||
<p>{{ __('app.feature_reader_text') }}</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>
|
||||
<h3>{{ __('app.feature_home_title') }}</h3>
|
||||
<p>{{ __('app.feature_home_text') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -20,6 +20,10 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="guest-topbar">
|
||||
@include('partials.locale-selector')
|
||||
<button type="button" class="theme-toggle" data-theme-toggle title="{{ __('app.change_theme') }}">◐</button>
|
||||
</div>
|
||||
<div class="centered-shell">
|
||||
@yield('content')
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<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.">
|
||||
<meta name="description" content="{{ __('app.meta_description') }}">
|
||||
<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">
|
||||
@@ -27,12 +27,13 @@
|
||||
<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>
|
||||
<a href="{{ route('self-hosting') }}" class="{{ request()->routeIs('self-hosting') ? 'active' : '' }}">{{ __('app.self_hosting') }}</a>
|
||||
@include('partials.locale-selector')
|
||||
<button type="button" class="theme-toggle" data-theme-toggle title="{{ __('app.change_theme') }}">◐</button>
|
||||
@if (\App\Models\User::query()->exists() && ! \App\Models\Setting::registrationsOpen())
|
||||
<a href="{{ route('login') }}" class="btn btn-primary btn-sm">{{ __('app.login') }}</a>
|
||||
@else
|
||||
<a href="{{ route('register') }}" class="btn btn-primary btn-sm">Créer un compte</a>
|
||||
<a href="{{ route('register') }}" class="btn btn-primary btn-sm">{{ __('app.create_account') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
</nav>
|
||||
@@ -42,8 +43,8 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>BloomFeed — projet open-source, auto-hébergé.</span>
|
||||
<a href="https://git.eldorianet.work/esteban/BloomFeed-RSS" target="_blank" rel="noopener">Code source</a>
|
||||
<span>{{ __('app.footer_tagline') }}</span>
|
||||
<a href="https://git.eldorianet.work/esteban/BloomFeed-RSS" target="_blank" rel="noopener">{{ __('app.source_code') }}</a>
|
||||
</footer>
|
||||
|
||||
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="card">
|
||||
<div class="sidebar-title">Flux</div>
|
||||
<div class="sidebar-title">{{ __('app.feeds_label') }}</div>
|
||||
<ul class="feed-list">
|
||||
<li>
|
||||
<a href="{{ route('dashboard') }}" class="{{ ! request('feed') ? 'active' : '' }}">
|
||||
Tous les articles
|
||||
{{ __('app.all_articles') }}
|
||||
</a>
|
||||
</li>
|
||||
@foreach ($feeds as $feed)
|
||||
@@ -21,18 +21,18 @@
|
||||
<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>
|
||||
<label for="sidebar-url">{{ __('app.add_feed') }}</label>
|
||||
<input type="url" name="url" id="sidebar-url" class="form-input" placeholder="{{ __('app.feed_url_placeholder') }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-sm">Ajouter</button>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-sm">{{ __('app.add') }}</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>
|
||||
<button type="submit" class="btn btn-ghost btn-block btn-sm">{{ __('app.refresh_all') }}</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ route('feeds.index') }}" class="text-sm text-muted mt-1" style="display:block; text-align:center;">
|
||||
Gérer mes flux
|
||||
{{ __('app.manage_feeds') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<form method="POST" action="{{ route('locale.update') }}" class="locale-form">
|
||||
@csrf
|
||||
<select name="locale" class="locale-select" onchange="this.form.submit()" title="{{ __('app.language') }}">
|
||||
@foreach (['fr' => 'Français', 'en' => 'English', 'es' => 'Español', 'de' => 'Deutsch'] as $code => $label)
|
||||
<option value="{{ $code }}" @selected(app()->getLocale() === $code)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</form>
|
||||
@@ -4,13 +4,17 @@
|
||||
<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('catalog') }}" class="{{ request()->routeIs('catalog') ? 'active' : '' }}">Catalogue</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>
|
||||
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">{{ __('app.articles') }}</a>
|
||||
<a href="{{ route('catalog') }}" class="{{ request()->routeIs('catalog') ? 'active' : '' }}">{{ __('app.catalog') }}</a>
|
||||
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">{{ __('app.my_feeds') }}</a>
|
||||
@if (auth()->user()?->is_owner)
|
||||
<a href="{{ route('settings') }}" class="{{ request()->routeIs('settings') ? 'active' : '' }}">{{ __('app.settings') }}</a>
|
||||
@endif
|
||||
@include('partials.locale-selector')
|
||||
<button type="button" class="theme-toggle" data-theme-toggle title="{{ __('app.change_theme') }}">◐</button>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Déconnexion</button>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ __('app.logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,55 +1,60 @@
|
||||
@extends('layouts.marketing', ['title' => 'Auto-héberger BloomFeed'])
|
||||
@extends('layouts.marketing', ['title' => __('app.selfhost_title').' — 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>
|
||||
<h1>{{ __('app.selfhost_title') }}</h1>
|
||||
<p class="hero-lede">{{ __('app.selfhost_intro') }}</p>
|
||||
|
||||
<div class="card mt-3">
|
||||
<h2>Prérequis</h2>
|
||||
<h2>{{ __('app.prereq_title') }}</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>
|
||||
<li>{{ __('app.prereq_docker') }}</li>
|
||||
<li>{{ __('app.prereq_port') }}</li>
|
||||
<li>{{ __('app.prereq_proxy') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h2>Installation</h2>
|
||||
<h2>{{ __('app.install_title') }}</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.
|
||||
{{ __('app.install_step_clone') }}
|
||||
<pre class="code-block">git clone https://git.eldorianet.work/esteban/BloomFeed-RSS bloomfeed
|
||||
cd bloomfeed</pre>
|
||||
</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>
|
||||
<li>
|
||||
{{ __('app.install_step_script') }}
|
||||
<pre class="code-block">bash scriptsite.sh</pre>
|
||||
{{ __('app.install_env_note') }}
|
||||
</li>
|
||||
<li>{{ __('app.install_step_containers') }}</li>
|
||||
<li>{{ __('app.install_step_owner') }}</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>
|
||||
<h2>{{ __('app.config_title') }}</h2>
|
||||
<ul class="docs-list">
|
||||
<li>{{ __('app.config_registrations') }}</li>
|
||||
<li>{{ __('app.config_language') }}</li>
|
||||
<li>{{ __('app.config_catalog') }}</li>
|
||||
</ul>
|
||||
</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>
|
||||
<h2>{{ __('app.update_title') }}</h2>
|
||||
<p>{{ __('app.update_text') }}</p>
|
||||
<pre class="code-block">bash scriptsite.sh</pre>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h2>Code source</h2>
|
||||
<h2>{{ __('app.uninstall_title') }}</h2>
|
||||
<p>{{ __('app.uninstall_text') }}</p>
|
||||
<pre class="code-block">bash uninstall.sh</pre>
|
||||
</div>
|
||||
|
||||
<div class="card mt-2">
|
||||
<h2>{{ __('app.source_title') }}</h2>
|
||||
<p>
|
||||
<a href="https://git.eldorianet.work/esteban/BloomFeed-RSS" target="_blank" rel="noopener">git.eldorianet.work/esteban/BloomFeed-RSS</a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
@extends('layouts.app', ['title' => __('app.settings').' — BloomFeed'])
|
||||
|
||||
@section('content')
|
||||
<div class="main-column" style="width:100%; max-width:720px; margin:0 auto;">
|
||||
@if (session('status'))
|
||||
<div class="form-status">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<h2 style="margin:0;">{{ __('app.settings_title') }}</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ __('app.registrations_section') }}</h3>
|
||||
<p class="text-muted text-sm">{{ __('app.registrations_desc') }}</p>
|
||||
<div class="flex-between mt-2">
|
||||
<span class="text-sm" style="font-weight:600; color: {{ $registrationsOpen ? 'var(--green)' : 'var(--text-muted)' }};">
|
||||
{{ $registrationsOpen ? __('app.registrations_state_open') : __('app.registrations_state_closed') }}
|
||||
</span>
|
||||
<form method="POST" action="{{ route('settings.toggleRegistrations') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn {{ $registrationsOpen ? 'btn-danger' : 'btn-primary' }} btn-sm">
|
||||
{{ $registrationsOpen ? __('app.close_registrations') : __('app.open_registrations') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ __('app.create_user_section') }}</h3>
|
||||
<p class="text-muted text-sm">{{ __('app.create_user_desc') }}</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="form-error mt-1">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('settings.createUser') }}" class="mt-2">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label for="new-name">{{ __('app.name') }}</label>
|
||||
<input type="text" name="name" id="new-name" class="form-input" value="{{ old('name') }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-email">{{ __('app.email') }}</label>
|
||||
<input type="email" name="email" id="new-email" class="form-input" value="{{ old('email') }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">{{ __('app.password') }}</label>
|
||||
<input type="text" name="password" id="new-password" class="form-input" minlength="8" required autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ __('app.create_account') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ __('app.catalog_sources_section') }}</h3>
|
||||
<p class="text-muted text-sm">{{ __('app.catalog_sources_desc') }}</p>
|
||||
|
||||
@error('source_url')
|
||||
<div class="form-error mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
@if (empty($catalogSources))
|
||||
<p class="text-muted text-sm mt-2">{{ __('app.no_sources') }}</p>
|
||||
@else
|
||||
<ul class="feed-list mt-2">
|
||||
@foreach ($catalogSources as $source)
|
||||
<li style="display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 10px;">
|
||||
<span class="text-sm" style="overflow-wrap:anywhere;">{{ $source }}</span>
|
||||
<form method="POST" action="{{ route('settings.removeSource') }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="source_url" value="{{ $source }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">{{ __('app.remove') }}</button>
|
||||
</form>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('settings.addSource') }}" class="mt-2">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label for="source-url">{{ __('app.source_url_label') }}</label>
|
||||
<input type="url" name="source_url" id="source-url" class="form-input" placeholder="https://git.example.com/user/repo/raw/branch/main/bloomflux.json" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ __('app.add_source') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ __('app.users_section') }}</h3>
|
||||
<ul class="feed-list mt-1">
|
||||
@foreach ($users as $user)
|
||||
<li style="display:flex; align-items:center; justify-content:space-between; padding:8px 10px;">
|
||||
<span>
|
||||
<strong>{{ $user->name }}</strong>
|
||||
<span class="text-muted text-sm">· {{ $user->email }}</span>
|
||||
</span>
|
||||
@if ($user->is_owner)
|
||||
<span class="pill">{{ __('app.owner_badge') }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -6,10 +6,12 @@ use App\Http\Controllers\CatalogController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\FeedController;
|
||||
use App\Http\Controllers\PageController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', [PageController::class, 'home'])->name('home');
|
||||
Route::get('/self-hosting', [PageController::class, 'selfHosting'])->name('self-hosting');
|
||||
Route::post('/locale', [SettingsController::class, 'updateLocale'])->name('locale.update');
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
|
||||
@@ -25,6 +27,12 @@ Route::middleware('auth')->group(function () {
|
||||
|
||||
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
|
||||
|
||||
Route::get('/settings', [SettingsController::class, 'index'])->name('settings');
|
||||
Route::post('/settings/registrations', [SettingsController::class, 'toggleRegistrations'])->name('settings.toggleRegistrations');
|
||||
Route::post('/settings/users', [SettingsController::class, 'createUser'])->name('settings.createUser');
|
||||
Route::post('/settings/sources', [SettingsController::class, 'addCatalogSource'])->name('settings.addSource');
|
||||
Route::delete('/settings/sources', [SettingsController::class, 'removeCatalogSource'])->name('settings.removeSource');
|
||||
|
||||
Route::get('/feeds', [FeedController::class, 'index'])->name('feeds.index');
|
||||
Route::post('/feeds', [FeedController::class, 'store'])->name('feeds.store');
|
||||
Route::delete('/feeds/{feed}', [FeedController::class, 'destroy'])->name('feeds.destroy');
|
||||
|
||||
+28
-28
@@ -5,22 +5,22 @@ REPO_URL="https://git.eldorianet.work/esteban/BloomFeed-RSS"
|
||||
APP_DIR="/opt/bloomfeed"
|
||||
HOST_PORT="7081"
|
||||
|
||||
echo "==> Kill forcé de tout process qui écoute sur le port ${HOST_PORT}"
|
||||
echo "==> Killing any process listening on port ${HOST_PORT}"
|
||||
PIDS="$(sudo lsof -ti tcp:${HOST_PORT} || true)"
|
||||
if [[ -n "${PIDS}" ]]; then
|
||||
echo "PIDs trouvés sur ${HOST_PORT}: ${PIDS}"
|
||||
echo "PIDs found on ${HOST_PORT}: ${PIDS}"
|
||||
sudo kill -9 ${PIDS} || true
|
||||
else
|
||||
echo "Aucun process actif sur ${HOST_PORT}"
|
||||
echo "No active process on ${HOST_PORT}"
|
||||
fi
|
||||
|
||||
if [[ -d "${APP_DIR}/.git" ]]; then
|
||||
echo "==> Mise à jour du dépôt existant"
|
||||
echo "==> Updating existing repository"
|
||||
sudo git -C "${APP_DIR}" remote set-url origin "${REPO_URL}"
|
||||
sudo git -C "${APP_DIR}" fetch --all
|
||||
sudo git -C "${APP_DIR}" reset --hard origin/main
|
||||
else
|
||||
echo "==> Suppression complète de ${APP_DIR} et clone propre du dépôt"
|
||||
echo "==> Removing ${APP_DIR} and cloning a fresh copy"
|
||||
sudo rm -rf "${APP_DIR}"
|
||||
sudo mkdir -p "${APP_DIR}"
|
||||
sudo git clone "${REPO_URL}" "${APP_DIR}"
|
||||
@@ -29,67 +29,67 @@ fi
|
||||
cd "${APP_DIR}"
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "==> Aucun .env trouvé, création à partir de .env.example"
|
||||
echo "==> No .env found, creating one from .env.example"
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
echo "==> Génération des secrets manquants dans .env (ne régénère jamais un secret déjà présent)"
|
||||
echo "==> Generating missing secrets in .env (existing secrets are never regenerated)"
|
||||
if grep -q '^APP_KEY=$' .env; then
|
||||
sed -i "s#^APP_KEY=.*#APP_KEY=base64:$(openssl rand -base64 32)#" .env
|
||||
echo " - APP_KEY généré"
|
||||
echo " - APP_KEY generated"
|
||||
fi
|
||||
if grep -q '^DB_PASSWORD=$' .env; then
|
||||
sed -i "s#^DB_PASSWORD=.*#DB_PASSWORD=$(openssl rand -hex 24)#" .env
|
||||
echo " - DB_PASSWORD généré"
|
||||
echo " - DB_PASSWORD generated"
|
||||
fi
|
||||
if grep -q '^DB_ROOT_PASSWORD=$' .env; then
|
||||
sed -i "s#^DB_ROOT_PASSWORD=.*#DB_ROOT_PASSWORD=$(openssl rand -hex 24)#" .env
|
||||
echo " - DB_ROOT_PASSWORD généré"
|
||||
echo " - DB_ROOT_PASSWORD generated"
|
||||
fi
|
||||
chmod 600 .env
|
||||
|
||||
echo "==> Arrêt de l'ancienne stack Docker Compose si présente"
|
||||
echo "==> Stopping the previous Docker Compose stack if present"
|
||||
sudo docker compose down --remove-orphans || true
|
||||
|
||||
echo "==> Build des images Docker (app + scheduler)"
|
||||
echo "==> Building the Docker images (app + scheduler)"
|
||||
sudo docker compose build --pull
|
||||
|
||||
echo "==> Lancement de la stack (app, scheduler, db)"
|
||||
echo "==> Starting the stack (app, scheduler, db)"
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Attente de la disponibilité de la base de données"
|
||||
echo "==> Waiting for the database to be ready"
|
||||
for i in $(seq 1 30); do
|
||||
if sudo docker compose exec -T db healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then
|
||||
echo "==> Base de données prête"
|
||||
echo "==> Database ready"
|
||||
break
|
||||
fi
|
||||
echo "==> DB pas encore prête, nouvelle tentative ($i/30)"
|
||||
echo "==> Database not ready yet, retrying ($i/30)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# NOTE : ne PAS lancer "artisan migrate" ici — l'entrypoint du conteneur app le fait
|
||||
# déjà au démarrage, et deux migrations concurrentes corrompent le registre des
|
||||
# migrations ("table already exists").
|
||||
echo "==> Attente de la fin des migrations de l'entrypoint"
|
||||
# NOTE: do NOT run "artisan migrate" here — the app container entrypoint already
|
||||
# runs it at startup, and two concurrent migrations corrupt the migrations
|
||||
# ledger ("table already exists").
|
||||
echo "==> Waiting for the entrypoint migrations to finish"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -o /dev/null "http://localhost:${HOST_PORT}/up"; then
|
||||
echo "==> Application démarrée"
|
||||
echo "==> Application up"
|
||||
break
|
||||
fi
|
||||
echo "==> App pas encore prête, nouvelle tentative ($i/30)"
|
||||
echo "==> Application not ready yet, retrying ($i/30)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "==> Vérification des conteneurs"
|
||||
echo "==> Container status"
|
||||
sudo docker compose ps
|
||||
|
||||
echo "==> Vérification du port ${HOST_PORT}"
|
||||
echo "==> Checking port ${HOST_PORT}"
|
||||
sudo lsof -nP -iTCP:${HOST_PORT} -sTCP:LISTEN || true
|
||||
|
||||
echo "==> Test HTTP de l'application"
|
||||
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:${HOST_PORT}/login" || echo "L'application ne répond pas encore, vérifiez les logs."
|
||||
echo "==> HTTP check"
|
||||
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:${HOST_PORT}/login" || echo "The application is not responding yet, check the logs."
|
||||
|
||||
echo "==> Logs récents"
|
||||
echo "==> Recent logs"
|
||||
sudo docker compose logs --tail 50 app || true
|
||||
|
||||
echo "==> Déploiement BloomFeed terminé — http://<ip-serveur>:${HOST_PORT}"
|
||||
echo "==> BloomFeed deployment finished — http://<server-ip>:${HOST_PORT}"
|
||||
|
||||
+25
-25
@@ -15,80 +15,80 @@ for arg in "$@"; do
|
||||
--yes|-y) ASSUME_YES=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: bash uninstall.sh [--purge] [--purge-source] [--yes]"
|
||||
echo " --purge Supprime aussi les volumes (base de données, storage)"
|
||||
echo " --purge-source Supprime aussi le code source et le .env dans ${APP_DIR} (nécessite --purge)"
|
||||
echo " --yes Ne pas demander de confirmation interactive"
|
||||
echo " --purge Also remove the volumes (database, storage)"
|
||||
echo " --purge-source Also remove the source code and the .env in ${APP_DIR}"
|
||||
echo " --yes Skip interactive confirmations"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Option inconnue: $arg (utilisez --help)"
|
||||
echo "Unknown option: $arg (use --help)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "${APP_DIR}" ]]; then
|
||||
echo "ERREUR: ${APP_DIR} introuvable — rien à désinstaller."
|
||||
echo "ERROR: ${APP_DIR} not found — nothing to uninstall."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
echo "==> Arrêt et suppression des conteneurs BloomFeed"
|
||||
echo "==> Stopping and removing the BloomFeed containers"
|
||||
sudo docker compose down --remove-orphans || true
|
||||
|
||||
if [[ "${PURGE_DATA}" == true ]]; then
|
||||
echo
|
||||
echo "ATTENTION: cette étape va supprimer DÉFINITIVEMENT :"
|
||||
echo " - la base de données MariaDB (tous les comptes, flux, articles)"
|
||||
echo " - les fichiers de storage de l'application"
|
||||
echo "WARNING: this step will PERMANENTLY delete:"
|
||||
echo " - the MariaDB database (all accounts, feeds, articles)"
|
||||
echo " - the application storage files"
|
||||
|
||||
if [[ "${ASSUME_YES}" != true ]]; then
|
||||
read -r -p "Tapez SUPPRIMER pour confirmer : " CONFIRM
|
||||
if [[ "${CONFIRM}" != "SUPPRIMER" ]]; then
|
||||
echo "Annulé. Les volumes de données sont conservés."
|
||||
read -r -p "Type DELETE to confirm: " CONFIRM
|
||||
if [[ "${CONFIRM}" != "DELETE" ]]; then
|
||||
echo "Cancelled. Data volumes are kept."
|
||||
PURGE_DATA=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${PURGE_DATA}" == true ]]; then
|
||||
echo "==> Suppression des volumes Docker"
|
||||
echo "==> Removing Docker volumes"
|
||||
sudo docker volume rm \
|
||||
"${PROJECT_NAME}_db_data" \
|
||||
"${PROJECT_NAME}_app_storage" \
|
||||
"${PROJECT_NAME}_scheduler_storage" \
|
||||
2>/dev/null || true
|
||||
|
||||
echo "==> Suppression des images Docker construites"
|
||||
echo "==> Removing built Docker images"
|
||||
sudo docker rmi "${PROJECT_NAME}-app" "${PROJECT_NAME}-scheduler" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "==> Volumes de données conservés (relancez avec --purge pour les supprimer)"
|
||||
echo "==> Data volumes kept (re-run with --purge to remove them)"
|
||||
fi
|
||||
|
||||
if [[ "${PURGE_SOURCE}" == true ]]; then
|
||||
echo
|
||||
echo "ATTENTION: cette étape va supprimer ${APP_DIR}, y compris le fichier .env (secrets, mots de passe)."
|
||||
echo "WARNING: this step will delete ${APP_DIR}, including the .env file (secrets, passwords)."
|
||||
|
||||
if [[ "${ASSUME_YES}" != true ]]; then
|
||||
read -r -p "Tapez SUPPRIMER LE CODE pour confirmer : " CONFIRM_SRC
|
||||
if [[ "${CONFIRM_SRC}" != "SUPPRIMER LE CODE" ]]; then
|
||||
echo "Annulé. ${APP_DIR} est conservé."
|
||||
read -r -p "Type DELETE THE CODE to confirm: " CONFIRM_SRC
|
||||
if [[ "${CONFIRM_SRC}" != "DELETE THE CODE" ]]; then
|
||||
echo "Cancelled. ${APP_DIR} is kept."
|
||||
PURGE_SOURCE=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${PURGE_SOURCE}" == true ]]; then
|
||||
cd /
|
||||
echo "==> Suppression de ${APP_DIR}"
|
||||
echo "==> Removing ${APP_DIR}"
|
||||
sudo rm -rf "${APP_DIR}"
|
||||
fi
|
||||
else
|
||||
echo "==> Code source conservé dans ${APP_DIR} (relancez avec --purge-source pour le supprimer aussi)"
|
||||
echo "==> Source code kept in ${APP_DIR} (re-run with --purge-source to remove it too)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==> Désinstallation terminée."
|
||||
echo " Conteneurs/réseau : supprimés"
|
||||
echo " Volumes de données : $([[ "${PURGE_DATA}" == true ]] && echo supprimés || echo conservés)"
|
||||
echo " Code source (${APP_DIR}) : $([[ "${PURGE_SOURCE}" == true ]] && echo supprimé || echo conservé)"
|
||||
echo "==> Uninstall finished."
|
||||
echo " Containers/network : removed"
|
||||
echo " Data volumes : $([[ "${PURGE_DATA}" == true ]] && echo removed || echo kept)"
|
||||
echo " Source (${APP_DIR}): $([[ "${PURGE_SOURCE}" == true ]] && echo removed || echo kept)"
|
||||
|
||||
Reference in New Issue
Block a user