site: implement search functionality

This commit is contained in:
Lucas Gabriel Vuotto 2025-04-27 15:20:49 +00:00
parent 3ae6b02672
commit 08be6b17f5
8 changed files with 257 additions and 2 deletions

View file

@ -45,7 +45,7 @@ sub tag_for_pager_shortcut ($self, $content, $page) {
sub link_for_tag ($self, $tag) sub link_for_tag ($self, $tag)
{ {
my $url = $self->url_for("list_media") my $url = $self->url_for("list_media")
->query(tags => $tag->{display}); ->query(tags => $tag->{display} . " ");
defined($tag->{kind_id}) and defined($tag->{kind_id}) and
my %class = (class => "tag-kind-$tag->{kind_id}"); my %class = (class => "tag-kind-$tag->{kind_id}");
@ -103,6 +103,7 @@ sub startup ($self)
$r->get("/media/<media_id:num>")->to("media#show")->name("show_media"); $r->get("/media/<media_id:num>")->to("media#show")->name("show_media");
$r->get("/tags")->to("tags#list")->name("list_tags"); $r->get("/tags")->to("tags#list")->name("list_tags");
$r->get("/search")->to("tags#search")->name("search_tags");
$r->get("/random/media")->to("random#media")->name("random_media"); $r->get("/random/media")->to("random#media")->name("random_media");
$r->get("/random/tag")->to("random#tag")->name("random_tag"); $r->get("/random/tag")->to("random#tag")->name("random_tag");

View file

@ -35,4 +35,36 @@ sub list ($self)
})->wait; })->wait;
} }
sub search ($self)
{
my $v = $self->validation;
my $q = $v->required("q")->param;
return $self->render(
status => 400
) if $v->has_error;
return $self->render(json => []) if $q eq "";
$self->render_later;
my $endpoint = $self->api_v0_url->path("search")->query(q => $q);
$self->ua->get_p($endpoint)->then(sub ($tx) {
return $self->render(
json => [],
status => 500,
) if $tx->error;
my $json = $tx->res->json;
my @tags = map +{
display => $_->{display},
count => $_->{count},
kind_class => $_->{kind_id} ?
"tag-kind-$_->{kind_id}" : "accent",
}, $json->{tags}->@*;
return $self->render(json => [@tags]);
})->wait;
}
1; 1;

View file

@ -52,9 +52,11 @@
} }
dl, dl,
form,
h1, h1,
h2, h2,
h3, h3,
input,
ol, ol,
p, p,
ul { ul {
@ -72,6 +74,7 @@ body {
} }
dl, dl,
form,
h1, h1,
h2, h2,
h3, h3,
@ -127,6 +130,65 @@ img {
height: auto; height: auto;
} }
input {
font: inherit;
border: var(--border-thin);
padding: 0 0.1875rem;
}
input[type=submit] {
background-color: var(--accent);
color: var(--bg);
font-weight: bold;
}
input[type=submit]:active {
background-color: var(--accent-hover);
border-color: var(--accent-hover);
}
form[name=search] {
gap: 1rem;
justify-content: center;
align-items: center;
padding-right: 0.5rem;
padding-left: 0.5rem;
}
form[name=search] input[type=submit] {
width: 7rem;
}
form[name=search] input[name=tags] {
width: 100%;
}
.position-relative {
position: relative;
}
#tags-suggestions {
position: absolute;
background-color: var(--bg);
width: 100%;
border: var(--border-thin);
box-shadow: rgba(0, 0, 0, 0.5) 0 0.25rem 0.5rem -0.25rem;
padding-left: 0;
list-style: none;
}
.suggestion-hover {
background-color: #eee;
}
#tags-suggestions li {
padding-left: 0.1875rem;
padding-right: 0.1875rem;
}
body > header { body > header {
border-bottom: var(--border-thin); border-bottom: var(--border-thin);
font-size: 1.25rem; font-size: 1.25rem;
@ -168,10 +230,18 @@ main {
padding-bottom: var(--gap); padding-bottom: var(--gap);
} }
[x-cloak] {
display: none;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
.float-right {
float: right;
}
.layout-viewport { .layout-viewport {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@ -263,16 +333,23 @@ main {
line-height: 0; line-height: 0;
} }
.accent {
color: var(--accent);
}
span.tag-kind-a,
a.tag-kind-a:link, a.tag-kind-a:link,
a.tag-kind-a:visited { a.tag-kind-a:visited {
color: var(--tag-kind-a); color: var(--tag-kind-a);
} }
span.tag-kind-c,
a.tag-kind-c:link, a.tag-kind-c:link,
a.tag-kind-c:visited { a.tag-kind-c:visited {
color: var(--tag-kind-c); color: var(--tag-kind-c);
} }
span.tag-kind-r,
a.tag-kind-r:link, a.tag-kind-r:link,
a.tag-kind-r:visited { a.tag-kind-r:visited {
color: var(--tag-kind-r); color: var(--tag-kind-r);

File diff suppressed because one or more lines are too long

97
public/js/app.js Normal file
View file

@ -0,0 +1,97 @@
function getCurrentWord(s, cursor) {
return s.substring(s.lastIndexOf(' ', cursor - 1) + 1, cursor);
}
function mySplice(s, idx, del, n) {
return s.substring(0, idx) + n + s.substring(idx + del);
}
function suggestionPosition(el) {
// el.parentNode is ul#tags-suggestion, which include a template. That's why
// a -1 is needed here.
const idx = [...el.parentNode.children].indexOf(el);
return idx <= 0 ? null : idx - 1;
}
function currentSuggestionElement(idx) {
if (idx === null)
return null;
return document.querySelector("#tags-suggestions").children[idx + 1];
}
document.addEventListener('alpine:init', () => {
Alpine.data('tagsSuggestions', () => ({
suggestions: [],
currentSuggestion: null,
inputCursor: 0,
query: '',
reset() {
this.suggestions = [];
this.currentSuggestion = null;
this.inputCursor = 0;
this.query = '';
},
async fetchSuggestions(evt) {
this.inputCursor = evt.target.selectionStart;
this.query = getCurrentWord(evt.target.value, this.inputCursor);
if (!this.query) {
this.reset();
return;
}
const params = new URLSearchParams({q: this.query});
const response = await fetch(`${endpoints.search}?${params}`);
if (!response.ok) {
this.reset();
return;
}
this.suggestions = await response.json();
},
completeSuggestion(evt) {
if (this.currentSuggestion === null)
return;
const value = this.$refs.tags.value;
const pos = this.inputCursor - this.query.length;
const tag = this.suggestions[this.currentSuggestion].display;
this.$refs.tags.value = mySplice(value, pos, this.query.length,
tag + ' ');
this.reset();
this.$refs.tags.focus();
// Don't switch focus if we're completing after a Tab press.
if (evt.type === "keydown" && evt.keyCode === 9)
evt.preventDefault();
},
selectCurrentSuggestion(evt) {
this.currentSuggestion = suggestionPosition(evt.currentTarget);
this.completeSuggestion(evt);
},
setCurrentSuggestion(evt, amount = 0) {
currentSuggestionElement(this.currentSuggestion)
?.classList.remove("suggestion-hover");
if (evt === 'mouseenter')
this.currentSuggestion = suggestionPosition(evt.currentTarget);
else {
const len = this.suggestions.length;
let cur = this.currentSuggestion;
if (cur === null)
cur = amount > 0 ? -1 : amount < 0 ? len : 0;
this.currentSuggestion = (cur + amount + len) % len;
}
currentSuggestionElement(this.currentSuggestion)
?.classList.add("suggestion-hover");
}
}));
});

34
templates/_search.html.ep Normal file
View file

@ -0,0 +1,34 @@
% stash("pooru.search" => 1);
<%= form_for "list_media" =>
name => "search",
class => "layout-flex-row" =>
begin %>
<div
class="layout-flex-item-fullsize position-relative"
x-data="tagsSuggestions">
<input type="search" name="tags" value="<%= param "tags" %>"
placeholder="Search tags..."
x-ref="tags"
@focus="fetchSuggestions"
@input.debounce="fetchSuggestions"
@keydown.tab="completeSuggestion"
@keydown.up.prevent="setCurrentSuggestion($event, -1)"
@keydown.down.prevent="setCurrentSuggestion($event, +1)"
@keydown.escape="reset">
<ul id="tags-suggestions"
x-cloak x-show="suggestions.length > 0">
<template x-for="suggestion in suggestions">
<li
@click="selectCurrentSuggestion"
@mouseenter="setCurrentSuggestion">
<span
x-text="suggestion.display"
:class="suggestion.kind_class">
</span>
<span class="float-right" x-text="suggestion.count"></span>
</li>
</template>
</ul>
</div>
<%= submit_button "Search" %>
<% end %>

View file

@ -1,6 +1,6 @@
% layout "main"; % layout "main";
<%= tag("h1", title) if title %> %= include "_search";
<div class="layout-flex-row gallery"> <div class="layout-flex-row gallery">
% for my $m ($media->@*) { % for my $m ($media->@*) {

View file

@ -5,6 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title><%= join(" - ", "Pooru", title() // ()) %></title> <title><%= join(" - ", "Pooru", title() // ()) %></title>
<%= stylesheet "/css/style.css" %> <%= stylesheet "/css/style.css" %>
% if (stash "pooru.search") {
<%= javascript begin %>
const endpoints = {
search: "<%= url_for("search_tags") %>",
};
<% end %>
<%= javascript "/js/app.js", defer => undef %>
<%= javascript '/js/alpinejs@3.14.9/dist/cdn.min.js', defer => undef %>
% }
</head> </head>
<body class="layout-flex-column"> <body class="layout-flex-column">
<header> <header>