diff --git a/README.md b/README.md
index 507c00e..8955e9e 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,9 @@ possible)**
- Searching by tags
- Negative search
- Ratings
-- Sorting (date imported, rating, size, etc)
+- Sorting (date imported, rating, size, tag-based ordering)
+- Tag-based ordering for comics/manga (page, volume, chapter)
+- Post navigation (previous/next buttons with keyboard support)
- Searching tags and autocomplete
- Notes and translation overlays
- tag and post relations (parents/siblings, duplicates/alternatives)
@@ -71,19 +73,46 @@ You can also use `?` to match for single character(eg: `?girl`) and `*` to match
Patterns prefixed with `-` will be excluded from results. Patterns are matched against tag's name, but
Hydrus's `namespace:subtag` syntax is also supported.
-Additionally you can sort results by including `order:*` in query. Supported sorts are: `order:posted`(date),
-`order:id`, `order:rating`, `order:size`. You can also append `_desc` or `_asc` to specify order(eg: `order:posted_asc`).
-If not specified, post are sorted by date descending.
+Additionally you can sort results by including `order:*` in query. Supported sorts are: `order:date`(date posted),
+`order:id`, `order:score`(rating), `order:size`. You can also append `_desc` or `_asc` to specify order(eg: `order:date_asc`).
+If not specified, posts are sorted by date descending.
+
+### Tag-based Ordering
+
+You can also sort by tag namespaces for reading comics/manga in order. By default, the following namespaces are
+supported: `order:page`, `order:volume`, `order:chapter`, `order:part`. These sort by the numeric value of the
+corresponding tags (e.g., `page:1`, `page:2`, `volume:1`).
+
+Multiple sort fields can be combined for hierarchical sorting:
+- `series:example order:volume order:page` - Sort by volume first, then by page within each volume
+
+Posts without the specified tag are placed at the end (ascending) or beginning (descending) of results.
+Non-numeric tag values (like `page:cover`) are treated as null and sorted accordingly.
+
+The list of sortable namespaces can be configured via `posts.tagSorts` in `configs.json`.
+
+### Post Navigation
+
+When clicking a post from search results, navigation buttons (Previous/Next) appear on the post page, allowing
+you to browse through posts in order. You can also use arrow keys (Left/Right) for keyboard navigation.
+
+### Rating Filter
If you use a numeric rating service and successfully imported the ratings, you can also filter posts by their ratings
using `rating:` namespace. You can search posts with specific rating(`rating:3`), range(`rating:2-4`) or query posts
that have not been rated(`rating:none`).
+### System Tags
+
`system:` tags from Hydrus are not real tags and are not fully supported. Hybooru only supports `system:inbox`,
`system:archive` and a non-standard `system:trash` for filtering posts that are respectively in inbox, are not in inbox
and are in trash. You can use them in the blacklist/whitelist and you can also negate them using `-` prefix in searches.
-Eg: `1girl blue_* -outdoors rating:3-5 order:rating_desc`
+### Examples
+
+- `1girl blue_* -outdoors rating:3-5 order:score_desc` - Search with tag filters and sort by rating
+- `series:example order:volume order:page` - Read a manga series in order
+- `order:page_asc` - Sort all posts by page number ascending
## Configuration
@@ -110,6 +139,7 @@ Hybooru's config is stored in `configs.json` file in the project's root director
| posts.cachePages | number | `5` | Number of pages cached in single cache entry. |
| posts.cacheRecords | number | `1024` | Max number of cache entries. |
| posts.maxPreviewSize | number | `104857600` | Max size in bytes of post that can be previewed in post page/gallery. Default is 100MB. |
+| posts.tagSorts | string[] | `["page", "volume", "chapter", "part"]` | List of tag namespaces that can be used for `order:*` sorting. Allows sorting posts by tag values (e.g., `order:page`). |
| tags | object | _see below_ | Options related to tags. All tags below support wildcards. |
| tags.services | (string/number)[] or null | `null` | List of names or ids of tag services to import. Use `null` to import from all services. |
| tags.motd | string or object or null | `null` | Query used to search for random image displayed on main page. You can also specify object to specify different tags for different themes(use `light`, `dark` and `auto` as keys) |
diff --git a/client/routes/post/PostNavigation.scss b/client/routes/post/PostNavigation.scss
new file mode 100644
index 0000000..126856d
--- /dev/null
+++ b/client/routes/post/PostNavigation.scss
@@ -0,0 +1,55 @@
+.PostNavigation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-color, rgba(128, 128, 128, 0.3));
+
+ .nav-button {
+ display: flex;
+ align-items: center;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ text-decoration: none;
+ color: inherit;
+ transition: background-color 0.2s, opacity 0.2s;
+ cursor: pointer;
+ font-size: 0.9em;
+
+ &:hover:not(.disabled) {
+ background-color: var(--hover-bg, rgba(128, 128, 128, 0.2));
+ }
+
+ &.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ .arrow {
+ font-size: 1.2em;
+ line-height: 1;
+ }
+
+ &.prev .arrow {
+ margin-right: 0.25rem;
+ }
+
+ &.next .arrow {
+ margin-left: 0.25rem;
+ }
+
+ .label {
+ @media (max-width: 400px) {
+ display: none;
+ }
+ }
+ }
+
+ .position {
+ font-size: 0.85em;
+ opacity: 0.7;
+ white-space: nowrap;
+ }
+}
diff --git a/client/routes/post/PostNavigation.tsx b/client/routes/post/PostNavigation.tsx
new file mode 100644
index 0000000..0858ddb
--- /dev/null
+++ b/client/routes/post/PostNavigation.tsx
@@ -0,0 +1,74 @@
+import React, { useCallback, useEffect } from "react";
+import { Link, useHistory, useLocation } from "react-router-dom";
+import { PostNavigationResponse } from "../../../server/routes/apiTypes";
+import { qsParse, qsStringify } from "../../helpers/utils";
+import "./PostNavigation.scss";
+
+interface PostNavigationProps {
+ navigation: PostNavigationResponse;
+}
+
+export default function PostNavigation({ navigation }: PostNavigationProps) {
+ const history = useHistory();
+ const location = useLocation();
+ const query = qsParse(location.search);
+
+ const buildLink = useCallback((id: number | null) => {
+ if(id === null) return null;
+ return `/posts/${id}${qsStringify(query)}`;
+ }, [query]);
+
+ const prevLink = buildLink(navigation.prev);
+ const nextLink = buildLink(navigation.next);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const onKeyDown = (ev: KeyboardEvent) => {
+ // Don't navigate if user is typing in an input
+ if(ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return;
+
+ if(ev.key === "ArrowLeft" && prevLink) {
+ ev.preventDefault();
+ history.push(prevLink);
+ } else if(ev.key === "ArrowRight" && nextLink) {
+ ev.preventDefault();
+ history.push(nextLink);
+ }
+ };
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [history, prevLink, nextLink]);
+
+ return (
+