Repository: miniflux/v2 (language: go)

Issue:
Tag entries page
Since a couple of time, Miniflux parses RSS/Atom/JSON feeds entries categories. These categories are displayed as tags in the entry page. I would like to have a link from the entry page to related entries using the tag.

- [x] I have read this document: https://miniflux.app/opinionated.html#feature-request



Discussion / hints:
I am volunteer to work on this
What do you have in mind exactly? A page that list all entries using the same tag? 

Tags are not indexed and uses a Postgres Array `text[]`. It might be slow to query. Just keep that in mind.
> A page that list all entries using the same tag

This is exactly the feature I have in mind!

Mhh, a `[]text` is probably not the best structure with a relational database to store that kind of information. I will experiment
I want this too. The current "Unread" feed is useful, but sometimes I am in the mood for only a few select feeds. So having an index page that allows to me to browse only certain content would be nice. Currently the "Categories" do something similar, the limitation is that one feed cannot belong to more than 1 category.

For example I might have a Youtube category, but some of those Youtube feeds relate to Coding and others to Cooking, sometimes I want to browse with such nuance. 
Alternative: Allow a feed to belong to more than 1 category.
Well.. the feature I propose does not use user defined tags but feed entries tags instead (defined in the feed itself by the author)
@rdelaage Oh yes I misunderstood. I like it still.
I made a separate FR for my idea: https://github.com/miniflux/v2/issues/2573

Fix patch:
diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json
index cc60035a0eb..c49a27f6289 100644
--- a/internal/locale/translations/de_DE.json
+++ b/internal/locale/translations/de_DE.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
     "alert.no_category": "Es ist keine Kategorie vorhanden.",
     "alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
+    "alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
     "alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
     "alert.no_feed": "Es sind keine Abonnements vorhanden.",
     "alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",
diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json
index 822821633db..94d74149cf3 100644
--- a/internal/locale/translations/el_EL.json
+++ b/internal/locale/translations/el_EL.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
     "alert.no_category": "Δεν υπάρχει κατηγορία.",
     "alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
+    "alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
     "alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
     "alert.no_feed": "Δεν έχετε συνδρομές.",
     "alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",
diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json
index 97e58fe5a2b..77b7377867b 100644
--- a/internal/locale/translations/en_US.json
+++ b/internal/locale/translations/en_US.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "There are no starred entries.",
     "alert.no_category": "There is no category.",
     "alert.no_category_entry": "There are no entries in this category.",
+    "alert.no_tag_entry": "There are no entries matching this tag.",
     "alert.no_feed_entry": "There are no entries for this feed.",
     "alert.no_feed": "You don’t have any feeds.",
     "alert.no_feed_in_category": "There is no feed for this category.",
diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json
index 914d8994220..d4669b39562 100644
--- a/internal/locale/translations/es_ES.json
+++ b/internal/locale/translations/es_ES.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "No hay marcador en este momento.",
     "alert.no_category": "No hay categoría.",
     "alert.no_category_entry": "No hay artículos en esta categoría.",
+    "alert.no_tag_entry": "No hay artículos con esta etiqueta.",
     "alert.no_feed_entry": "No hay artículos para esta fuente.",
     "alert.no_feed": "No tienes fuentes.",
     "alert.no_feed_in_category": "No hay fuentes para esta categoría.",
diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json
index 41bd2e50d04..6b2f2fca8f6 100644
--- a/internal/locale/translations/fi_FI.json
+++ b/internal/locale/translations/fi_FI.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
     "alert.no_category": "Ei ole kategoriaa.",
     "alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
+    "alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
     "alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
     "alert.no_feed": "Sinulla ei ole tilauksia.",
     "alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",
diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json
index d616eb3eb3a..fb2982fafba 100644
--- a/internal/locale/translations/fr_FR.json
+++ b/internal/locale/translations/fr_FR.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
     "alert.no_category": "Il n'y a aucune catégorie.",
     "alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
+    "alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
     "alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
     "alert.no_feed": "Vous n'avez aucun abonnement.",
     "alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",
diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json
index 199c66af1a6..ef7c0c043eb 100644
--- a/internal/locale/translations/hi_IN.json
+++ b/internal/locale/translations/hi_IN.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
     "alert.no_category": "कोई श्रेणी नहीं है।",
     "alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
+    "alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
     "alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
     "alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
     "alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",
diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json
index ee06ac61f34..3f1e3cd3ade 100644
--- a/internal/locale/translations/id_ID.json
+++ b/internal/locale/translations/id_ID.json
@@ -248,6 +248,7 @@
     "alert.no_bookmark": "Tidak ada markah.",
     "alert.no_category": "Tidak ada kategori.",
     "alert.no_category_entry": "Tidak ada artikel di kategori ini.",
+    "alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
     "alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
     "alert.no_feed": "Anda tidak memiliki langganan.",
     "alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",
diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json
index 6e808a02053..e9385c7c320 100644
--- a/internal/locale/translations/it_IT.json
+++ b/internal/locale/translations/it_IT.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Nessun preferito disponibile.",
     "alert.no_category": "Nessuna categoria disponibile.",
     "alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
+    "alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
     "alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
     "alert.no_feed": "Nessun feed disponibile.",
     "alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",
diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json
index 8c767b55162..efd2d37dca8 100644
--- a/internal/locale/translations/ja_JP.json
+++ b/internal/locale/translations/ja_JP.json
@@ -248,6 +248,7 @@
     "alert.no_bookmark": "現在星付きはありません。",
     "alert.no_category": "カテゴリが存在しません。",
     "alert.no_category_entry": "このカテゴリには記事がありません。",
+    "alert.no_tag_entry": "このタグに一致するエントリーはありません。",
     "alert.no_feed_entry": "このフィードには記事がありません。",
     "alert.no_feed": "何も購読していません。",
     "alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",
diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json
index d47510e9518..d9141819e67 100644
--- a/internal/locale/translations/nl_NL.json
+++ b/internal/locale/translations/nl_NL.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
     "alert.no_category": "Er zijn geen categorieën.",
     "alert.no_category_entry": "Deze categorie bevat geen feeds.",
+    "alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
     "alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
     "alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
     "alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",
diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json
index 32f3e4f8ceb..3b39301d204 100644
--- a/internal/locale/translations/pl_PL.json
+++ b/internal/locale/translations/pl_PL.json
@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
     "alert.no_category": "Nie ma żadnej kategorii!",
     "alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
+    "alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
     "alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
     "alert.no_feed": "Nie masz żadnej subskrypcji.",
     "alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json
index 56861a3a080..7c639f5ba5a 100644
--- a/internal/locale/translations/pt_BR.json
+++ b/internal/locale/translations/pt_BR.json
@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Não há favorito neste momento.",
     "alert.no_category": "Não há categoria.",
     "alert.no_category_entry": "Não há itens nesta categoria.",
+    "alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
     "alert.no_feed_entry": "Não há itens nessa fonte.",
     "alert.no_feed": "Não há inscrições.",
     "alert.no_feed_in_category": "Não há inscrições nessa categoria.",
diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json
index 69c139f0183..6327c09f480 100644
--- a/internal/locale/translations/ru_RU.json
+++ b/internal/locale/translations/ru_RU.json
@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Избранное отсутствует.",
     "alert.no_category": "Категории отсутствуют.",
     "alert.no_category_entry": "В этой категории нет статей.",
+    "alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
     "alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
     "alert.no_feed": "У вас нет ни одной подписки.",
     "alert.no_feed_in_category": "Для этой категории нет подписки.",
diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json
index 15fe4c8cf13..4fc999a251f 100644
--- a/internal/locale/translations/tr_TR.json
+++ b/internal/locale/translations/tr_TR.json
@@ -18,6 +18,7 @@
   "alert.no_bookmark": "Yıldızlanmış makale yok.",
   "alert.no_category": "Hiç kategori yok.",
   "alert.no_category_entry": "Bu kategoride hiç makele yok.",
+  "alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
   "alert.no_feed": "Hiç beslemeniz yok.",
   "alert.no_feed_entry": "Bu besleme için makele yok.",
   "alert.no_feed_in_category": "Bu kategori için besleme yok.",
diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json
index eeb305d73b4..832a14710c2 100644
--- a/internal/locale/translations/uk_UA.json
+++ b/internal/locale/translations/uk_UA.json
@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Наразі закладки відсутні.",
     "alert.no_category": "Немає категорії.",
     "alert.no_category_entry": "У цій категорії немає записів.",
+    "alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
     "alert.no_feed_entry": "У цій стрічці немає записів.",
     "alert.no_feed": "У вас немає підписок.",
     "alert.no_feed_in_category": "У цій категорії немає підписок.",
diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json
index dc1079c420f..b32a270a52e 100644
--- a/internal/locale/translations/zh_CN.json
+++ b/internal/locale/translations/zh_CN.json
@@ -248,6 +248,7 @@
     "alert.no_bookmark": "目前没有收藏",
     "alert.no_category": "目前没有分类",
     "alert.no_category_entry": "该分类下没有文章",
+    "alert.no_tag_entry": "没有与此标签匹配的条目。",
     "alert.no_feed_entry": "该源中没有文章",
     "alert.no_feed": "目前没有源",
     "alert.no_history": "目前没有历史",
diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json
index 4b4316f3b0e..39504b73c78 100644
--- a/internal/locale/translations/zh_TW.json
+++ b/internal/locale/translations/zh_TW.json
@@ -248,6 +248,7 @@
     "alert.no_bookmark": "目前沒有收藏",
     "alert.no_category": "目前沒有分類",
     "alert.no_category_entry": "該分類下沒有文章",
+    "alert.no_tag_entry": "沒有與此標籤相符的條目。",
     "alert.no_feed_entry": "該Feed中沒有文章",
     "alert.no_feed": "目前沒有Feed",
     "alert.no_history": "目前沒有歷史",
diff --git a/internal/storage/entry_pagination_builder.go b/internal/storage/entry_pagination_builder.go
index bab478d392d..9779f245eaf 100644
--- a/internal/storage/entry_pagination_builder.go
+++ b/internal/storage/entry_pagination_builder.go
@@ -58,6 +58,15 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
 	}
 }
 
+func (e *EntryPaginationBuilder) WithTags(tags []string) {
+	if len(tags) > 0 {
+		for _, tag := range tags {
+			e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
+			e.args = append(e.args, tag)
+		}
+	}
+}
+
 // WithGloballyVisible adds global visibility to the condition.
 func (e *EntryPaginationBuilder) WithGloballyVisible() {
 	e.conditions = append(e.conditions, "not c.hide_globally")
diff --git a/internal/storage/entry_query_builder.go b/internal/storage/entry_query_builder.go
index 680fbedb25e..9ab26738508 100644
--- a/internal/storage/entry_query_builder.go
+++ b/internal/storage/entry_query_builder.go
@@ -160,7 +160,7 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
 func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
 	if len(tags) > 0 {
 		for _, cat := range tags {
-			e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1))
+			e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
 			e.args = append(e.args, cat)
 		}
 	}
