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` - -