A grid/card view for displaying feeds with images, configurable 2-4 columns (#477)

* A grid/card view for displaying feeds with images, configurable 2-4 columns

* Allow typos Stroe

* Add screenshots to README

* Keep only one image, typography, version

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
This commit is contained in:
Marius StroeandAlexandre Alapetite authored and GitHub committed 2026-06-29 15:22:40 +02:00
1 parent b7a8fa3366
commit f0e681f03b
13 files changed
+3593

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
[default.extend-identifiers]
ot = "ot"
Stroe = "Stroe"
Ths2 = "Ths2"
[default.extend-words]
+17
View File
@@ -0,0 +1,17 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2024 FreshRSS User
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+119
View File
@@ -0,0 +1,119 @@
# Grid View Extension for FreshRSS
A card/grid view extension for [FreshRSS](https://github.com/FreshRSS/FreshRSS) that displays feed entries in a responsive multi-column layout with prominent images.
## Features
- **Card-based layout**: Displays entries as cards with thumbnails, titles, source info, and descriptions
- **Configurable columns**: Choose between 2, 3, or 4 columns via the extension settings
- **Responsive design**: Automatically adjusts to fewer columns on tablets and mobile
- **Smart thumbnail extraction**: Uses feed thumbnails when available, falls back to extracting images from content (filters out small images <400x400, logos, icons, and theme assets)
- **Open Graph image fetching**: Optionally fetches OG images from article pages that have no thumbnail in the RSS feed (async with max 3 concurrent fetches)
- **Category/feed header**: Shows the current category or feed name at the top of the grid (“Main Stream” when viewing all feeds)
- **Action bar overlay**: Action buttons (mark read, star, share, open) appear as a transparent overlay on the card thumbnail on hover (desktop) or tap (mobile)
- **Mobile-friendly**: Tapping the article title opens the link directly without requiring card selection first; tapping elsewhere on the card toggles the action bar
- **Mobile sidebar toggle**: Optional floating hamburger button on mobile screens to open the FreshRSS sidebar without scrolling to the top
- **Browser Reader Mode**: Opens articles in Firefox Reader Mode (or equivalent) for distraction-free reading
- **FreshRSS Native Share**: Integrated share dropdown using your configured sharing services
- **Star/Favorite support**: Mark articles as favorites directly from the card with AJAX sync
- **Sort by publication date**: Optional setting to default sorting to publication date (newest first)
- **Keyboard shortcut**: Press <kbd>G</kbd> to toggle grid view on/off
- **Dark theme support**: Works seamlessly with FreshRSS dark themes
- **Persistent preference**: Your grid view state is saved locally across sessions
- **No flash of list view**: Seamless transitions during AJAX feed navigation with FOLV prevention
## Screenshots
Grid view — 3 columns (single feed):
![Grid view with 3 columns showing Apple feed articles](screenshots/FreshRSSGrid1.jpg)
## Installation
1. Download or clone this repository
2. Copy the `xExtension-GridView` folder to your FreshRSS `extensions/` directory
3. In FreshRSS, go to **Settings → Extensions**
4. Enable the “Grid View” extension
## Usage
1. After enabling the extension, go to its configuration page to set your preferred options
2. Click the **grid icon (▦)** in the header area or press <kbd>G</kbd> on your keyboard to toggle grid view
3. Click on any card to open the article in browser Reader Mode (Firefox) or a new tab
4. Hover over a card (or tap on mobile) to reveal the action buttons:
- **✓ Mark as Read**: Toggle read/unread state
- **★ Star**: Mark the article as a favorite (syncs with FreshRSS)
- **🔗 Share**: Opens FreshRSS native share dropdown with your configured sharing services
- **↗ Open**: Open the original article in a new tab
5. Articles are automatically marked as read when you click on them
Your grid view preference is saved locally and persists across sessions.
## Configuration
| Option | Description | Default |
| ------ | ----------- | ------- |
| Number of columns | How many columns to display (2-4) | 3 |
| Thumbnail fetching | Fetch Open Graph images from article pages that lack thumbnails | Off |
| Default sorting | Sort by publication date, newest first | Off |
| Mobile menu button | Show a floating sidebar toggle button on mobile screens | Off |
| Sticky navigation bar | Keep the top navigation bar visible while scrolling | On |
## Requirements
- FreshRSS 1.20.0 or later
## Responsive Breakpoints
| Screen Width | Columns |
| ------------ | ------- |
| > 1200px | Configured value (2-4) |
| 900px - 1200px | Up to 3 |
| 600px - 900px | 2 |
| < 600px | 1 |
## Development
### File Structure
```text
xExtension-GridView/
├── metadata.json # Extension metadata
├── extension.php # Main PHP class (hooks, OG image fetching, config)
├── configure.phtml # Configuration form
├── static/
│ ├── grid.css # Grid layout styles
│ ├── grid.js # Card transformation + state sync logic
│ └── placeholder.jpg # Fallback thumbnail image
├── i18n/
│ ├── en/ext.php # English translations
│ ├── fr/ext.php # French translations
│ └── de/ext.php # German translations
├── LICENSE
└── README.md
```
### How It Works
1. The extension registers hooks during `init()`: `js_vars` injects configuration, `entry_before_display` injects thumbnail markers, and optionally `entry_before_insert` fetches OG images
2. JavaScript adds a toggle button and listens for the <kbd>G</kbd> keyboard shortcut
3. When grid view is enabled, the stream container gets a `.grid` class and a `gridview-active` class is added to `body` (for FOLV prevention)
4. A context header is inserted showing the current category, feed name, or “Main Stream”
5. FreshRSS date separator `.transition` elements are hidden in grid mode
6. JavaScript transforms existing `.flux` elements into card format with:
- Thumbnail extraction (with smart filtering for size and type)
- Action bar overlay on the thumbnail (visible on hover/tap)
- Star button with AJAX sync via FreshRSS bookmark links
- Share button using FreshRSS native share dropdown
7. CSS applies the grid layout using CSS Grid with `--gridview-columns` custom property
8. MutationObservers watch for dynamically loaded entries, stream replacements, and state changes (read/unread/favorite)
9. Click handlers open articles in browser Reader Mode and mark as read
10. On mobile, tapping the article title opens the link directly; tapping elsewhere toggles the action bar
11. When the mobile menu button setting is enabled, a floating hamburger button appears at the bottom-left on screens under 841px, toggling the FreshRSS sidebar via `toggle_aside_click()`
## License
AGPL-3.0 - See [LICENSE](LICENSE) for details.
## Credits
- Built for [FreshRSS](https://github.com/FreshRSS/FreshRSS)
+98
View File
@@ -0,0 +1,98 @@
<?php
/** @var GridViewExtension $this */
$currentColumns = $this->getColumns();
$fetchOgImage = $this->isOgImageFetchEnabled();
$sortByDate = $this->isSortByPublicationDateEnabled();
$mobileMenuButton = $this->isMobileMenuButtonEnabled();
$stickyNav = $this->isStickyNavEnabled();
?>
<form method="post" action="<?= _url('extension', 'configure', 'e', $this->getName()) ?>">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>">
<div class="form-group">
<label class="group-name" for="columns">
<?= _t('ext.gridview.config.columns') ?>
</label>
<div class="group-controls">
<select id="columns" name="columns">
<?php for ($i = 2; $i <= 4; $i++): ?>
<option value="<?= $i ?>" <?= $currentColumns === $i ? 'selected' : '' ?>>
<?= $i ?> <?= _t('ext.gridview.config.columns_label') ?>
</option>
<?php endfor; ?>
</select>
<p class="help"><?= _t('ext.gridview.config.columns_help') ?></p>
</div>
</div>
<div class="form-group">
<label class="group-name" for="fetch_og_image">
<?= _t('ext.gridview.config.fetch_og_image') ?>
</label>
<div class="group-controls">
<label>
<input type="checkbox" id="fetch_og_image" name="fetch_og_image" value="1" <?= $fetchOgImage ? 'checked' : '' ?> />
<?= _t('ext.gridview.config.fetch_og_image_label') ?>
</label>
<p class="help"><?= _t('ext.gridview.config.fetch_og_image_help') ?></p>
</div>
</div>
<div class="form-group">
<label class="group-name" for="sort_by_date">
<?= _t('ext.gridview.config.sort_by_date') ?>
</label>
<div class="group-controls">
<label>
<input type="checkbox" id="sort_by_date" name="sort_by_date" value="1" <?= $sortByDate ? 'checked' : '' ?> />
<?= _t('ext.gridview.config.sort_by_date_label') ?>
</label>
<p class="help"><?= _t('ext.gridview.config.sort_by_date_help') ?></p>
</div>
</div>
<div class="form-group">
<label class="group-name" for="mobile_menu_button">
<?= _t('ext.gridview.config.mobile_menu_button') ?>
</label>
<div class="group-controls">
<label>
<input type="checkbox" id="mobile_menu_button" name="mobile_menu_button" value="1" <?= $mobileMenuButton ? 'checked' : '' ?> />
<?= _t('ext.gridview.config.mobile_menu_button_label') ?>
</label>
<p class="help"><?= _t('ext.gridview.config.mobile_menu_button_help') ?></p>
</div>
</div>
<div class="form-group">
<label class="group-name" for="sticky_nav">
<?= _t('ext.gridview.config.sticky_nav') ?>
</label>
<div class="group-controls">
<label>
<input type="checkbox" id="sticky_nav" name="sticky_nav" value="1" <?= $stickyNav ? 'checked' : '' ?> />
<?= _t('ext.gridview.config.sticky_nav_label') ?>
</label>
<p class="help"><?= _t('ext.gridview.config.sticky_nav_help') ?></p>
</div>
</div>
<div class="form-group form-actions">
<div class="group-controls">
<button type="submit" class="btn btn-important">
<?= _t('gen.action.submit') ?>
</button>
<button type="reset" class="btn">
<?= _t('gen.action.cancel') ?>
</button>
</div>
</div>
</form>
<div class="form-group">
<label class="group-name"><?= _t('ext.gridview.config.usage_title') ?></label>
<div class="group-controls">
<p class="help"><?= _t('ext.gridview.config.usage_info') ?></p>
</div>
</div>
+516
View File
@@ -0,0 +1,516 @@
<?php
final class GridViewExtension extends Minz_Extension {
/** @var int Default number of columns */
private const DEFAULT_COLUMNS = 3;
/** @var int Minimum columns allowed */
private const MIN_COLUMNS = 2;
/** @var int Maximum columns allowed */
private const MAX_COLUMNS = 4;
/** @var string Default sort field: publication date */
private const DEFAULT_SORT = 'date';
/** @var string Default sort direction: newest first */
private const DEFAULT_SORT_ORDER = 'DESC';
/**
* @throws Minz_PermissionDeniedException
*/
#[\Override]
public function init(): void {
parent::init();
// Load CSS and JS
Minz_View::appendStyle($this->getFileUrl('grid.css'));
Minz_View::appendScript($this->getFileUrl('grid.js'));
// Register translations
$this->registerTranslates();
// Set default sorting to Publication Date, newest first (if enabled)
if ($this->isSortByPublicationDateEnabled()) {
$this->applyDefaultSort();
}
// Inject column count and settings as JS variable
$this->registerHook('js_vars', [$this, 'injectJsVars']);
// Inject thumbnail URL into content so it's available in all view modes
// (Reading View doesn't render the .item.thumbnail element)
$this->registerHook('entry_before_display', [$this, 'injectThumbnailMarker']);
// Fetch OG image for entries that have no thumbnail (user-configurable)
if ($this->isOgImageFetchEnabled()) {
$this->registerHook('entry_before_insert', [$this, 'fetchOgImage']);
// Instead of blocking page render to fetch OG images, inject a
// marker that the JS picks up and fetches asynchronously.
$this->registerHook('entry_before_display', [$this, 'markEntryForOgFetch']);
}
}
/**
* Read a single user configuration value.
*
* Uses the array-based configuration API for compatibility with
* FreshRSS 1.28.x and earlier, which lack the typed getters.
*
* @return mixed
*/
private function cfgValue(string $key): mixed {
/** @phpstan-ignore method.deprecated */
return $this->getUserConfigurationValue($key);
}
/**
* Read the full user configuration array.
*
* @return array<string, mixed>
*/
private function cfgAll(): array {
/** @phpstan-ignore method.deprecated */
return $this->getUserConfiguration();
}
/**
* Persist the full user configuration array.
*
* @param array<string, mixed> $config
*/
private function cfgSave(array $config): void {
/** @phpstan-ignore method.deprecated */
$this->setUserConfiguration($config);
}
/**
* Apply default sort order: Publication Date, newest first (9->1).
* Only sets it once; the user's explicit choice via the FreshRSS UI
* is preserved by checking the extension's own 'sort_applied' flag.
*
* @throws Minz_PermissionDeniedException
*/
private function applyDefaultSort(): void {
if ($this->cfgValue('sort_applied') === true) {
return;
}
try {
$userConf = FreshRSS_Context::userConf();
$userConf->_attribute('sort', self::DEFAULT_SORT);
$userConf->_attribute('sort_order', self::DEFAULT_SORT_ORDER);
$userConf->save();
// Mark as applied so we don't override the user's future changes
$config = $this->cfgAll();
$config['sort_applied'] = true;
$this->cfgSave($config);
} catch (\Throwable $e) {
Minz_Log::warning('GridView: Failed to set default sort order: ' . $e->getMessage());
}
}
/**
* Inject JavaScript variables for grid configuration
* @param array<string, mixed> $vars
* @return array<string, mixed>
*/
public function injectJsVars(array $vars): array {
$columnsValue = $this->cfgValue('columns');
$columns = is_numeric($columnsValue) ? (int) $columnsValue : self::DEFAULT_COLUMNS;
if ($columns < self::MIN_COLUMNS || $columns > self::MAX_COLUMNS) {
$columns = self::DEFAULT_COLUMNS;
}
$vars['gridview'] = [
'columns' => $columns,
'placeholderUrl' => $this->getFileUrl('placeholder.jpg'),
'ogFetchUrl' => './?c=extension&a=configure&e=' . urlencode($this->getName()),
'showMobileMenuButton' => $this->isMobileMenuButtonEnabled(),
'stickyNavEnabled' => $this->isStickyNavEnabled(),
'shareMenuHtml' => $this->buildShareMenuHtml(),
];
return $vars;
}
/**
* Build the share menu HTML template with placeholders.
* Reader view (article.phtml) doesn't render entry_share_menu, so
* we generate it here and pass it to JS for use in grid cards.
* Placeholders: --entryId--, --link--, --titleText--
* @return string
*/
private function buildShareMenuHtml(): string {
if (!FreshRSS_Auth::hasAccess()) {
return '';
}
try {
$sharing = FreshRSS_Context::userConf()->sharing;
if (empty($sharing) || !is_array($sharing)) {
return '';
}
} catch (\Throwable $e) {
return '';
}
$html = '<ul class="dropdown-menu">';
$html .= '<li class="dropdown-header">' . _t('index.share') . '</li>';
foreach ($sharing as $shareOptions) {
$share = FreshRSS_Share::get($shareOptions['type'] ?? '');
if ($share === null) {
continue;
}
$shareOptions['id'] = '--entryId--';
$shareOptions['link'] = '--link--';
$shareOptions['title'] = '--titleText--';
$share->update($shareOptions);
if ('GET' === $share->method()) {
$html .= '<li class="item share">';
$html .= '<a target="_blank" rel="noreferrer" href="'
. htmlspecialchars($share->url(), ENT_QUOTES, 'UTF-8')
. '" data-type="' . htmlspecialchars($share->type(), ENT_QUOTES, 'UTF-8')
. '">' . htmlspecialchars($share->name() ?? '', ENT_QUOTES, 'UTF-8') . '</a>';
$html .= '</li>';
} else {
$html .= '<li class="item share">';
$html .= '<a href="POST">' . htmlspecialchars($share->name() ?? '', ENT_QUOTES, 'UTF-8') . '</a>';
$html .= '<form method="POST" action="'
. htmlspecialchars($share->url(), ENT_QUOTES, 'UTF-8')
. '" disabled="disabled"><input type="hidden" value="--link--" name="'
. htmlspecialchars($share->field() ?? '', ENT_QUOTES, 'UTF-8') . '"/></form>';
$html .= '</li>';
}
}
$html .= '</ul>';
return $html;
}
/**
* Handle configuration form submission
*/
#[\Override]
public function handleConfigureAction(): void {
// Handle AJAX OG image fetch request (returns JSON and exits)
if (Minz_Request::paramString('ajax') === 'og') {
$this->ajaxFetchOgImage();
return;
}
parent::init();
$this->registerTranslates();
if (Minz_Request::isPost()) {
$columns = Minz_Request::paramInt('columns');
// Validate column count
if ($columns < self::MIN_COLUMNS) {
$columns = self::MIN_COLUMNS;
} elseif ($columns > self::MAX_COLUMNS) {
$columns = self::MAX_COLUMNS;
}
$fetchOgImage = Minz_Request::paramBoolean('fetch_og_image');
$sortByDate = Minz_Request::paramBoolean('sort_by_date');
$mobileMenuButton = Minz_Request::paramBoolean('mobile_menu_button');
$stickyNav = Minz_Request::paramBoolean('sticky_nav');
// Reset sort_applied flag when sort_by_date is toggled so it
// re-applies (or stops applying) on the next page load
$sortApplied = $this->cfgValue('sort_applied') === true;
// setUserConfiguration expects an array of all config values
$this->cfgSave([
'columns' => $columns,
'fetch_og_image' => $fetchOgImage,
'sort_by_date' => $sortByDate,
'sort_applied' => $sortByDate ? $sortApplied : false,
'mobile_menu_button' => $mobileMenuButton,
'sticky_nav' => $stickyNav,
]);
}
}
/**
* Get current column configuration
* @return int
*/
public function getColumns(): int {
$value = $this->cfgValue('columns');
$columns = is_numeric($value) ? (int) $value : self::DEFAULT_COLUMNS;
if ($columns < self::MIN_COLUMNS || $columns > self::MAX_COLUMNS) {
return self::DEFAULT_COLUMNS;
}
return $columns;
}
/**
* Whether sort by publication date is enabled in user configuration.
* @return bool
*/
public function isSortByPublicationDateEnabled(): bool {
$value = $this->cfgValue('sort_by_date');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether the mobile menu button is enabled in user configuration.
* @return bool
*/
public function isMobileMenuButtonEnabled(): bool {
$value = $this->cfgValue('mobile_menu_button');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether the sticky navigation bar is enabled in user configuration.
* @return bool
*/
public function isStickyNavEnabled(): bool {
$value = $this->cfgValue('sticky_nav');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether OG image fetching is enabled in user configuration.
* @return bool
*/
public function isOgImageFetchEnabled(): bool {
// Default to false (opt-in) so existing users aren't surprised by slower page loads
$value = $this->cfgValue('fetch_og_image');
return $value === true || $value === 1 || $value === '1';
}
/**
* Inject a hidden thumbnail marker into the entry content.
* Reading View doesn't render the .item.thumbnail element, so the
* JS grid code can't find the thumbnail. This adds a hidden span
* with the URL so getThumbnail() can pick it up in any view.
*/
public function injectThumbnailMarker(FreshRSS_Entry $entry): FreshRSS_Entry {
$thumbnail = $entry->thumbnail(true);
if (empty($thumbnail['url'])) {
return $entry;
}
$url = htmlspecialchars($thumbnail['url'], ENT_QUOTES, 'UTF-8');
$marker = '<span class="gridview-thumbnail-marker" data-thumbnail="' . $url . '" style="display:none"></span>';
$entry->_content($marker . $entry->content(false));
return $entry;
}
/**
* Fetch Open Graph image for entries that have no thumbnail.
* Called when a new entry is being inserted into the database.
*
* @throws Minz_PermissionDeniedException
*/
public function fetchOgImage(FreshRSS_Entry $entry): FreshRSS_Entry {
return $this->ensureThumbnail($entry);
}
/**
* Mark entries that have no thumbnail so the JS can fetch OG images
* asynchronously. This replaces the synchronous fetchOgImageOnDisplay
* to avoid blocking page rendering.
*/
public function markEntryForOgFetch(FreshRSS_Entry $entry): FreshRSS_Entry {
$thumbnail = $entry->thumbnail(true);
if (!empty($thumbnail['url']) || $entry->attributeBoolean('og_image_checked')) {
return $entry;
}
// Don't mark if the content already has images (the JS card
// builder will extract them directly)
if (preg_match('/<img\s[^>]*src\s*=\s*["\']https?:\/\//i', $entry->content(false))) {
return $entry;
}
$link = $entry->link();
if (empty($link) || !str_starts_with($link, 'http')) {
return $entry;
}
$escapedLink = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
$escapedId = htmlspecialchars($entry->id(), ENT_QUOTES, 'UTF-8');
$marker = '<span class="gridview-og-fetch" '
. 'data-og-url="' . $escapedLink . '" '
. 'data-og-entry-id="' . $escapedId . '" '
. 'style="display:none"></span>';
$entry->_content($entry->content(false) . $marker);
return $entry;
}
/**
* AJAX endpoint: fetch the OG image for a given URL and return JSON.
* Called via ?c=extension&a=configure&e=GridView&ajax=og&url=...
*/
private function ajaxFetchOgImage(): void {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, max-age=86400');
if (!FreshRSS_Auth::hasAccess()) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$url = Minz_Request::paramString('url');
if (empty($url) || (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://'))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid URL']);
exit;
}
$ogImage = $this->extractOgImage($url);
echo json_encode(['image' => $ogImage]);
exit;
}
/**
* Ensure the entry has a thumbnail; if not, try to fetch the OG image.
*
* @param FreshRSS_Entry $entry
* @param bool $persist Whether to persist changes to the database (for display-time fetches)
* @return FreshRSS_Entry
* @throws Minz_PermissionDeniedException
*/
private function ensureThumbnail(FreshRSS_Entry $entry, bool $persist = false): FreshRSS_Entry {
// Skip if entry already has a thumbnail
$thumbnail = $entry->thumbnail(true);
if (!empty($thumbnail['url'])) {
return $entry;
}
// Don't re-attempt if we already checked this entry
if ($entry->attributeBoolean('og_image_checked')) {
return $entry;
}
// Skip if content already has images
if (preg_match('/<img\s[^>]*src\s*=\s*["\']https?:\/\//i', $entry->content(false))) {
return $entry;
}
$link = htmlspecialchars_decode($entry->link(), ENT_QUOTES);
if (empty($link) || !str_starts_with($link, 'http')) {
return $entry;
}
$ogImage = $this->extractOgImage($link);
if ($ogImage !== null) {
$entry->_attribute('thumbnail', ['url' => $ogImage]);
} else {
$entry->_attribute('og_image_checked', true);
}
// Persist to database when called at display time so the image
// is available on subsequent page loads without re-fetching
if ($persist) {
try {
$entryDAO = FreshRSS_Factory::createEntryDao();
$entryDAO->updateEntry($entry->toArray());
} catch (\Throwable $e) {
Minz_Log::warning('GridView: Failed to persist OG image: ' . $e->getMessage());
}
}
return $entry;
}
/**
* Fetch a URL and extract the og:image meta tag.
*
* @param string $url The article URL to fetch
* @return string|null The og:image URL or null if not found
*/
private function extractOgImage(string $url): ?string {
$ctx = stream_context_create([
'http' => [
'timeout' => 5,
'method' => 'GET',
'header' => "User-Agent: FreshRSS/GridView\r\n",
'follow_location' => 1,
'max_redirects' => 3,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
// Only fetch the first 50 KB — og:image is in <head>
$html = @file_get_contents($url, false, $ctx, 0, 50000);
if ($html === false || $html === '') {
return null;
}
// Try og:image first, then twitter:image (both attribute orderings)
if (preg_match('/<meta\s[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $url);
}
if (preg_match('/<meta\s[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $url);
}
if (preg_match('/<meta\s[^>]*name=["\']twitter:image["\'][^>]*content=["\']([^"\']+)["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $url);
}
if (preg_match('/<meta\s[^>]*content=["\']([^"\']+)["\'][^>]*name=["\']twitter:image["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $url);
}
return null;
}
/**
* Normalize a potentially relative image URL to absolute.
*
* @param string $imageUrl The image URL (may be relative)
* @param string $pageUrl The page URL for resolving relative paths
* @return string|null Absolute URL or null if invalid
*/
private function normalizeImageUrl(string $imageUrl, string $pageUrl): ?string {
$imageUrl = html_entity_decode($imageUrl, ENT_QUOTES, 'UTF-8');
$imageUrl = trim($imageUrl);
if (empty($imageUrl)) {
return null;
}
// Already absolute
if (str_starts_with($imageUrl, 'http://') || str_starts_with($imageUrl, 'https://')) {
return $imageUrl;
}
// Protocol-relative
if (str_starts_with($imageUrl, '//')) {
$scheme = parse_url($pageUrl, PHP_URL_SCHEME) ?: 'https';
return $scheme . ':' . $imageUrl;
}
// Relative — resolve against page URL
$parsed = parse_url($pageUrl);
if ($parsed === false || empty($parsed['host'])) {
return null;
}
$base = ($parsed['scheme'] ?? 'https') . '://' . $parsed['host'];
if (str_starts_with($imageUrl, '/')) {
return $base . $imageUrl;
}
$path = $parsed['path'] ?? '/';
$dir = substr($path, 0, (int)strrpos($path, '/') + 1);
return $base . $dir . $imageUrl;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
return array(
'gridview' => array(
'view_mode_name' => 'Rasteransicht',
'config' => array(
'columns' => 'Anzahl der Spalten',
'columns_label' => 'Spalten',
'columns_help' => 'Wählen Sie aus, wie viele Spalten in der Rasteransicht angezeigt werden sollen (2-4). Auf kleineren Bildschirmen passt sich dies automatisch an.',
'fetch_og_image' => 'Vorschaubilder abrufen',
'fetch_og_image_label' => 'Vorschaubilder von Artikelseiten abrufen',
'fetch_og_image_help' => 'Wenn aktiviert, ruft die Erweiterung Open-Graph-Bilder von Artikelseiten ab, die kein Vorschaubild im RSS-Feed haben. Dies verbessert die Kartenbilder, kann aber das Laden der Seite verlangsamen.',
'sort_by_date' => 'Standard-Sortierung',
'sort_by_date_label' => 'Nach Veröffentlichungsdatum sortieren (neueste zuerst)',
'sort_by_date_help' => 'Wenn aktiviert, wird die Standard-Sortierung auf Veröffentlichungsdatum (neueste zuerst) gesetzt. Dies wird einmalig angewendet; Sie können die Sortierung in FreshRSS weiterhin manuell ändern.',
'mobile_menu_button' => 'Seitenleisten-Umschalttaste',
'mobile_menu_button_label' => 'Schwebende Seitenleisten-Umschalttaste anzeigen',
'mobile_menu_button_help' => 'Wenn aktiviert, erscheint unten links auf dem Bildschirm eine schwebende Hamburger-Taste, um die FreshRSS-Seitenleiste zu öffnen.',
'sticky_nav' => 'Feste Navigationsleiste',
'sticky_nav_label' => 'Navigationsleiste beim Scrollen sichtbar halten',
'sticky_nav_help' => 'Wenn aktiviert, bleibt die Navigationsleiste (mit gelesen/ungelesen, Favoriten usw.) beim Scrollen durch Artikel im Rastermodus oben fixiert.',
'usage_title' => 'Verwendung',
'usage_info' => 'Nach dem Speichern Ihrer Einstellungen klicken Sie auf das Rastersymbol (▦) in der Kopfzeile oder drücken Sie „G" auf Ihrer Tastatur, um die Rasteransicht umzuschalten. Klicken Sie auf eine Karte, um den Artikel in einem neuen Tab zu öffnen.',
),
),
);
+26
View File
@@ -0,0 +1,26 @@
<?php
return array(
'gridview' => array(
'view_mode_name' => 'Grid View',
'config' => array(
'columns' => 'Number of columns',
'columns_label' => 'columns',
'columns_help' => 'Choose how many columns to display in the grid view (2-4). On smaller screens, this will automatically adjust.',
'fetch_og_image' => 'Thumbnail fetching',
'fetch_og_image_label' => 'Fetch thumbnails from article pages',
'fetch_og_image_help' => 'When enabled, the extension will fetch Open Graph images from article pages that have no thumbnail in the RSS feed. This provides better card images but may slow down page loading.',
'sort_by_date' => 'Default sorting',
'sort_by_date_label' => 'Sort by publication date (newest first)',
'sort_by_date_help' => 'When enabled, the default sort order is set to publication date, newest first. This is applied once; you can still change the sort order manually in FreshRSS.',
'mobile_menu_button' => 'Sidebar toggle button',
'mobile_menu_button_label' => 'Show a floating sidebar toggle button',
'mobile_menu_button_help' => 'When enabled, a floating hamburger button appears at the bottom-left of the screen to open the FreshRSS sidebar.',
'sticky_nav' => 'Sticky navigation bar',
'sticky_nav_label' => 'Keep the top navigation bar visible while scrolling',
'sticky_nav_help' => 'When enabled, the navigation bar (with read/unread, favourites, etc.) stays fixed at the top while scrolling through articles in grid mode.',
'usage_title' => 'How to use',
'usage_info' => 'After saving your settings, click the grid icon (▦) in the header or press "G" on your keyboard to toggle grid view. Click on any card to open the article in a new tab.',
),
),
);
+26
View File
@@ -0,0 +1,26 @@
<?php
return array(
'gridview' => array(
'view_mode_name' => 'Vue Grille',
'config' => array(
'columns' => 'Nombre de colonnes',
'columns_label' => 'colonnes',
'columns_help' => 'Choisissez le nombre de colonnes à afficher dans la vue grille (2-4). Sur les petits écrans, cela s\'ajustera automatiquement.',
'fetch_og_image' => 'Récupération des miniatures',
'fetch_og_image_label' => 'Récupérer les miniatures depuis les pages des articles',
'fetch_og_image_help' => 'Lorsque cette option est activée, l\'extension récupère les images Open Graph des pages d\'articles qui n\'ont pas de miniature dans le flux RSS. Cela améliore les images des cartes mais peut ralentir le chargement des pages.',
'sort_by_date' => 'Tri par défaut',
'sort_by_date_label' => 'Trier par date de publication (plus récent en premier)',
'sort_by_date_help' => 'Lorsque cette option est activée, l\'ordre de tri par défaut est défini sur la date de publication, du plus récent au plus ancien. Ceci est appliqué une seule fois ; vous pouvez toujours modifier l\'ordre de tri manuellement dans FreshRSS.',
'mobile_menu_button' => 'Bouton du panneau latéral',
'mobile_menu_button_label' => 'Afficher un bouton flottant pour le panneau latéral',
'mobile_menu_button_help' => 'Lorsque cette option est activée, un bouton hamburger flottant apparaît en bas à gauche de l\'écran pour ouvrir le panneau latéral de FreshRSS.',
'sticky_nav' => 'Barre de navigation fixe',
'sticky_nav_label' => 'Garder la barre de navigation visible lors du défilement',
'sticky_nav_help' => 'Lorsque cette option est activée, la barre de navigation (avec lu/non lu, favoris, etc.) reste fixée en haut lors du défilement des articles en mode grille.',
'usage_title' => 'Comment utiliser',
'usage_info' => 'Après avoir enregistré vos paramètres, cliquez sur l\'icône grille (▦) dans l\'en-tête ou appuyez sur « G » sur votre clavier pour basculer la vue grille. Cliquez sur n\'importe quelle carte pour ouvrir l\'article dans un nouvel onglet.',
),
),
);
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Grid View",
"author": "Marius Stroe <https://github.com/mstroe>",
"description": "A grid/card view for displaying feeds with images, configurable 2-4 columns, FreshRSS native share integration, and browser reader mode support",
"version": "1.1.0",
"entrypoint": "GridView",
"type": "user",
"compatibility": "1.20.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+781
View File
@@ -0,0 +1,781 @@
/**
* Grid View Extension - CSS Styles
* Card layout for FreshRSS
*/
/* ============================================
Grid Toggle & Refresh Buttons
============================================ */
.read_grid,
[data-grid-toggle] {
padding: 0.25rem 0.5rem;
display: inline-flex;
font-size: 1.25rem;
border: 1px solid var(--frss-border-color, #ddd);
border-radius: 4px;
align-items: center;
justify-content: center;
margin-left: 0.5rem;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
text-decoration: none;
}
.read_grid:hover,
[data-grid-toggle]:hover {
background-color: var(--frss-background-alt, #f0f0f0);
}
.read_grid.active,
[data-grid-toggle].active {
background-color: var(--frss-accent-color, #4a90d9) !important;
color: #fff !important;
border-color: var(--frss-accent-color, #4a90d9);
}
/* ============================================
Grid Container - Applied when view mode is 'grid'
============================================ */
#stream.grid {
padding: 1.25rem;
display: grid;
max-width: 100%;
grid-template-columns: repeat(var(--gridview-columns, 3), 1fr);
gap: 1.25rem;
box-sizing: border-box;
}
/* ============================================
Date Separators / Day Dividers
============================================ */
#stream.grid > .gridview-header {
margin: 0 0 0.25rem 0;
padding: 0.75rem 0;
display: flex;
width: 100%;
color: var(--frss-text-color, #333);
font-size: 1.25rem;
border-bottom: 2px solid var(--frss-accent-color, #4a90d9);
grid-column: 1 / -1;
font-weight: 700;
align-items: baseline;
gap: 0.75rem;
}
.gridview-header-name {
flex-shrink: 0;
}
.gridview-header-new-articles {
display: none;
color: var(--frss-accent-color, #4a90d9);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.gridview-header-new-articles.visible {
display: inline;
}
.gridview-header-new-articles:hover {
text-decoration: underline;
}
/* Hide the native FreshRSS new-article banner in grid mode */
#stream.grid > #new-article,
body.gridview-active #new-article {
display: none !important;
}
#stream.grid > .transition {
display: none;
}
#stream.grid .day,
#stream.grid .date-separator,
#stream.grid .flux_date,
#stream.grid > .day-title,
#stream.grid > h2,
#stream.grid > h3 {
margin: 0.5rem 0;
padding: 0.75rem 0;
background: transparent;
display: flex;
width: 100%;
color: var(--frss-text-muted, #666);
font-size: 0.875rem;
border-bottom: 1px solid var(--frss-border-color, #e0e0e0);
grid-column: 1 / -1; /* Span full width */
font-weight: 600;
align-items: center;
}
#stream.grid .day::before,
#stream.grid .date-separator::before {
content: "📅 ";
margin-right: 0.5rem;
}
/* ============================================
Card Styling
============================================ */
#stream.grid .flux {
margin: 0;
/* Reset padding/margin that FreshRSS Reader view may add to .flux */
padding: 0;
background: var(--frss-background-color, #fff);
display: flex;
height: 100%;
border: 1px solid var(--frss-border-color, #e0e0e0);
border-radius: 12px;
flex-direction: column;
overflow: hidden;
isolation: isolate;
transition: box-shadow 0.2s ease, border 0.2s ease;
cursor: pointer;
min-height: 482px;
}
#stream.grid .flux:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Override FreshRSS default .not_read border in grid mode.
The actual unread indicator is managed via inline styles
by syncFluxVisualState() in grid.js. */
#stream.grid .flux.not_read {
border-left: 1px solid var(--frss-border-color, #e0e0e0);
}
/* Override .current styling in grid mode. FreshRSS sets .current on the
most recently interacted article; the box-shadow looked like an unwanted
full blue border when switching from list view to grid view. */
#stream.grid .flux.current {
box-shadow: none;
}
#stream.grid .flux.current:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* ============================================
Card Image / Thumbnail
============================================ */
#stream.grid .flux .flux_header {
margin: 0;
padding: 0;
background: none;
display: flex;
border: none;
flex-direction: column;
position: relative;
}
#stream.grid .flux .card-thumbnail {
background: var(--frss-background-alt, #f5f5f5);
display: flex;
width: 100%;
height: 394px;
border-radius: inherit;
overflow: hidden;
align-items: center;
justify-content: center;
cursor: pointer;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
will-change: transform;
}
#stream.grid .flux .card-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
#stream.grid .flux:hover .card-thumbnail img {
transform: scale(1.05);
}
#stream.grid .flux .card-thumbnail .no-image {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
width: 100%;
height: 100%;
color: #fff;
font-size: 3rem;
align-items: center;
justify-content: center;
opacity: 0.7;
}
#stream.grid .flux .card-thumbnail .placeholder-image {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.85) saturate(0.9);
}
#stream.grid .flux:hover .card-thumbnail .placeholder-image {
filter: brightness(0.9) saturate(1);
}
/* ============================================
Card Content Area
============================================ */
#stream.grid .flux .card-content {
padding: 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
}
#stream.grid .flux .card-meta {
display: flex;
color: var(--frss-text-muted, #666);
font-size: 0.75rem;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
#stream.grid .flux .card-meta .favicon {
width: 16px;
height: 16px;
border-radius: 2px;
}
#stream.grid .flux .card-meta .feed-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
#stream.grid .flux .card-meta .reading-time {
margin-left: auto;
white-space: nowrap;
}
#stream.grid .flux .card-title {
margin: 0 0 0.5rem 0;
color: var(--frss-text-color, #333);
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
overflow-wrap: break-word;
}
#stream.grid .flux .card-title a {
color: inherit;
text-decoration: none;
}
#stream.grid .flux .card-title a:hover {
color: var(--frss-accent-color, #4a90d9);
}
#stream.grid .flux .card-summary-wrapper {
position: relative;
flex-grow: 1;
margin: 0;
cursor: pointer;
}
#stream.grid .flux .card-summary {
margin: 0;
color: var(--frss-text-muted, #666);
font-size: 0.875rem;
line-height: 1.5;
overflow: hidden;
}
/* Scrollable tooltip panel shown on hover — fills .card-content area */
#stream.grid .flux .card-summary-tooltip {
padding: 0.75rem 1rem;
background: var(--frss-background-color, #fff);
display: none;
border-radius: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
}
#stream.grid .flux .card-summary-tooltip p {
margin: 0;
color: var(--frss-text-color, #333);
font-size: 0.875rem;
line-height: 1.5;
padding-bottom: 1rem;
}
#stream.grid .flux .card-summary-tooltip.expanded {
display: block;
}
#stream.grid .flux .card-date {
color: var(--frss-text-muted, #888);
font-size: 0.75rem;
margin-top: auto;
padding-top: 0.75rem;
}
/* ============================================
Hide default elements in grid view
============================================ */
#stream.grid .flux .flux_content,
#stream.grid .flux .item.manage,
#stream.grid .flux .item.link,
#stream.grid .flux > .flux_header > .horizontal-list {
display: none !important;
}
/* Hide the FreshRSS prev/up/next arrow buttons in grid mode */
body.gridview-active #nav_entries {
display: none !important;
}
/* Ensure grid card wrapper fills the flux without extra spacing
(Reader view may apply padding/margin to .flux children) */
#stream.grid .flux .gridview-card-content {
display: flex;
flex-direction: column;
flex-grow: 1;
}
/* ============================================
Card Actions (on hover)
============================================ */
#stream.grid .flux .card-actions {
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.55);
display: flex;
border-top: none;
gap: 0.5rem;
position: absolute;
bottom: 0;
left: 0;
right: 0;
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}
#stream.grid .flux.card-selected .card-actions {
opacity: 1;
}
#stream.grid .flux .card-actions button,
#stream.grid .flux .card-actions a {
padding: 0.25rem 0.5rem;
background: none;
color: rgba(255, 255, 255, 0.9);
font-size: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
#stream.grid .flux .card-actions button:hover,
#stream.grid .flux .card-actions a:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
#stream.grid .flux.favorite .card-actions .action-star {
color: #f5a623;
}
/* ============================================
Share Button in Cards
============================================ */
#stream.grid .flux .card-actions .action-share {
padding: 0.25rem 0.5rem;
background: none;
display: inline-flex;
color: rgba(255, 255, 255, 0.9);
font-size: 0.85rem;
border: none;
border-radius: 4px;
align-items: center;
justify-content: center;
cursor: pointer;
}
#stream.grid .flux .card-actions .action-share:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
#stream.grid .flux .card-actions .action-share .icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
#stream.grid .flux .card-actions .action-share:hover .icon {
opacity: 1;
filter: brightness(0) invert(1);
}
/* ============================================
Hidden Original Content (for FreshRSS functionality)
============================================ */
#stream.grid .flux .gridview-original-content {
display: none !important;
}
/* But show the share dropdown when it's activated via :target */
#stream.grid .flux .gridview-original-content .dropdown-menu,
#stream.grid .flux .gridview-original-content .item.share .dropdown-menu {
display: none;
}
/* When dropdown is targeted, show it positioned relative to the card */
#stream.grid .flux .gridview-original-content .dropdown-target:target ~ .dropdown-menu {
padding: 0.5rem 0;
background: var(--frss-background-color, #fff);
display: block !important;
border: 1px solid var(--frss-border-color, #ddd);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: fixed;
z-index: 9999;
min-width: 180px;
}
/* ============================================
Prevent Flash of List View (FOLV)
Hide untransformed flux entries in grid mode
so the default list layout never shows.
============================================ */
#stream.grid .flux:not(.gridview-transformed) {
margin: 0;
padding: 0;
height: 0;
border: none;
visibility: hidden;
min-height: 0;
overflow: hidden;
}
/* When grid mode is active (body flag), hide the raw stream content
even before JS adds .grid to a newly replaced #stream element.
This prevents the brief flash of list view during AJAX navigation. */
body.gridview-active #stream:not(.grid) {
visibility: hidden;
min-height: 200px;
}
/* ============================================
Loading Skeleton / Spinner
Shown while grid items are being transformed
============================================ */
.gridview-loading-overlay {
padding: 0;
display: flex;
width: 100%;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 1.25rem;
}
.gridview-skeleton-card {
background: var(--frss-background-color, #fff);
max-width: calc(100% / var(--gridview-columns, 3) - 1.25rem + 1.25rem / var(--gridview-columns, 3));
border: 1px solid var(--frss-border-color, #e0e0e0);
border-radius: 12px;
flex: 1 1 calc(100% / var(--gridview-columns, 3) - 1.25rem);
min-width: 200px;
overflow: hidden;
min-height: 482px;
}
.gridview-skeleton-card .skeleton-thumbnail {
background: linear-gradient(90deg,
var(--frss-background-alt, #f0f0f0) 25%,
var(--frss-border-color, #e8e8e8) 50%,
var(--frss-background-alt, #f0f0f0) 75%);
width: 100%;
height: 394px;
background-size: 200% 100%;
animation: gridview-shimmer 1.5s infinite;
}
.gridview-skeleton-card .skeleton-content {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.gridview-skeleton-card .skeleton-line {
background: linear-gradient(90deg,
var(--frss-background-alt, #f0f0f0) 25%,
var(--frss-border-color, #e8e8e8) 50%,
var(--frss-background-alt, #f0f0f0) 75%);
height: 0.875rem;
border-radius: 4px;
background-size: 200% 100%;
animation: gridview-shimmer 1.5s infinite;
}
.gridview-skeleton-card .skeleton-line.short {
width: 40%;
}
.gridview-skeleton-card .skeleton-line.medium {
width: 75%;
}
.gridview-skeleton-card .skeleton-line.long {
width: 100%;
}
@keyframes gridview-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ============================================
Toast Notification
============================================ */
@keyframes gridview-toast-fade {
0% { opacity: 0; transform: translateX(-50%) translateY(10px); }
10% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
/* ============================================
Mobile Bottom Bar (Menu Button)
============================================ */
.gridview-mobile-bar {
padding: 0.5rem;
display: none;
position: fixed;
bottom: 0;
left: 0;
z-index: 1000;
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
}
body.gridview-active .gridview-mobile-bar {
display: block;
}
.gridview-mobile-menu-btn {
background: var(--frss-background-color, #fff);
display: flex;
width: 2.75rem;
height: 2.75rem;
color: var(--frss-text-color, #333);
border: 1px solid var(--frss-border-color, #ddd);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.gridview-mobile-menu-btn:active {
background: var(--frss-background-alt, #f0f0f0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* ============================================
Sticky Nav Bar in Grid Mode
Keep the FreshRSS toolbar visible while scrolling
in grid mode (Normal and Reader views).
FreshRSS uses display:table on #global with .aside as
a table-cell. position:sticky cannot work inside a table
layout, so we:
1. Switch #global from table to flex layout
2. Wrap .nav_menu + #stream in a scroll container via JS
(see grid.js: wrapContentForStickyNav)
3. Make .nav_menu sticky inside that scroll container
============================================ */
body.gridview-sticky-nav #global {
display: flex;
flex-direction: row;
}
body.gridview-sticky-nav #global > .aside {
display: block;
flex-shrink: 0;
}
body.gridview-sticky-nav .gridview-scroll-wrapper {
flex: 1 1 0%;
min-width: 0;
overflow-y: auto;
}
body.gridview-sticky-nav .nav_menu {
position: sticky;
top: 0;
z-index: 50;
background: var(--frss-background-color, #fff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
body.gridview-sticky-nav .nav_menu {
background: var(--frss-background-color, #1e1e1e);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
.theme-dark body.gridview-sticky-nav .nav_menu,
body.theme-dark.gridview-sticky-nav .nav_menu,
body.gridview-sticky-nav .theme-dark .nav_menu,
[data-theme="dark"] body.gridview-sticky-nav .nav_menu,
body.gridview-sticky-nav[data-theme="dark"] .nav_menu {
background: var(--frss-background-color, #1e1e1e);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* ============================================
Responsive Breakpoints
============================================ */
@media (max-width: 1200px) {
#stream.grid {
grid-template-columns: repeat(min(var(--gridview-columns, 3), 3), 1fr);
}
}
@media (max-width: 900px) {
#stream.grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding: 1rem;
}
}
@media (max-width: 600px) {
#stream.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem;
}
#stream.grid .flux {
min-height: 419px;
}
#stream.grid .flux .card-thumbnail {
height: 328px;
}
}
/* ============================================
Dark Theme Support
============================================ */
@media (prefers-color-scheme: dark) {
#stream.grid .flux {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
}
#stream.grid .flux:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
#stream.grid .flux .card-thumbnail .no-image {
background: linear-gradient(135deg, #434343 0%, #000 100%);
}
#stream.grid .flux .card-summary-tooltip {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
}
/* Support FreshRSS dark themes */
.theme-dark #stream.grid .flux,
[data-theme="dark"] #stream.grid .flux {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
}
.theme-dark #stream.grid .flux:hover,
[data-theme="dark"] #stream.grid .flux:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.theme-dark #stream.grid .flux .card-summary-tooltip,
[data-theme="dark"] #stream.grid .flux .card-summary-tooltip {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
@media (prefers-color-scheme: dark) {
.gridview-mobile-menu-btn {
background: var(--frss-background-color, #1e1e1e);
color: var(--frss-text-color, #e0e0e0);
border-color: var(--frss-border-color, #333);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
}
@media (prefers-color-scheme: dark) {
.gridview-skeleton-card {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
}
}
.theme-dark .gridview-mobile-menu-btn,
[data-theme="dark"] .gridview-mobile-menu-btn {
background: var(--frss-background-color, #1e1e1e);
color: var(--frss-text-color, #e0e0e0);
border-color: var(--frss-border-color, #333);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.theme-dark .gridview-skeleton-card,
[data-theme="dark"] .gridview-skeleton-card {
background: var(--frss-background-color, #1e1e1e);
border-color: var(--frss-border-color, #333);
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB