Upgrade of BloomFeed v0.1
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
APP_NAME=BloomFeed
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=http://localhost:7081
|
||||||
|
|
||||||
|
APP_LOCALE=fr
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=fr_FR
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=bloomfeed
|
||||||
|
DB_USERNAME=bloomfeed
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# Utilisé par docker-compose pour initialiser MariaDB (doit correspondre à DB_PASSWORD ci-dessus)
|
||||||
|
DB_ROOT_PASSWORD=
|
||||||
|
|
||||||
|
# Catalogue central BloomFluxDB (lecture seule)
|
||||||
|
BLOOMFLUX_DB_HOST=163.5.159.95
|
||||||
|
BLOOMFLUX_DB_PORT=3306
|
||||||
|
BLOOMFLUX_DB_DATABASE=bloomfluxdb
|
||||||
|
BLOOMFLUX_DB_USERNAME=bloomflux_reader
|
||||||
|
BLOOMFLUX_DB_PASSWORD=BloomFlux_Public_Read_2026
|
||||||
|
BLOOMFLUX_DB_TIMEOUT=4
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.claude
|
||||||
|
/.codex
|
||||||
|
/.cursor/
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/fonts-manifest.dev.json
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
_ide_helper.php
|
||||||
|
composer.phar
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
@@ -21,6 +21,8 @@ n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
|
|||||||
- Lecture approfondie intégrée : extraction du contenu principal (via
|
- Lecture approfondie intégrée : extraction du contenu principal (via
|
||||||
[Readability](https://github.com/fivefilters/readability.php)) converti en Markdown, sans
|
[Readability](https://github.com/fivefilters/readability.php)) converti en Markdown, sans
|
||||||
quitter BloomFeed.
|
quitter BloomFeed.
|
||||||
|
- Catalogue de flux façon "market" alimenté par **BloomFluxDB** (base centrale en lecture seule),
|
||||||
|
avec cache local de secours si le serveur catalogue est injoignable.
|
||||||
- Récupération périodique des flux via une tâche planifiée.
|
- Récupération périodique des flux via une tâche planifiée.
|
||||||
- Thème clair/sombre.
|
- Thème clair/sombre.
|
||||||
- Inscription fermée après la création du compte propriétaire — pas de SaaS ouvert.
|
- Inscription fermée après la création du compte propriétaire — pas de SaaS ouvert.
|
||||||
@@ -31,9 +33,9 @@ n'a qu'un seul compte propriétaire, créé une seule fois au premier lancement.
|
|||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Mode sombre | Connexion |
|
| Catalogue de flux | Mode sombre |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ planificateur, MariaDB). Voir la page **[/self-hosting](/self-hosting)** une foi
|
|||||||
l'application lancée, ou directement :
|
l'application lancée, ou directement :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.eldorianet.work/esteban/RSS-Eldoria-Network bloomfeed
|
git clone https://git.eldorianet.work/esteban/BloomFeed-RSS bloomfeed
|
||||||
cd bloomfeed
|
cd bloomfeed
|
||||||
bash scriptsite.sh
|
bash scriptsite.sh
|
||||||
```
|
```
|
||||||
@@ -78,6 +80,23 @@ Pour désinstaller proprement (avec confirmation avant toute suppression de donn
|
|||||||
bash uninstall.sh
|
bash uninstall.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## BloomFluxDB (catalogue central)
|
||||||
|
|
||||||
|
Les instances BloomFeed consultent en lecture seule une base MariaDB centrale contenant une
|
||||||
|
liste de flux pré-sélectionnés, affichée dans l'onglet **Catalogue**. La liste est mise en cache
|
||||||
|
localement : si le serveur catalogue est injoignable, la dernière copie connue est servie, et
|
||||||
|
elle se rafraîchit automatiquement à la prochaine consultation réussie.
|
||||||
|
|
||||||
|
Le script de création (base, table, utilisateur `bloomflux_reader` en `SELECT` uniquement, et
|
||||||
|
liste de flux initiale) est dans [docs/bloomfluxdb-setup.sql](docs/bloomfluxdb-setup.sql) — à
|
||||||
|
exécuter sur le serveur catalogue :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p < docs/bloomfluxdb-setup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
La connexion se configure dans le `.env` de chaque instance via les variables `BLOOMFLUX_DB_*`.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT.
|
MIT.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\FluxCatalogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class CatalogController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, FluxCatalogService $catalog): View
|
||||||
|
{
|
||||||
|
$result = $catalog->getCatalog();
|
||||||
|
|
||||||
|
$subscribedUrls = $request->user()->feeds()->pluck('url')->all();
|
||||||
|
|
||||||
|
return view('catalog', [
|
||||||
|
'feeds' => collect($result['feeds'])->groupBy('category'),
|
||||||
|
'fromCache' => $result['fromCache'],
|
||||||
|
'updatedAt' => $result['updatedAt'],
|
||||||
|
'subscribedUrls' => $subscribedUrls,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class FluxCatalogService
|
||||||
|
{
|
||||||
|
private const CACHE_KEY = 'bloomflux.catalog';
|
||||||
|
|
||||||
|
private const CACHE_TIME_KEY = 'bloomflux.catalog_updated_at';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le catalogue BloomFluxDB, en le rafraîchissant depuis le serveur
|
||||||
|
* distant quand il est joignable, sinon en servant la dernière copie en cache.
|
||||||
|
*
|
||||||
|
* @return array{feeds: list<array{title: string, url: string, category: string, lang: string, description: ?string}>, fromCache: bool, updatedAt: ?Carbon}
|
||||||
|
*/
|
||||||
|
public function getCatalog(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = DB::connection('bloomflux')
|
||||||
|
->table('feeds')
|
||||||
|
->where('is_active', 1)
|
||||||
|
->orderBy('category')
|
||||||
|
->orderBy('title')
|
||||||
|
->get(['title', 'url', 'category', 'lang', 'description'])
|
||||||
|
->map(fn ($row) => (array) $row)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
Cache::forever(self::CACHE_KEY, $rows);
|
||||||
|
Cache::forever(self::CACHE_TIME_KEY, now()->toIso8601String());
|
||||||
|
|
||||||
|
return ['feeds' => $rows, 'fromCache' => false, 'updatedAt' => now()];
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('BloomFluxDB unreachable, serving cached catalog', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cached = Cache::get(self::CACHE_KEY, []);
|
||||||
|
$updatedAt = Cache::get(self::CACHE_TIME_KEY);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'feeds' => $cached,
|
||||||
|
'fromCache' => true,
|
||||||
|
'updatedAt' => $updatedAt ? Carbon::parse($updatedAt) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,24 @@ return [
|
|||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Catalogue central BloomFluxDB (lecture seule, distant) — timeout court
|
||||||
|
// pour ne jamais bloquer la page si le serveur catalogue est injoignable.
|
||||||
|
'bloomflux' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => env('BLOOMFLUX_DB_HOST', '163.5.159.95'),
|
||||||
|
'port' => env('BLOOMFLUX_DB_PORT', '3306'),
|
||||||
|
'database' => env('BLOOMFLUX_DB_DATABASE', 'bloomfluxdb'),
|
||||||
|
'username' => env('BLOOMFLUX_DB_USERNAME', 'bloomflux_reader'),
|
||||||
|
'password' => env('BLOOMFLUX_DB_PASSWORD', 'BloomFlux_Public_Read_2026'),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'strict' => true,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? [
|
||||||
|
\PDO::ATTR_TIMEOUT => (int) env('BLOOMFLUX_DB_TIMEOUT', 4),
|
||||||
|
] : [],
|
||||||
|
],
|
||||||
|
|
||||||
'mariadb' => [
|
'mariadb' => [
|
||||||
'driver' => 'mariadb',
|
'driver' => 'mariadb',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- BloomFluxDB — catalogue central de flux RSS pour les instances BloomFeed
|
||||||
|
-- À exécuter sur le serveur catalogue (163.5.159.95), en root MariaDB/MySQL :
|
||||||
|
-- mysql -u root -p < bloomfluxdb-setup.sql
|
||||||
|
--
|
||||||
|
-- Prérequis réseau : MariaDB doit écouter sur l'extérieur
|
||||||
|
-- (bind-address = 0.0.0.0 dans /etc/mysql/mariadb.conf.d/50-server.cnf,
|
||||||
|
-- puis systemctl restart mariadb) et le port 3306 doit être ouvert.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS bloomfluxdb
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE bloomfluxdb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feeds (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
url VARCHAR(2048) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL DEFAULT 'Divers',
|
||||||
|
lang CHAR(2) NOT NULL DEFAULT 'fr',
|
||||||
|
description VARCHAR(500) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_feeds_url (url(500)),
|
||||||
|
KEY idx_feeds_category (category),
|
||||||
|
KEY idx_feeds_active (is_active)
|
||||||
|
) ENGINE = InnoDB;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Utilisateur en LECTURE SEULE pour les instances BloomFeed ('%' = accessible
|
||||||
|
-- depuis n'importe quelle IP). Changez le mot de passe si vous voulez restreindre,
|
||||||
|
-- et reportez-le dans le .env des instances (BLOOMFLUX_DB_PASSWORD).
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE USER IF NOT EXISTS 'bloomflux_reader'@'%' IDENTIFIED BY 'BloomFlux_Public_Read_2026';
|
||||||
|
GRANT SELECT ON bloomfluxdb.feeds TO 'bloomflux_reader'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Liste de flux pré-faite (extensible à volonté : INSERT depuis root/localhost)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT IGNORE INTO feeds (title, url, category, lang, description) VALUES
|
||||||
|
-- Actualités générales
|
||||||
|
('Le Monde', 'https://www.lemonde.fr/rss/une.xml', 'Actualités', 'fr', 'La une du journal Le Monde.'),
|
||||||
|
('France Info', 'https://www.franceinfo.fr/titres.rss', 'Actualités', 'fr', 'Les titres de France Info en continu.'),
|
||||||
|
('Courrier International', 'https://www.courrierinternational.com/feed/all/rss.xml', 'Actualités', 'fr', 'L''actualité internationale vue par la presse étrangère.'),
|
||||||
|
('BBC News', 'https://feeds.bbci.co.uk/news/rss.xml', 'Actualités', 'en', 'Fil d''actualité mondial de la BBC.'),
|
||||||
|
-- Tech & informatique
|
||||||
|
('Korben', 'https://korben.info/feed', 'Tech', 'fr', 'Actu tech, astuces et bidouilles.'),
|
||||||
|
('Numerama', 'https://www.numerama.com/feed/', 'Tech', 'fr', 'Société numérique, tech et sciences.'),
|
||||||
|
('Next', 'https://next.ink/feed/', 'Tech', 'fr', 'Actualité informatique et numérique de fond.'),
|
||||||
|
('Journal du hacker', 'https://www.journalduhacker.net/rss', 'Tech', 'fr', 'Liens tech partagés par la communauté francophone.'),
|
||||||
|
('Ars Technica', 'https://feeds.arstechnica.com/arstechnica/index', 'Tech', 'en', 'Analyses tech approfondies.'),
|
||||||
|
('Hacker News', 'https://news.ycombinator.com/rss', 'Tech', 'en', 'Les liens les plus discutés de la communauté HN.'),
|
||||||
|
('The Verge', 'https://www.theverge.com/rss/index.xml', 'Tech', 'en', 'Tech, science et culture numérique.'),
|
||||||
|
-- Linux & open source
|
||||||
|
('Phoronix', 'https://www.phoronix.com/rss.php', 'Open source', 'en', 'Linux, matériel et open source.'),
|
||||||
|
('LinuxFr', 'https://linuxfr.org/news.atom', 'Open source', 'fr', 'L''actualité du logiciel libre en français.'),
|
||||||
|
('It''s FOSS', 'https://itsfoss.com/rss/', 'Open source', 'en', 'Tutoriels et actus Linux/open source.'),
|
||||||
|
-- Sécurité
|
||||||
|
('CERT-FR', 'https://www.cert.ssi.gouv.fr/feed/', 'Sécurité', 'fr', 'Alertes et avis de sécurité de l''ANSSI.'),
|
||||||
|
('Krebs on Security', 'https://krebsonsecurity.com/feed/', 'Sécurité', 'en', 'Investigations cybersécurité de Brian Krebs.'),
|
||||||
|
('Zataz', 'https://www.zataz.com/feed/', 'Sécurité', 'fr', 'Actualité cybersécurité et fuites de données.'),
|
||||||
|
-- Développement
|
||||||
|
('Dev.to', 'https://dev.to/feed', 'Développement', 'en', 'Articles de la communauté dev.to.'),
|
||||||
|
('GitHub Blog', 'https://github.blog/feed/', 'Développement', 'en', 'Nouveautés et ingénierie GitHub.'),
|
||||||
|
('Laravel News', 'https://feed.laravel-news.com/', 'Développement', 'en', 'L''écosystème Laravel au quotidien.'),
|
||||||
|
-- Sciences
|
||||||
|
('Futura Sciences', 'https://www.futura-sciences.com/rss/actualites.xml', 'Sciences', 'fr', 'Actualités scientifiques tous domaines.'),
|
||||||
|
('CNRS Le Journal', 'https://lejournal.cnrs.fr/rss', 'Sciences', 'fr', 'Le journal de la recherche française.'),
|
||||||
|
-- Jeux vidéo
|
||||||
|
('Gamekult', 'https://www.gamekult.com/feed.xml', 'Jeux vidéo', 'fr', 'Actualité et critiques jeux vidéo.'),
|
||||||
|
('Rock Paper Shotgun', 'https://www.rockpapershotgun.com/feed', 'Jeux vidéo', 'en', 'Jeux PC, tests et actus.');
|
||||||
|
|
||||||
|
-- Vérification rapide
|
||||||
|
SELECT category, COUNT(*) AS nb FROM feeds GROUP BY category ORDER BY category;
|
||||||
|
After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 674 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 322 KiB |
@@ -998,6 +998,148 @@ h1, h2, h3 {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Catalogue (market) ---------- */
|
||||||
|
|
||||||
|
.market-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-notice {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid var(--accent-soft-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
animation: fade-up 350ms var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-search {
|
||||||
|
font-size: var(--font-base);
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 6px 15px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast), transform var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active {
|
||||||
|
background: var(--gradient-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-static {
|
||||||
|
cursor: default;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-static:hover {
|
||||||
|
transform: none;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
animation: fade-up 450ms var(--ease-out) both;
|
||||||
|
transition: transform var(--t-base), box-shadow var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--accent-soft-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card-desc {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-lang {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-added {
|
||||||
|
color: var(--green);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -31,4 +31,49 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catalogue : recherche + filtre par catégorie côté client
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var grid = document.getElementById('market-grid');
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var search = document.getElementById('market-search');
|
||||||
|
var chips = document.getElementById('market-chips');
|
||||||
|
var emptyNotice = document.getElementById('market-empty');
|
||||||
|
var activeCategory = '';
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
var query = (search.value || '').toLowerCase().trim();
|
||||||
|
var visible = 0;
|
||||||
|
|
||||||
|
grid.querySelectorAll('.market-card').forEach(function (card) {
|
||||||
|
var matchesCategory = !activeCategory || card.getAttribute('data-category') === activeCategory;
|
||||||
|
var matchesQuery = !query || card.getAttribute('data-search').indexOf(query) !== -1;
|
||||||
|
var show = matchesCategory && matchesQuery;
|
||||||
|
card.hidden = !show;
|
||||||
|
if (show) {
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emptyNotice.hidden = visible > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
chips.addEventListener('click', function (event) {
|
||||||
|
var chip = event.target.closest('.chip');
|
||||||
|
if (!chip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chips.querySelectorAll('.chip').forEach(function (c) {
|
||||||
|
c.classList.remove('active');
|
||||||
|
});
|
||||||
|
chip.classList.add('active');
|
||||||
|
activeCategory = chip.getAttribute('data-category');
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@extends('layouts.app', ['title' => 'Catalogue — BloomFeed'])
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="main-column" style="width:100%;">
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="form-status">{{ session('status') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="market-header">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0;">Catalogue de flux</h2>
|
||||||
|
<p class="text-muted text-sm" style="margin:4px 0 0;">
|
||||||
|
Une sélection de flux RSS maintenue par BloomFluxDB — ajoutez-les à votre collection en un clic.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($fromCache)
|
||||||
|
<div class="cache-notice">
|
||||||
|
⚠ Serveur catalogue injoignable — liste servie depuis le cache local
|
||||||
|
@if ($updatedAt)
|
||||||
|
(mise à jour {{ $updatedAt->diffForHumans() }})
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($feeds->isEmpty())
|
||||||
|
<div class="card empty-state">
|
||||||
|
Le catalogue est vide pour le moment.
|
||||||
|
@if ($fromCache)
|
||||||
|
Il sera récupéré automatiquement dès que le serveur catalogue sera joignable.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<input type="search" id="market-search" class="form-input market-search" placeholder="Rechercher un flux… (nom, description, catégorie)">
|
||||||
|
|
||||||
|
<div class="market-chips" id="market-chips">
|
||||||
|
<button type="button" class="chip active" data-category="">Tout</button>
|
||||||
|
@foreach ($feeds->keys() as $category)
|
||||||
|
<button type="button" class="chip" data-category="{{ $category }}">{{ $category }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-head">
|
||||||
|
<span class="market-card-title">{{ $item['title'] }}</span>
|
||||||
|
<span class="chip chip-static">{{ $category }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="market-card-desc">{{ $item['description'] ?: $item['url'] }}</p>
|
||||||
|
<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>
|
||||||
|
@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>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card empty-state" id="market-empty" hidden>Aucun flux ne correspond à votre recherche.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<span>BloomFeed — projet open-source, auto-hébergé.</span>
|
<span>BloomFeed — projet open-source, auto-hébergé.</span>
|
||||||
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">Code source</a>
|
<a href="https://git.eldorianet.work/esteban/BloomFeed-RSS" target="_blank" rel="noopener">Code source</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="navbar-links">
|
<div class="navbar-links">
|
||||||
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">Articles</a>
|
<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>
|
<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>
|
<button type="button" class="theme-toggle" data-theme-toggle title="Changer de thème">◐</button>
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<h2>Code source</h2>
|
<h2>Code source</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://git.eldorianet.work/esteban/RSS-Eldoria-Network" target="_blank" rel="noopener">git.eldorianet.work/esteban/RSS-Eldoria-Network</a>
|
<a href="https://git.eldorianet.work/esteban/BloomFeed-RSS" target="_blank" rel="noopener">git.eldorianet.work/esteban/BloomFeed-RSS</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\ArticleController;
|
use App\Http\Controllers\ArticleController;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\CatalogController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\FeedController;
|
use App\Http\Controllers\FeedController;
|
||||||
use App\Http\Controllers\PageController;
|
use App\Http\Controllers\PageController;
|
||||||
@@ -22,6 +23,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
|
||||||
|
|
||||||
Route::get('/feeds', [FeedController::class, 'index'])->name('feeds.index');
|
Route::get('/feeds', [FeedController::class, 'index'])->name('feeds.index');
|
||||||
Route::post('/feeds', [FeedController::class, 'store'])->name('feeds.store');
|
Route::post('/feeds', [FeedController::class, 'store'])->name('feeds.store');
|
||||||
Route::delete('/feeds/{feed}', [FeedController::class, 'destroy'])->name('feeds.destroy');
|
Route::delete('/feeds/{feed}', [FeedController::class, 'destroy'])->name('feeds.destroy');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="https://git.eldorianet.work/esteban/RSS-Eldoria-Network"
|
REPO_URL="https://git.eldorianet.work/esteban/BloomFeed-RSS"
|
||||||
APP_DIR="/opt/bloomfeed"
|
APP_DIR="/opt/bloomfeed"
|
||||||
HOST_PORT="7081"
|
HOST_PORT="7081"
|
||||||
|
|
||||||
|
|||||||