diff --git a/internal/template/functions.go b/internal/template/functions.go
index 54e787cf50d..cfbfc53d785 100644
--- a/internal/template/functions.go
+++ b/internal/template/functions.go
@@ -8,6 +8,7 @@ import (
 	"html/template"
 	"math"
 	"net/mail"
+	"net/url"
 	"slices"
 	"strings"
 	"time"
@@ -91,8 +92,9 @@ func (f *funcMap) Map() template.FuncMap {
 		"nonce": func() string {
 			return crypto.GenerateRandomStringHex(16)
 		},
-		"deRef":    func(i *int) int { return *i },
-		"duration": duration,
+		"deRef":     func(i *int) int { return *i },
+		"duration":  duration,
+		"urlEncode": url.PathEscape,
 
 		// These functions are overrode at runtime after the parsing.
 		"elapsed": func(timezone string, t time.Time) string {
diff --git a/internal/template/templates/views/entry.html b/internal/template/templates/views/entry.html
index 4284f097013..48f3c5fe4b7 100644
--- a/internal/template/templates/views/entry.html
+++ b/internal/template/templates/views/entry.html
@@ -135,7 +135,7 @@ <h1 id="page-header-title" dir="auto">
         {{ if .entry.Tags }}
         <div class="entry-tags">
             {{ t "entry.tags.label" }}
-            {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<strong>{{ $e }}</strong>{{end}}
+	    {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<a href="{{ route "tagEntriesAll" "tagName" (urlEncode $e) }}"><strong>{{ $e }}</strong></a>{{end}}
         </div>
         {{ end }}
         <div class="entry-date">
diff --git a/internal/template/templates/views/tag_entries.html b/internal/template/templates/views/tag_entries.html
new file mode 100644
index 00000000000..86d1c2036a2
--- /dev/null
+++ b/internal/template/templates/views/tag_entries.html
@@ -0,0 +1,52 @@
+{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }}
+
+{{ define "page_header"}}
+<section class="page-header" aria-labelledby="page-header-title page-header-title-count">
+    <h1 id="page-header-title" dir="auto">
+        {{ .tagName }}
+        <span aria-hidden="true"> ({{ .total }})</span>
+    </h1>
+    <span id="page-header-title-count" class="sr-only">{{ plural "page.tag_entry_count" .total .total }}</span>
+</section>
+{{ end }}
+
+{{ define "content"}}
+{{ if not .entries }}
+    <p role="alert" class="alert alert-info">{{ t "alert.no_tag_entry" }}</p>
+{{ else }}
+    <div class="pagination-top">
+        {{ template "pagination" .pagination }}
+    </div>
+    <div class="items">
+        {{ range .entries }}
+        <article
+            class="item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}"
+            data-id="{{ .ID }}"
+            aria-labelledby="entry-title-{{ .ID }}"
+            tabindex="-1"
+        >
+            <header class="item-header" dir="auto">
+                <h2 id="entry-title-{{ .ID }}" class="item-title">
+                    <a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
+                        {{ if ne .Feed.Icon.IconID 0 }}
+                        <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="">
+                        {{ end }}
+                        {{ .Title }}
+                    </a>
+                </h2>
+                <span class="category">
+                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                        {{ .Feed.Category.Title }}
+                    </a>
+                </span>
+            </header>
+            {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
+        </article>
+        {{ end }}
+    </div>
+    <div class="pagination-bottom">
+        {{ template "pagination" .pagination }}
+    </div>
+{{ end }}
+
+{{ end }}
diff --git a/internal/ui/entry_tag.go b/internal/ui/entry_tag.go
new file mode 100644
index 00000000000..cf153a8b42a
--- /dev/null
+++ b/internal/ui/entry_tag.go
@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"net/http"
+	"net/url"
+
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response/html"
+	"miniflux.app/v2/internal/http/route"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/storage"
+	"miniflux.app/v2/internal/ui/session"
+	"miniflux.app/v2/internal/ui/view"
+)
+
+func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+	entryID := request.RouteInt64Param(r, "entryID")
+
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithTags([]string{tagName})
+	builder.WithEntryID(entryID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+
+	entry, err := builder.GetEntry()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	if entry == nil {
+		html.NotFound(w, r)
+		return
+	}
+
+	if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+
+		entry.Status = model.EntryStatusRead
+	}
+
+	entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
+	entryPaginationBuilder.WithTags([]string{tagName})
+	prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	nextEntryRoute := ""
+	if nextEntry != nil {
+		nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
+	}
+
+	prevEntryRoute := ""
+	if prevEntry != nil {
+		prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
+	}
+
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	view.Set("entry", entry)
+	view.Set("prevEntry", prevEntry)
+	view.Set("nextEntry", nextEntry)
+	view.Set("nextEntryRoute", nextEntryRoute)
+	view.Set("prevEntryRoute", prevEntryRoute)
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
+	view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
+
+	html.OK(w, r, view.Render("entry"))
+}
diff --git a/internal/ui/tag_entries_all.go b/internal/ui/tag_entries_all.go
new file mode 100644
index 00000000000..a7f6fb02487
--- /dev/null
+++ b/internal/ui/tag_entries_all.go
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"net/http"
+	"net/url"
+
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response/html"
+	"miniflux.app/v2/internal/http/route"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/ui/session"
+	"miniflux.app/v2/internal/ui/view"
+)
+
+func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	offset := request.QueryIntParam(r, "offset", 0)
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+	builder.WithTags([]string{tagName})
+	builder.WithSorting("status", "asc")
+	builder.WithSorting(user.EntryOrder, user.EntryDirection)
+	builder.WithOffset(offset)
+	builder.WithLimit(user.EntriesPerPage)
+
+	entries, err := builder.GetEntries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	count, err := builder.CountEntries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	view.Set("tagName", tagName)
+	view.Set("total", count)
+	view.Set("entries", entries)
+	view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
+	view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
+	view.Set("showOnlyUnreadEntries", false)
+
+	html.OK(w, r, view.Render("tag_entries"))
+}
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 6d8e729cf61..d6641c01920 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -93,6 +93,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
 	uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
 
+	// Tag pages.
+	uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
+	uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
+
 	// Entry pages.
 	uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)


Test patch: