site: implement search functionality
This commit is contained in:
parent
3ae6b02672
commit
08be6b17f5
8 changed files with 257 additions and 2 deletions
|
@ -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");
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
5
public/js/alpinejs@3.14.9/dist/cdn.min.js
vendored
Normal file
5
public/js/alpinejs@3.14.9/dist/cdn.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
97
public/js/app.js
Normal file
97
public/js/app.js
Normal 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
34
templates/_search.html.ep
Normal 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 %>
|
|
@ -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->@*) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue