feat: tag bookmarks and sites, and browse the library by tag
This commit is contained in:
parent
5cd053c8a9
commit
b0b86ef5b1
13 changed files with 445 additions and 21 deletions
|
|
@ -3,6 +3,7 @@ import '../../l10n/strings.dart';
|
|||
import '../../mews/document.dart';
|
||||
import 'mews_urls.dart';
|
||||
import 'page_blocks.dart';
|
||||
import 'tags_page.dart';
|
||||
|
||||
Future<MewsPage> buildBookmarksPage(
|
||||
Uri url, {
|
||||
|
|
@ -10,7 +11,10 @@ Future<MewsPage> buildBookmarksPage(
|
|||
required Strings strings,
|
||||
}) async {
|
||||
final query = url.queryParameters['q'] ?? '';
|
||||
final bookmarks = await db.bookmarksByDate(query: query);
|
||||
final tag = url.queryParameters['tag'];
|
||||
final bookmarks = await db.bookmarksByDate(query: query, tag: tag);
|
||||
final tags = await db.tagsOfMany(
|
||||
TagKind.bookmark, bookmarks.map((b) => b.url).toList());
|
||||
final title = strings.t('bookmarks-title');
|
||||
|
||||
return internalPage(
|
||||
|
|
@ -18,16 +22,30 @@ Future<MewsPage> buildBookmarksPage(
|
|||
title: title,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
searchForm(
|
||||
textForm(
|
||||
action: MewsUrls.bookmarks,
|
||||
label: strings.t('bookmarks-search-label'),
|
||||
submitLabel: strings.t('action-search'),
|
||||
query: query,
|
||||
field: 'q',
|
||||
value: query,
|
||||
),
|
||||
if (query.isNotEmpty)
|
||||
ParagraphBlock([
|
||||
ParagraphBlock([
|
||||
if (query.isNotEmpty) ...[
|
||||
MewsLink(MewsUrls.page(url, {'q': null}),
|
||||
[MewsText(strings.t('action-clear-search'))]),
|
||||
const MewsText(actionSeparator),
|
||||
],
|
||||
if (tag != null && tag.isNotEmpty) ...[
|
||||
MewsLink(MewsUrls.page(url, {'tag': null}),
|
||||
[MewsText(strings.t('bookmarks-show-all'))]),
|
||||
const MewsText(actionSeparator),
|
||||
],
|
||||
MewsLink(MewsUrls.tags, [MewsText(strings.t('nav-tags'))]),
|
||||
]),
|
||||
if (tag != null && tag.isNotEmpty)
|
||||
ParagraphBlock([
|
||||
MewsEmphasis(EmphasisStyle.emphasis,
|
||||
[MewsText(strings.t('bookmarks-tagged', {'tag': tag}))]),
|
||||
]),
|
||||
if (bookmarks.isEmpty)
|
||||
ParagraphBlock([
|
||||
|
|
@ -41,10 +59,16 @@ Future<MewsPage> buildBookmarksPage(
|
|||
href: bookmark.url,
|
||||
label: bookmark.title ?? bookmark.url,
|
||||
date: dayText(bookmark.createdAt),
|
||||
chips: [
|
||||
for (final tag in tags[bookmark.url] ?? const <String>[])
|
||||
action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag),
|
||||
],
|
||||
actions: [
|
||||
action(
|
||||
MewsUrls.action('remove-bookmark', {'url': bookmark.url}),
|
||||
strings.t('action-remove-bookmark')),
|
||||
action(tagsOfItemUrl(TagKind.bookmark, bookmark.url),
|
||||
strings.t('action-edit-tags')),
|
||||
],
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ Future<MewsPage> buildHistoryPage(
|
|||
title: title,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
searchForm(
|
||||
textForm(
|
||||
action: MewsUrls.history,
|
||||
label: strings.t('history-search-label'),
|
||||
submitLabel: strings.t('action-search'),
|
||||
query: query,
|
||||
field: 'q',
|
||||
value: query,
|
||||
),
|
||||
// Clearing history cannot be undone, so it takes a second step rather
|
||||
// than a dialog: another page the reader can simply leave.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Future<MewsPage> buildHubPage(
|
|||
final sites = await db.countSubscriptions();
|
||||
final visits = await db.countHistory();
|
||||
final unread = await db.unreadCount();
|
||||
final tags = await db.tagCounts();
|
||||
|
||||
return internalPage(
|
||||
url: url,
|
||||
|
|
@ -57,6 +58,14 @@ Future<MewsPage> buildHubPage(
|
|||
one: 'count-pages-one',
|
||||
many: 'count-pages-many'),
|
||||
),
|
||||
itemRow(
|
||||
href: MewsUrls.tags,
|
||||
label: strings.t('nav-tags'),
|
||||
note: countText(strings, tags.length,
|
||||
none: 'count-tags-none',
|
||||
one: 'count-tags-one',
|
||||
many: 'count-tags-many'),
|
||||
),
|
||||
itemRow(href: MewsUrls.settings, label: strings.t('nav-settings')),
|
||||
]),
|
||||
ParagraphBlock([
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ abstract final class MewsUrls {
|
|||
static const bookmarks = 'mews:bookmarks';
|
||||
static const subscriptions = 'mews:subscriptions';
|
||||
static const history = 'mews:history';
|
||||
static const tags = 'mews:tags';
|
||||
static const settings = 'mews:settings';
|
||||
|
||||
/// Whether [url] belongs to the internal scheme.
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ MewsListItem itemRow({
|
|||
String? date,
|
||||
String? note,
|
||||
bool strong = false,
|
||||
List<Choice> chips = const [],
|
||||
List<Choice> actions = const [],
|
||||
}) {
|
||||
final text = date == null ? label : '$date $label';
|
||||
|
|
@ -83,6 +84,11 @@ MewsListItem itemRow({
|
|||
const MewsLineBreak(),
|
||||
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
|
||||
],
|
||||
if (chips.isNotEmpty) ...[
|
||||
const MewsLineBreak(),
|
||||
..._separated(
|
||||
[for (final chip in chips) MewsLink(chip.href, [MewsText(chip.label)])]),
|
||||
],
|
||||
if (actions.isNotEmpty) ...[
|
||||
const MewsLineBreak(),
|
||||
..._separated(
|
||||
|
|
@ -139,18 +145,20 @@ 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.
|
||||
/// A one-field form that submits back into the mews scheme, turning what the
|
||||
/// reader typed into one query parameter named [field]. [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({
|
||||
FormBlock textForm({
|
||||
required String action,
|
||||
required String field,
|
||||
required String label,
|
||||
required String submitLabel,
|
||||
required String query,
|
||||
String value = '',
|
||||
Map<String, String> keep = const {},
|
||||
}) {
|
||||
return FormBlock(
|
||||
|
|
@ -158,7 +166,7 @@ FormBlock searchForm({
|
|||
method: 'get',
|
||||
submitLabel: submitLabel,
|
||||
fields: [
|
||||
MewsTextField(name: 'q', label: label, password: false, value: query),
|
||||
MewsTextField(name: field, label: label, password: false, value: value),
|
||||
for (final kept in keep.entries)
|
||||
MewsHiddenField(name: kept.key, value: kept.value),
|
||||
MewsSubmitField(reset: false, label: submitLabel),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import '../../l10n/strings.dart';
|
|||
import '../../mews/document.dart';
|
||||
import 'mews_urls.dart';
|
||||
import 'page_blocks.dart';
|
||||
import 'tags_page.dart';
|
||||
|
||||
Future<MewsPage> buildSubscriptionsPage(
|
||||
Uri url, {
|
||||
|
|
@ -10,7 +11,10 @@ Future<MewsPage> buildSubscriptionsPage(
|
|||
required Strings strings,
|
||||
}) async {
|
||||
final query = url.queryParameters['q'] ?? '';
|
||||
final sites = await db.allSubscriptions(query: query);
|
||||
final tag = url.queryParameters['tag'];
|
||||
final sites = await db.allSubscriptions(query: query, tag: tag);
|
||||
final tags = await db.tagsOfMany(
|
||||
TagKind.subscription, sites.map((s) => s.url).toList());
|
||||
final unread = await db.unreadCounts();
|
||||
final title = strings.t('subscriptions-title');
|
||||
|
||||
|
|
@ -19,16 +23,30 @@ Future<MewsPage> buildSubscriptionsPage(
|
|||
title: title,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
searchForm(
|
||||
textForm(
|
||||
action: MewsUrls.subscriptions,
|
||||
label: strings.t('subscriptions-search-label'),
|
||||
submitLabel: strings.t('action-search'),
|
||||
query: query,
|
||||
field: 'q',
|
||||
value: query,
|
||||
),
|
||||
if (query.isNotEmpty)
|
||||
ParagraphBlock([
|
||||
ParagraphBlock([
|
||||
if (query.isNotEmpty) ...[
|
||||
MewsLink(MewsUrls.page(url, {'q': null}),
|
||||
[MewsText(strings.t('action-clear-search'))]),
|
||||
const MewsText(actionSeparator),
|
||||
],
|
||||
if (tag != null && tag.isNotEmpty) ...[
|
||||
MewsLink(MewsUrls.page(url, {'tag': null}),
|
||||
[MewsText(strings.t('subscriptions-show-all'))]),
|
||||
const MewsText(actionSeparator),
|
||||
],
|
||||
MewsLink(MewsUrls.tags, [MewsText(strings.t('nav-tags'))]),
|
||||
]),
|
||||
if (tag != null && tag.isNotEmpty)
|
||||
ParagraphBlock([
|
||||
MewsEmphasis(EmphasisStyle.emphasis,
|
||||
[MewsText(strings.t('subscriptions-tagged', {'tag': tag}))]),
|
||||
]),
|
||||
if (sites.isEmpty)
|
||||
ParagraphBlock([
|
||||
|
|
@ -43,12 +61,18 @@ Future<MewsPage> buildSubscriptionsPage(
|
|||
href: site.url,
|
||||
label: site.title ?? site.url,
|
||||
note: _checkedNote(strings, site, unread[site.url] ?? 0),
|
||||
chips: [
|
||||
for (final tag in tags[site.url] ?? const <String>[])
|
||||
action(MewsUrls.page(url, {'tag': tag, 'q': null}), tag),
|
||||
],
|
||||
actions: [
|
||||
action(
|
||||
MewsUrls.action('refresh-subscription', {'url': site.url}),
|
||||
strings.t('action-refresh')),
|
||||
action(MewsUrls.action('unsubscribe', {'url': site.url}),
|
||||
strings.t('action-unsubscribe')),
|
||||
action(tagsOfItemUrl(TagKind.subscription, site.url),
|
||||
strings.t('action-edit-tags')),
|
||||
],
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
157
app/lib/application/pages/tags_page.dart
Normal file
157
app/lib/application/pages/tags_page.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import '../../database/database.dart';
|
||||
import '../../l10n/strings.dart';
|
||||
import '../../mews/document.dart';
|
||||
import 'mews_urls.dart';
|
||||
import 'page_blocks.dart';
|
||||
|
||||
/// Tags, in two shapes. Without an item it lists every tag in use and what
|
||||
/// carries it; with one it is where the reader puts tags on that item.
|
||||
///
|
||||
/// A tag exists only while something carries it, so adding a tag and creating
|
||||
/// one are the same act.
|
||||
Future<MewsPage> buildTagsPage(
|
||||
Uri url, {
|
||||
required MewsDatabase db,
|
||||
required Strings strings,
|
||||
}) async {
|
||||
final kind = _kindOf(url.queryParameters['kind']);
|
||||
final item = url.queryParameters['url'];
|
||||
return kind != null && item != null && item.isNotEmpty
|
||||
? _assign(url, db: db, strings: strings, kind: kind, item: item)
|
||||
: _index(url, db: db, strings: strings);
|
||||
}
|
||||
|
||||
TagKind? _kindOf(String? name) => switch (name) {
|
||||
'bookmark' => TagKind.bookmark,
|
||||
'subscription' => TagKind.subscription,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// The address of the tags page for one item.
|
||||
String tagsOfItemUrl(TagKind kind, String url) =>
|
||||
MewsUrls.page(Uri.parse(MewsUrls.tags), {'kind': kind.name, 'url': url});
|
||||
|
||||
Future<MewsPage> _index(
|
||||
Uri url, {
|
||||
required MewsDatabase db,
|
||||
required Strings strings,
|
||||
}) async {
|
||||
final counts = await db.tagCounts();
|
||||
final title = strings.t('tags-title');
|
||||
|
||||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
if (counts.isEmpty)
|
||||
ParagraphBlock([MewsText(strings.t('tags-empty'))])
|
||||
else
|
||||
itemList([
|
||||
for (final tag in counts.keys)
|
||||
MewsListItem([
|
||||
ParagraphBlock([
|
||||
MewsEmphasis(EmphasisStyle.strong, [MewsText(tag)]),
|
||||
const MewsLineBreak(),
|
||||
..._uses(strings, tag, counts[tag]!),
|
||||
const MewsLineBreak(),
|
||||
MewsLink(MewsUrls.action('delete-tag', {'tag': tag}),
|
||||
[MewsText(strings.t('tags-delete'))]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// What carries a tag, each count a link to the list it narrows.
|
||||
List<MewsInline> _uses(Strings strings, String tag, TagUse use) {
|
||||
final links = <MewsInline>[];
|
||||
if (use.bookmarks > 0) {
|
||||
links.add(MewsLink(
|
||||
MewsUrls.page(Uri.parse(MewsUrls.bookmarks), {'tag': tag}),
|
||||
[
|
||||
MewsText(countText(strings, use.bookmarks,
|
||||
none: 'count-bookmarks-none',
|
||||
one: 'count-bookmarks-one',
|
||||
many: 'count-bookmarks-many')),
|
||||
],
|
||||
));
|
||||
}
|
||||
if (use.sites > 0) {
|
||||
if (links.isNotEmpty) links.add(const MewsText(actionSeparator));
|
||||
links.add(MewsLink(
|
||||
MewsUrls.page(Uri.parse(MewsUrls.subscriptions), {'tag': tag}),
|
||||
[
|
||||
MewsText(countText(strings, use.sites,
|
||||
none: 'count-sites-none',
|
||||
one: 'count-sites-one',
|
||||
many: 'count-sites-many')),
|
||||
],
|
||||
));
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
Future<MewsPage> _assign(
|
||||
Uri url, {
|
||||
required MewsDatabase db,
|
||||
required Strings strings,
|
||||
required TagKind kind,
|
||||
required String item,
|
||||
}) async {
|
||||
final held = await db.tagsOf(kind, item);
|
||||
final all = (await db.tagCounts()).keys.toList();
|
||||
final available = all.where((tag) => !held.contains(tag)).toList();
|
||||
final name = await db.itemTitle(kind, item) ?? item;
|
||||
final title = strings.t('tags-assign-title', {'name': name});
|
||||
final back =
|
||||
kind == TagKind.bookmark ? MewsUrls.bookmarks : MewsUrls.subscriptions;
|
||||
|
||||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
body: [
|
||||
HeadingBlock(1, [MewsText(title)]),
|
||||
ParagraphBlock([
|
||||
MewsLink(back, [MewsText(strings.t('tags-done'))]),
|
||||
]),
|
||||
const RuleBlock(),
|
||||
// Adding a tag that does not exist yet is how one is made.
|
||||
textForm(
|
||||
action: MewsUrls.action('add-tag'),
|
||||
field: 'tag',
|
||||
label: strings.t('tags-add-label'),
|
||||
submitLabel: strings.t('tags-add-submit'),
|
||||
keep: {'kind': kind.name, 'url': item},
|
||||
),
|
||||
if (held.isNotEmpty) ...[
|
||||
HeadingBlock(2, [MewsText(strings.t('tags-on-item'))]),
|
||||
itemList([
|
||||
for (final tag in held)
|
||||
itemRow(
|
||||
href: MewsUrls.page(Uri.parse(back), {'tag': tag}),
|
||||
label: tag,
|
||||
actions: [
|
||||
action(
|
||||
MewsUrls.action('remove-tag',
|
||||
{'kind': kind.name, 'url': item, 'tag': tag}),
|
||||
strings.t('tags-remove')),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
if (available.isNotEmpty) ...[
|
||||
HeadingBlock(2, [MewsText(strings.t('tags-other'))]),
|
||||
itemList([
|
||||
for (final tag in available)
|
||||
itemRow(
|
||||
href: MewsUrls.action(
|
||||
'add-tag', {'kind': kind.name, 'url': item, 'tag': tag}),
|
||||
label: tag,
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue