feat: search, filter and act on items from the library pages

This commit is contained in:
randogoth 2026-10-11 13:25:57 +03:00
parent 1b426ccd8b
commit 5cd053c8a9
11 changed files with 401 additions and 43 deletions

View file

@ -1,3 +1,5 @@
import '../database/database.dart';
import 'controllers/bookmarks_controller.dart';
import 'controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'controllers/subscriptions_controller.dart';
@ -12,12 +14,18 @@ import 'pages/mews_urls.dart';
/// miss is a bug rather than something to explain to the reader.
class InternalActions {
InternalActions({
required MewsDatabase db,
required ReaderSettingsController settings,
required BookmarksController bookmarks,
required SubscriptionsController subscriptions,
}) : _settings = settings,
}) : _db = db,
_settings = settings,
_bookmarks = bookmarks,
_subscriptions = subscriptions;
final MewsDatabase _db;
final ReaderSettingsController _settings;
final BookmarksController _bookmarks;
final SubscriptionsController _subscriptions;
Future<bool> apply(String url) async {
@ -33,6 +41,10 @@ class InternalActions {
'mark-unread' => _markRead(args, read: false),
'refresh-subscriptions' => _refreshAll(),
'refresh-subscription' => _refreshOne(args['url']),
'remove-bookmark' => _withUrl(args, _bookmarks.remove),
'unsubscribe' => _withUrl(args, _subscriptions.remove),
'forget-page' => _withUrl(args, _db.forgetPage),
'clear-history' => _clearHistory(),
_ => false,
};
}
@ -43,6 +55,20 @@ class InternalActions {
_ => Future.value(false),
};
/// Runs [change] on the address the action names, if it names one.
Future<bool> _withUrl(
Map<String, String> args, Future<void> Function(String) change) async {
final url = args['url'];
if (url == null || url.isEmpty) return false;
await change(url);
return true;
}
Future<bool> _clearHistory() async {
await _db.clearHistory();
return true;
}
Future<bool> _markRead(Map<String, String> args, {required bool read}) async {
final url = args['url'];
if (url == null || url.isEmpty) return false;

View file

@ -1,6 +1,7 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
Future<MewsPage> buildBookmarksPage(
@ -8,7 +9,8 @@ Future<MewsPage> buildBookmarksPage(
required MewsDatabase db,
required Strings strings,
}) async {
final bookmarks = await db.bookmarksByDate();
final query = url.queryParameters['q'] ?? '';
final bookmarks = await db.bookmarksByDate(query: query);
final title = strings.t('bookmarks-title');
return internalPage(
@ -16,8 +18,22 @@ Future<MewsPage> buildBookmarksPage(
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
searchForm(
action: MewsUrls.bookmarks,
label: strings.t('bookmarks-search-label'),
submitLabel: strings.t('action-search'),
query: query,
),
if (query.isNotEmpty)
ParagraphBlock([
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
]),
if (bookmarks.isEmpty)
ParagraphBlock([MewsText(strings.t('bookmarks-empty'))])
ParagraphBlock([
MewsText(strings.t(
query.isEmpty ? 'bookmarks-empty' : 'bookmarks-no-matches')),
])
else
itemList([
for (final bookmark in bookmarks)
@ -25,6 +41,11 @@ Future<MewsPage> buildBookmarksPage(
href: bookmark.url,
label: bookmark.title ?? bookmark.url,
date: dayText(bookmark.createdAt),
actions: [
action(
MewsUrls.action('remove-bookmark', {'url': bookmark.url}),
strings.t('action-remove-bookmark')),
],
),
]),
],

View file

@ -1,6 +1,7 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
Future<MewsPage> buildHistoryPage(
@ -8,7 +9,9 @@ Future<MewsPage> buildHistoryPage(
required MewsDatabase db,
required Strings strings,
}) async {
final visits = await db.recentVisits();
final query = url.queryParameters['q'] ?? '';
final confirming = url.queryParameters['confirm'] == 'clear';
final pages = await db.recentPages(query: query);
final title = strings.t('history-title');
return internalPage(
@ -16,15 +19,49 @@ Future<MewsPage> buildHistoryPage(
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
if (visits.isEmpty)
ParagraphBlock([MewsText(strings.t('history-empty'))])
searchForm(
action: MewsUrls.history,
label: strings.t('history-search-label'),
submitLabel: strings.t('action-search'),
query: query,
),
// Clearing history cannot be undone, so it takes a second step rather
// than a dialog: another page the reader can simply leave.
if (confirming) ...[
ParagraphBlock([MewsText(strings.t('history-clear-confirm'))]),
ParagraphBlock([
MewsLink(MewsUrls.action('clear-history'),
[MewsText(strings.t('history-clear-yes'))]),
const MewsText(actionSeparator),
MewsLink(MewsUrls.history, [MewsText(strings.t('history-clear-no'))]),
]),
] else
ParagraphBlock([
if (query.isNotEmpty) ...[
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
const MewsText(actionSeparator),
],
if (pages.isNotEmpty)
MewsLink(MewsUrls.page(url, {'confirm': 'clear'}),
[MewsText(strings.t('history-clear'))]),
]),
if (pages.isEmpty)
ParagraphBlock([
MewsText(strings
.t(query.isEmpty ? 'history-empty' : 'history-no-matches')),
])
else
itemList([
for (final visit in visits)
for (final page in pages)
itemRow(
href: visit.url,
label: visit.title ?? visit.url,
date: dayTimeText(visit.visitedAt),
href: page.url,
label: page.title ?? page.url,
date: dayTimeText(page.visitedAt),
actions: [
action(MewsUrls.action('forget-page', {'url': page.url}),
strings.t('history-forget')),
],
),
]),
],

View file

@ -139,6 +139,33 @@ ListBlock choiceRow(List<Choice> options) {
);
}
/// A one-field search box that submits back to its own page, turning what the
/// reader typed into a query string. [keep] carries the parameters the submit
/// must not drop as hidden fields, the way an HTML form does.
///
/// Keep this at a fixed position in a page's blocks: the renderer matches form
/// state by position, so a block appearing above it would reset what the
/// reader typed.
FormBlock searchForm({
required String action,
required String label,
required String submitLabel,
required String query,
Map<String, String> keep = const {},
}) {
return FormBlock(
action: action,
method: 'get',
submitLabel: submitLabel,
fields: [
MewsTextField(name: 'q', label: label, password: false, value: query),
for (final kept in keep.entries)
MewsHiddenField(name: kept.key, value: kept.value),
MewsSubmitField(reset: false, label: submitLabel),
],
);
}
/// A plain list of links, used for menus and library lists alike.
ListBlock itemList(List<MewsListItem> items) =>
ListBlock(ordered: false, start: 1, items: items);

View file

@ -1,6 +1,7 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
import 'page_blocks.dart';
Future<MewsPage> buildSubscriptionsPage(
@ -8,7 +9,8 @@ Future<MewsPage> buildSubscriptionsPage(
required MewsDatabase db,
required Strings strings,
}) async {
final sites = await db.allSubscriptions();
final query = url.queryParameters['q'] ?? '';
final sites = await db.allSubscriptions(query: query);
final unread = await db.unreadCounts();
final title = strings.t('subscriptions-title');
@ -17,28 +19,53 @@ Future<MewsPage> buildSubscriptionsPage(
title: title,
body: [
...chrome(title: title, current: url, strings: strings),
searchForm(
action: MewsUrls.subscriptions,
label: strings.t('subscriptions-search-label'),
submitLabel: strings.t('action-search'),
query: query,
),
if (query.isNotEmpty)
ParagraphBlock([
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
]),
if (sites.isEmpty)
ParagraphBlock([MewsText(strings.t('subscriptions-empty'))])
ParagraphBlock([
MewsText(strings.t(query.isEmpty
? 'subscriptions-empty'
: 'subscriptions-no-matches')),
])
else
itemList([
for (final site in sites)
itemRow(
href: site.url,
label: site.title ?? site.url,
date: dayText(site.createdAt),
note: _unreadNote(strings, unread[site.url] ?? 0),
note: _checkedNote(strings, site, unread[site.url] ?? 0),
actions: [
action(
MewsUrls.action('refresh-subscription', {'url': site.url}),
strings.t('action-refresh')),
action(MewsUrls.action('unsubscribe', {'url': site.url}),
strings.t('action-unsubscribe')),
],
),
]),
],
);
}
/// The unread count for a site, or null when it has nothing unread, so the
/// row carries no second line.
String? _unreadNote(Strings strings, int count) {
if (count == 0) return null;
return countText(strings, count,
none: 'count-unread-none',
one: 'count-unread-one',
many: 'count-unread-many');
/// What the reader needs to know about a site: what is waiting, or when it was
/// last looked at. Each is a whole message of its own.
String _checkedNote(Strings strings, Subscription site, int unread) {
if (unread > 0) {
return countText(strings, unread,
none: 'count-unread-none',
one: 'count-unread-one',
many: 'count-unread-many');
}
final checkedAt = site.lastCheckedAt;
if (checkedAt == null) return strings.t('subscriptions-never-checked');
return strings.t('subscriptions-checked', {'date': dayText(checkedAt)});
}