Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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) |
Expand Down
55 changes: 55 additions & 0 deletions client/routes/post/PostNavigation.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
74 changes: 74 additions & 0 deletions client/routes/post/PostNavigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="PostNavigation">
{prevLink ? (
<Link to={prevLink} className="nav-button prev">
<span className="arrow">&#8249;</span>
<span className="label">Previous</span>
</Link>
) : (
<span className="nav-button prev disabled">
<span className="arrow">&#8249;</span>
<span className="label">Previous</span>
</span>
)}

<span className="position">
{navigation.position + 1} / {navigation.total}
</span>

{nextLink ? (
<Link to={nextLink} className="nav-button next">
<span className="label">Next</span>
<span className="arrow">&#8250;</span>
</Link>
) : (
<span className="nav-button next disabled">
<span className="label">Next</span>
<span className="arrow">&#8250;</span>
</span>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions client/routes/post/PostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Tags from "../../components/Tags";
import NotFoundPage from "../error/NotFoundPage";
import File from "./File";
import SourceLink from "./SourceLink";
import PostNavigation from "./PostNavigation";
import "./PostPage.scss";

const RELATION_STRING: Record<Relation, string> = {
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function PostPage() {
<Layout className={`PostPage${fullHeight ? " fullHeight" : ""}`}
simpleSettings
sidebar={<>
{pageData.navigation && <PostNavigation navigation={pageData.navigation} />}
{rating}
<div className="namespace">
<b>Statistics</b>
Expand Down
9 changes: 6 additions & 3 deletions client/routes/search/GalleryPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useReducer, useRef } from "react";
import { useHistory } from "react-router";
import { Link } from "react-router-dom";
import { PostSummary } from "../../../server/routes/apiTypes";
import useQuery from "../../hooks/useQuery";
import File from "../post/File";
import "./GalleryPopup.scss";

Expand All @@ -14,6 +15,8 @@ interface GalleryPopupProps {
export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) {
const [header, toggleHeader] = useReducer(acc => !acc, true);
const history = useHistory();
let [query] = useQuery();
query = query && `?query=${encodeURIComponent(query)}`;
const offset = useRef(0);
const velocity = useRef(0);
const moving = useRef(false);
Expand Down Expand Up @@ -128,7 +131,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) {
const onKeyDown = (ev: KeyboardEvent) => {
if(ev.key === "ArrowLeft" && hasLeft) setId(id - 1);
else if(ev.key === "ArrowRight" && hasRight) setId(id + 1);
else if(ev.key === "Enter") history.push(`/posts/${posts[id].id}`);
else if(ev.key === "Enter") history.push(`/posts/${posts[id].id}${query}`);
else if(ev.key === "Escape") setId(null);
};

Expand All @@ -144,7 +147,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) {
document.documentElement.removeEventListener("keydown", onKeyDown);
document.documentElement.removeEventListener("wheel", onWheel);
};
}, [history, posts, id, setId, hasLeft, hasRight]);
}, [history, posts, id, setId, hasLeft, hasRight, query]);

if(id === null || !posts[id]) return null;

Expand All @@ -156,7 +159,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) {
<div className="GalleryPopup" style={{ left: `${offset.current}vw` }} ref={wrapper}>
<div className={`header${header ? " open" : ""}`}>
<div className="closeBtn" onClick={onClose}>✕</div>
<Link to={`/posts/${post.id}`} className="moreBtn">Open Post</Link>
<Link to={`/posts/${post.id}${query}`} className="moreBtn">Open Post</Link>
</div>
{leftPost &&
<div key={leftPost.id} className="wrap left">
Expand Down
3 changes: 2 additions & 1 deletion configs.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"pageSize": 72,
"cachePages": 5,
"cacheRecords": 1024,
"maxPreviewSize": 104857600
"maxPreviewSize": 104857600,
"tagSorts": ["page", "volume", "chapter", "part"]
},
"tags": {
"services": null,
Expand Down
Loading