From 598d1e772e56bd95d2144e84a74fce1166b34077 Mon Sep 17 00:00:00 2001
From: dhardtke <1360135+dhardtke@users.noreply.github.com>
Date: Sat, 1 May 2021 14:54:56 +0200
Subject: [PATCH] feat: Re-implement much simpler tag filter (#9)
---
assets/icons.svg | 2 +-
assets/index.ts | 4 +-
assets/scss/_bootstrap/_index.scss | 2 +-
assets/scss/_linked_cards.scss | 11 +-
assets/scss/recipe_list.scss | 32 +---
assets/ts/_util/jaro_winkler.ts | 68 -------
assets/ts/components/component.ts | 27 +--
assets/ts/components/observer.ts | 12 +-
.../page/recipe_list_page/recipe_list_page.ts | 2 +-
assets/ts/page/recipe_list_page/tag_filter.ts | 167 ++++--------------
deps.ts | 1 +
src/http/routes/recipe.routes.ts | 16 +-
src/http/routes/routers.ts | 2 -
src/http/routes/tag.routes.ts | 29 ---
src/http/util/parameters.ts | 18 ++
src/http/webserver.ts | 4 +-
src/i18n/de.ts | 1 +
src/i18n/en.ts | 1 +
src/i18n/mod.ts | 1 +
src/tpl/templates/_components/icon.ts | 1 -
.../templates/recipe/recipe_list.template.ts | 112 +++++++-----
tsconfig.json | 19 +-
22 files changed, 186 insertions(+), 346 deletions(-)
delete mode 100644 assets/ts/_util/jaro_winkler.ts
delete mode 100644 src/http/routes/tag.routes.ts
diff --git a/assets/icons.svg b/assets/icons.svg
index 73e2300..bd85122 100644
--- a/assets/icons.svg
+++ b/assets/icons.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/index.ts b/assets/index.ts
index 21140e7..cb01b7b 100644
--- a/assets/index.ts
+++ b/assets/index.ts
@@ -5,7 +5,7 @@
///
import "./deps.ts";
-import { bootComponents, Component } from "./ts/components/component.ts";
+import { bootComponents, BaseComponent } from "./ts/components/component.ts";
import { Observer } from "./ts/components/observer.ts";
import { NavbarDarkModeSwitcher } from "./ts/global/_navbar_dark_mode_switcher.ts";
import { removeUrlFlashParameter } from "./ts/global/_remove_url_flash_parameter.ts";
@@ -14,7 +14,7 @@ import { RecipeEditPage } from "./ts/page/recipe_edit_page/recipe_edit_page.ts";
import { RecipeListPage } from "./ts/page/recipe_list_page/recipe_list_page.ts";
// components
-Component.register("Observer", Observer);
+console.assert(Boolean(Observer));
bootComponents();
const globals = [
diff --git a/assets/scss/_bootstrap/_index.scss b/assets/scss/_bootstrap/_index.scss
index 93569a8..86e8822 100644
--- a/assets/scss/_bootstrap/_index.scss
+++ b/assets/scss/_bootstrap/_index.scss
@@ -38,7 +38,7 @@ $list-group-active-border-color: $secondary;
@import "../../node_modules/bootstrap/scss/buttons";
@import "../../node_modules/bootstrap/scss/transitions";
@import "../../node_modules/bootstrap/scss/dropdown";
-//@import "../../node_modules/bootstrap/scss/button-group";
+@import "../../node_modules/bootstrap/scss/button-group";
@import "../../node_modules/bootstrap/scss/nav";
@import "../../node_modules/bootstrap/scss/navbar";
@import "../../node_modules/bootstrap/scss/card";
diff --git a/assets/scss/_linked_cards.scss b/assets/scss/_linked_cards.scss
index ea46739..a9be19d 100644
--- a/assets/scss/_linked_cards.scss
+++ b/assets/scss/_linked_cards.scss
@@ -5,7 +5,14 @@
color: inherit;
transition: bootstrap.$transition-base;
- &:hover {
- border-color: bootstrap.$primary;
+ &:not(.disabled) {
+ &:hover {
+ border-color: bootstrap.$primary;
+ }
+ }
+
+ .disabled {
+ pointer-events: none;
+ cursor: default;
}
}
diff --git a/assets/scss/recipe_list.scss b/assets/scss/recipe_list.scss
index 8cb37dc..25ca59e 100644
--- a/assets/scss/recipe_list.scss
+++ b/assets/scss/recipe_list.scss
@@ -23,34 +23,18 @@
}
#tag-filter {
- width: 100%;
+ max-height: 300px;
+ padding-right: .75rem;
- .list-group {
- max-height: calc(100vh - 200px);
-
- &-item {
- transition: bootstrap.$transition-base;
-
- &.active {
- // fix weird shift when transitioning
- margin-top: 0;
- border-top-width: 0;
- }
-
- &.disabled {
- pointer-events: unset;
- cursor: unset;
- }
- }
+ @include bootstrap.media-breakpoint-down('md') {
+ max-height: 600px;
}
- @include bootstrap.media-breakpoint-up("lg") {
- & {
- width: 350px;
- }
+ .active {
+ background: bootstrap.$primary;
- .list-group {
- height: 500px;
+ &:hover {
+ color: #fff;
}
}
}
diff --git a/assets/ts/_util/jaro_winkler.ts b/assets/ts/_util/jaro_winkler.ts
deleted file mode 100644
index aa1c231..0000000
--- a/assets/ts/_util/jaro_winkler.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-// based on https://gist.github.com/sumn2u/0e0b5d9505ad096284928a987ace13fb#file-jaro-wrinker-js
-export function jaroWinklerDistance(s1: string, s2: string) {
- let m = 0;
-
- // Exit early if either are empty.
- if (s1.length === 0 || s2.length === 0) {
- return 0;
- }
-
- // Exit early if they're an exact match.
- if (s1 === s2) {
- return 1;
- }
-
- const range = (Math.floor(Math.max(s1.length, s2.length) / 2)) - 1,
- s1Matches = new Array(s1.length),
- s2Matches = new Array(s2.length);
-
- for (let i = 0; i < s1.length; i++) {
- const low = (i >= range) ? i - range : 0, high = (i + range <= s2.length) ? (i + range) : (s2.length - 1);
-
- for (let j = low; j <= high; j++) {
- if (s1Matches[i] !== true && s2Matches[j] !== true && s1[i] === s2[j]) {
- ++m;
- s1Matches[i] = s2Matches[j] = true;
- break;
- }
- }
- }
-
- // Exit early if no matches were found.
- if (m === 0) {
- return 0;
- }
-
- // Count the transpositions.
- let k = 0;
- let n_trans = 0;
-
- for (let i = 0; i < s1.length; i++) {
- if (s1Matches[i]) {
- let j;
- for (j = k; j < s2.length; j++) {
- if (s2Matches[j]) {
- k = j + 1;
- break;
- }
- }
-
- if (s1[i] !== s2[j]) {
- ++n_trans;
- }
- }
- }
-
- let weight = (m / s1.length + m / s2.length + (m - (n_trans / 2)) / m) / 3, l = 0;
- const p = 0.1;
-
- if (weight > 0.7) {
- while (s1[l] === s2[l] && l < 4) {
- ++l;
- }
-
- weight = weight + l * p * (1 - weight);
- }
-
- return weight;
-}
diff --git a/assets/ts/components/component.ts b/assets/ts/components/component.ts
index 552a6ce..c56b378 100644
--- a/assets/ts/components/component.ts
+++ b/assets/ts/components/component.ts
@@ -1,23 +1,24 @@
// deno-lint-ignore no-explicit-any
type Class = new (...args: any[]) => S;
-const registry: { [name: string]: Class } = {};
+const registry: { [name: string]: Class } = {};
const registeredElements: HTMLElement[] = [];
-export abstract class Component {
- protected readonly ctx!: HTMLElement;
-
- constructor(ctx: HTMLElement) {
- this.ctx = ctx;
+export function Component(name: string) {
+ return (ctor: Class) => {
+ registry[name] = ctor;
}
+}
- static register(name: string, cmp: Class) {
- registry[name] = cmp;
- }
+export abstract class BaseComponent {
+ static readonly _name: string;
+ protected readonly ctx: HTMLElement;
- abstract mount(): void;
+ protected constructor(ctx: HTMLElement) {
+ this.ctx = ctx;
+ }
- unmount(): void {
+ destructor(): void {
}
}
@@ -31,13 +32,13 @@ export function bootComponents() {
throw new Error(`No component with the name ${ctx.dataset.cmp} found.`);
}
registeredElements.push(ctx);
- new cmp(ctx).mount();
+ new cmp(ctx);
}
});
}
if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", initializeComponents);
+ document.addEventListener("DOMContentLoaded", initializeComponents, false);
} else {
initializeComponents();
}
diff --git a/assets/ts/components/observer.ts b/assets/ts/components/observer.ts
index 82e8d12..8f781bf 100644
--- a/assets/ts/components/observer.ts
+++ b/assets/ts/components/observer.ts
@@ -1,24 +1,26 @@
-import { Component } from "./component.ts";
+import { BaseComponent, Component } from "./component.ts";
export const Events = {
Intersecting: "ObserverIntersecting"
} as const;
-export class Observer extends Component {
+@Component("Observer")
+export class Observer extends BaseComponent {
private observer?: IntersectionObserver;
- mount() {
+ constructor(ctx: HTMLElement) {
+ super(ctx);
this.observer = new IntersectionObserver(([entry]) => {
if (entry && entry.isIntersecting) {
window.dispatchEvent(new CustomEvent(Events.Intersecting));
- this.unmount();
+ this.destructor();
}
});
this.observer.observe(this.ctx);
}
- unmount() {
+ destructor() {
this.observer?.disconnect();
this.ctx.remove();
}
diff --git a/assets/ts/page/recipe_list_page/recipe_list_page.ts b/assets/ts/page/recipe_list_page/recipe_list_page.ts
index 7d65a66..14461a3 100644
--- a/assets/ts/page/recipe_list_page/recipe_list_page.ts
+++ b/assets/ts/page/recipe_list_page/recipe_list_page.ts
@@ -53,7 +53,7 @@ function registerInfiniteScrolling($recipeList: HTMLElement) {
export const RecipeListPage = () => {
registerOrderByControl();
- new TagFilter();
+ console.assert(Boolean(TagFilter));
const $recipeList = document.querySelector(Selectors.RecipeList);
if ($recipeList && $recipeList.dataset[Data.InfiniteScrolling] === "true") {
diff --git a/assets/ts/page/recipe_list_page/tag_filter.ts b/assets/ts/page/recipe_list_page/tag_filter.ts
index eec4088..6349f71 100644
--- a/assets/ts/page/recipe_list_page/tag_filter.ts
+++ b/assets/ts/page/recipe_list_page/tag_filter.ts
@@ -1,148 +1,45 @@
-import { jaroWinklerDistance } from "../../_util/jaro_winkler.ts";
-import { removeUrlParameterValue } from "../../_util/remove_url_parameter_value.ts";
+import { BaseComponent, Component } from "../../components/component.ts";
-declare type Tag = {
- id: number;
- title: string;
- description: string;
- recipeCount: number;
-};
+const Hash = "#tag-filter-visible";
-const TagListEndpoint = "/tag/filter";
-const Id = "tag-filter";
-const UrlParameter = "tagId";
-const JaroWinklerSimilarityThreshold = .75;
-const Selectors = {
- Title: ".tag-title",
- RecipeCount: ".tag-recipe-count",
- Results: ".list-group",
- Item: ".list-group-item",
- ClearBtn: ".btn-tag-clear",
- InputFilter: ".input-filter"
-} as const;
-const Classes = {
- Active: "active",
- Disabled: "disabled",
- Hidden: "d-none"
-} as const;
+@Component("TagToggle")
+class TagToggle extends BaseComponent {
+ constructor(ctx: HTMLElement) {
+ super(ctx);
-export class TagFilter {
- private readonly $tagFilter: HTMLDivElement;
- private readonly $inputFilter: HTMLInputElement;
- private readonly $results: HTMLDivElement;
- private readonly $template: HTMLTemplateElement;
-
- private readonly requestUrl: URL;
- private readonly activeTagIds: Set;
- private readonly hiddenTagIds: Set = new Set();
-
- constructor() {
- this.$tagFilter = document.getElementById(Id)! as HTMLDivElement;
- this.$results = this.$tagFilter.querySelector(Selectors.Results)!;
- this.$template = this.$tagFilter.querySelector("template")!;
- this.$tagFilter.querySelector(Selectors.ClearBtn)!.addEventListener("click", this.clear);
- this.$inputFilter = this.$tagFilter.querySelector(Selectors.InputFilter)!;
- this.$inputFilter.addEventListener("input", this.inputFilter);
- this.requestUrl = new URL(window.location.href);
- this.activeTagIds = new Set(this.requestUrl.searchParams.getAll("tagId").map((id) => parseInt(id, 10)));
-
- this.load();
+ this.ctx.addEventListener("click", () => {
+ const active = location.href.includes(Hash);
+ window.history.replaceState({}, "", location.href.replace(location.hash, "") + (active ? "" : Hash));
+ });
}
+}
- private clear = (e: Event) => {
- e.preventDefault();
- e.stopPropagation();
- this.activeTagIds.clear();
- this.hiddenTagIds.clear();
- this.$inputFilter.value = "";
- this.load();
- };
-
- private inputFilter = () => {
- const query = this.$inputFilter.value.trim().toLowerCase();
- const $items = this.$results.querySelectorAll(Selectors.Item);
- for (let i = 0; i < $items.length; i++) {
- const $item = $items[i];
- const title = $item.querySelector(Selectors.Title)!.textContent!.trim().toLowerCase();
- const id = parseInt($item.dataset.tagId!, 10);
- const shouldBeHidden = Boolean(query) && !$item.classList.contains(Classes.Active) && jaroWinklerDistance(query, title) < JaroWinklerSimilarityThreshold;
- $item.classList.toggle(Classes.Hidden, shouldBeHidden);
- if (shouldBeHidden) {
- this.hiddenTagIds.add(id);
- } else {
- this.hiddenTagIds.delete(id);
- }
- }
- };
-
- private addItemClickListener($item: HTMLAnchorElement) {
- $item.addEventListener("click", (e) => {
- e.preventDefault();
- e.stopPropagation();
- if (parseInt($item.querySelector(Selectors.RecipeCount)!.textContent!, 10) === 0) {
- return;
- }
- const id = parseInt($item.dataset.tagId!, 10);
- const isActive = $item.classList.toggle(Classes.Active);
- const $input = $item.querySelector("input") as HTMLInputElement;
- $input.disabled = !isActive;
- if (isActive) {
- this.activeTagIds.add(id);
- } else {
- this.activeTagIds.delete(id);
- }
- this.load();
+@Component("TagFilter")
+export class TagFilter extends BaseComponent {
+ constructor(ctx: HTMLElement) {
+ super(ctx);
+
+ this.ctx.querySelectorAll("a[href]").forEach(($anchor) => {
+ $anchor.addEventListener("click", (e) => {
+ e.preventDefault();
+ // use Hash to show the collapsed div after reload
+ window.history.pushState({}, "", $anchor.href + Hash);
+ window.location.reload();
+ });
});
- };
-
- private buildTagUrl(tagId: number): string {
- const url = new URL(this.requestUrl.toString());
- if (this.activeTagIds.has(tagId)) {
- removeUrlParameterValue(url, UrlParameter, tagId + "");
- } else {
- url.searchParams.append(UrlParameter, tagId + "");
+ if (window.location.hash === Hash) {
+ this.ctx.classList.add("show");
}
- return url.toString();
- }
- private buildEndpointUrl(): URL {
- const url = new URL(TagListEndpoint, window.location.href);
- this.activeTagIds.forEach((id) => url.searchParams.append(UrlParameter, id + ""));
- return url;
+ window.addEventListener("popstate", this.onPopState);
}
- private updateItem(tag: Tag, $item: HTMLAnchorElement) {
- const $input = $item.querySelector("input") as HTMLInputElement;
- $input.value = tag.id + "";
- $item.dataset.tagId = tag.id + "";
- $item.querySelector(Selectors.Title)!.textContent = tag.title;
- const $countBadge = $item.querySelector(Selectors.RecipeCount)!;
- $countBadge.textContent = tag.recipeCount + "";
- $countBadge.classList.toggle(Classes.Hidden, tag.recipeCount === 0);
- $item.href = this.buildTagUrl(tag.id);
- const isActive = this.activeTagIds.has(tag.id);
- $input.disabled = !isActive;
- const isDisabled = $input.disabled && tag.recipeCount === 0;
- $item.classList.toggle(Classes.Active, isActive);
- $item.classList.toggle(Classes.Disabled, isDisabled);
- $item.classList.toggle(Classes.Hidden, this.hiddenTagIds.has(tag.id));
- $item.title = !isDisabled && tag.description || "";
+ destructor() {
+ super.destructor();
+ window.removeEventListener("popstate", this.onPopState);
}
- private load() {
- // @ts-ignore
- fetch(this.buildEndpointUrl()).then(async (response) => {
- const tags: Tag[] = await response.json();
- for (const tag of tags) {
- let $item = this.$results.querySelector(`[data-tag-id="${tag.id}"]`) as HTMLAnchorElement;
- if (!$item) {
- const $fragment = this.$template.content.cloneNode(true) as DocumentFragment;
- $item = $fragment.querySelector("a") as HTMLAnchorElement;
- this.addItemClickListener($item);
- this.$results.appendChild($fragment);
- }
- this.updateItem(tag, $item);
- }
- });
- }
+ private onPopState = () => {
+ window.location.reload();
+ };
}
diff --git a/deps.ts b/deps.ts
index 177b428..bb7c1fa 100644
--- a/deps.ts
+++ b/deps.ts
@@ -1,6 +1,7 @@
export * as sqlite from "https://deno.land/x/sqlite@v2.4.0/mod.ts";
export * as Dom from "https://deno.land/x/deno_dom@v0.1.8-alpha/deno-dom-wasm.ts";
export * as Oak from "https://deno.land/x/oak@v7.3.0/mod.ts";
+export { HttpServerStd } from "https://deno.land/x/oak@v7.3.0/http_server_std.ts";
export { default as parseIngredient } from "https://cdn.skypack.dev/parse-ingredient@v0.3.0?min";
export * from "https://cdn.skypack.dev/numeric-quantity@v1.0.1?min";
export * as Zod from "https://cdn.skypack.dev/zod@3.0.0-alpha.33?dts";
diff --git a/src/http/routes/recipe.routes.ts b/src/http/routes/recipe.routes.ts
index 21187cc..1dcf702 100644
--- a/src/http/routes/recipe.routes.ts
+++ b/src/http/routes/recipe.routes.ts
@@ -130,11 +130,17 @@ router
},
),
);
- const tags = tagIds.length
- ? services.get(TagService).list({
- filters: { ids: tagIds },
- })
- : [];
+
+ const tags = services.get(TagService).list({
+ orderBy: { column: "title" },
+ loadRecipeCount: true,
+ filters: {
+ tagsWithSameRecipes: {
+ ids: tagIds,
+ includeOthers: true,
+ },
+ },
+ });
ctx.response.body = RecipeListTemplate(
recipes,
tags,
diff --git a/src/http/routes/routers.ts b/src/http/routes/routers.ts
index 4d5523f..80ae7a8 100644
--- a/src/http/routes/routers.ts
+++ b/src/http/routes/routers.ts
@@ -1,7 +1,6 @@
import { AssetsRouter } from "./assets.routes.ts";
import { IndexRouter } from "./index.routes.ts";
import { RecipeRouter } from "./recipe.routes.ts";
-import { TagRouter } from "./tag.routes.ts";
import { ThumbnailsRouter } from "./thumbnails.routes.ts";
export const Routers = [
@@ -9,5 +8,4 @@ export const Routers = [
AssetsRouter,
ThumbnailsRouter,
RecipeRouter,
- TagRouter,
];
diff --git a/src/http/routes/tag.routes.ts b/src/http/routes/tag.routes.ts
deleted file mode 100644
index f952047..0000000
--- a/src/http/routes/tag.routes.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Oak } from "../../../deps.ts";
-import { services } from "../../data/service/services.ts";
-import { TagService } from "../../data/service/tag.service.ts";
-import { AppState } from "../webserver.ts";
-
-const router: Oak.Router = new Oak.Router({ prefix: "/tag" });
-router
- .get("/filter", (ctx: Oak.Context) => {
- const service: TagService = services.get(TagService);
-
- const tagIds = ctx.request.url.searchParams.getAll("tagId").map((id) =>
- parseInt(id, 10)
- );
- const tags = service.list({
- orderBy: { column: "title" },
- loadRecipeCount: true,
- filters: {
- tagsWithSameRecipes: {
- ids: tagIds,
- includeOthers: true,
- },
- },
- });
-
- ctx.response.type = "json";
- ctx.response.body = JSON.stringify(tags);
- });
-
-export { router as TagRouter };
diff --git a/src/http/util/parameters.ts b/src/http/util/parameters.ts
index dc09685..7646c61 100644
--- a/src/http/util/parameters.ts
+++ b/src/http/util/parameters.ts
@@ -18,6 +18,7 @@ export function removeParameter(url: URL, parameterName: string): string {
return result.toString();
}
+// TODO rename functions for consistency
export function parameter(
url: URL,
parameterName: string,
@@ -26,6 +27,14 @@ export function parameter(
return url.searchParams.get(parameterName) || _default;
}
+export function parameterValues(
+ url: URL,
+ parameterName: string,
+ _default = [],
+): string[] {
+ return url.searchParams.getAll(parameterName) || _default;
+}
+
export function setParameter(
url: URL,
parameterName: string,
@@ -35,3 +44,12 @@ export function setParameter(
result.searchParams.set(parameterName, parameterValue);
return result;
}
+export function appendParameter(
+ url: URL,
+ parameterName: string,
+ parameterValue: unknown,
+): URL {
+ const result = new URL(url.toString());
+ result.searchParams.append(parameterName, String(parameterValue));
+ return result;
+}
diff --git a/src/http/webserver.ts b/src/http/webserver.ts
index 873ab97..8626c7e 100644
--- a/src/http/webserver.ts
+++ b/src/http/webserver.ts
@@ -1,4 +1,4 @@
-import { log, Oak } from "../../deps.ts";
+import { HttpServerStd, log, Oak } from "../../deps.ts";
import { Database } from "../data/db.ts";
import { services } from "../data/service/services.ts";
import { ingredient } from "../data/util/ingredient.ts";
@@ -47,8 +47,10 @@ export async function spawnServer(
);
Page.minifying = args.settings.minifyHtml;
+ // TODO remove explicit HttpServerStd instantiation once https://github.com/denoland/deno/issues/10193 is fixed
const app = new Oak.Application({
state,
+ serverConstructor: HttpServerStd,
});
app.use(
parameterAdapter(),
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index fe52f46..8f63a23 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -41,6 +41,7 @@ export const de: Language = {
previous: "Vorherige Seite",
},
recipe: {
+ count: (n) => `${n} Rezept${n === 1 ? "" : "e"}`,
aggregateRating: "Fremde Bewertung",
aggregateRatingCount: "Anzahl fremder Bewertungen",
aggregateRatingValue: "Fremde Bewertung",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 2a76651..4c00a1d 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -41,6 +41,7 @@ export const en: Language = {
previous: "Previous page",
},
recipe: {
+ count: (n) => `${n} Recipe${n === 1 ? "" : "s"}`,
aggregateRating: "Foreign rating",
aggregateRatingCount: "Number of foreign ratings",
aggregateRatingValue: "Foreign rating",
diff --git a/src/i18n/mod.ts b/src/i18n/mod.ts
index 8b0c56c..b5dcb56 100644
--- a/src/i18n/mod.ts
+++ b/src/i18n/mod.ts
@@ -73,6 +73,7 @@ interface Ingredients {
}
interface Recipe {
+ count: (n: number) => string;
aggregateRating: string;
aggregateRatingCount: string;
aggregateRatingValue: string;
diff --git a/src/tpl/templates/_components/icon.ts b/src/tpl/templates/_components/icon.ts
index 14ad941..4507016 100644
--- a/src/tpl/templates/_components/icon.ts
+++ b/src/tpl/templates/_components/icon.ts
@@ -9,7 +9,6 @@ export const ICONS = [
"moon",
"globe",
"search",
- "funnel",
"battery-half",
"layout-wtf",
"alarm",
diff --git a/src/tpl/templates/recipe/recipe_list.template.ts b/src/tpl/templates/recipe/recipe_list.template.ts
index c29f904..9698a0a 100644
--- a/src/tpl/templates/recipe/recipe_list.template.ts
+++ b/src/tpl/templates/recipe/recipe_list.template.ts
@@ -3,7 +3,7 @@ import { Recipe } from "../../../data/model/recipe.ts";
import { Tag } from "../../../data/model/tag.ts";
import { Pagination } from "../../../data/pagination.ts";
import { date, number } from "../../../data/util/format.ts";
-import { parameter, removeParameter, removeParameterValue, } from "../../../http/util/parameters.ts";
+import { appendParameter, parameter, parameterValues, removeParameter, removeParameterValue, } from "../../../http/util/parameters.ts";
import { UrlGenerator } from "../../../http/util/url_generator.ts";
import { l } from "../../../i18n/mod.ts";
import { e, html } from "../../mod.ts";
@@ -13,27 +13,55 @@ import { Icon, LabeledIcon } from "../_components/icon.ts";
import { Pagination as PaginationComponent } from "../_components/pagination.ts";
import { Page } from "../_structure/page.ts";
-export const TagFilter = () => html`
-
-