diff --git a/frontend/src/components/badge.njk b/frontend/src/components/badge.njk index 6cb0f47..0e35adc 100644 --- a/frontend/src/components/badge.njk +++ b/frontend/src/components/badge.njk @@ -7,7 +7,7 @@ } :host > div { - background-color: var(--gold); + background-color: var(--light-gray); border-radius: 0.4rem; color: var(--gray); font-weight: 600; diff --git a/frontend/src/components/blog-article-list.njk b/frontend/src/components/blog-article-list.njk new file mode 100644 index 0000000..da0f1c4 --- /dev/null +++ b/frontend/src/components/blog-article-list.njk @@ -0,0 +1,88 @@ + + + + + +{% includeOnce 'components/button.njk' %} +{% includeOnce 'components/loader-section.njk' %} +{% includeOnce 'components/pagination.njk' %} diff --git a/frontend/src/components/blog-article-list.ts b/frontend/src/components/blog-article-list.ts new file mode 100644 index 0000000..dd91040 --- /dev/null +++ b/frontend/src/components/blog-article-list.ts @@ -0,0 +1,174 @@ +import {capitalize, sendApiGet, TesoftComponent} from "../scripts/main.ts"; +import {TesoftLoaderSection} from "./loader-section.ts"; +import {TesoftPagination} from "./pagination.ts"; +import {TesoftError} from "./error.ts"; +import {TesoftTag} from "./tag.ts"; +import {formatArticleDate, getBadgeText} from "./blog-article.ts"; +import {TesoftBadge} from "./badge.ts"; + +export class TesoftBlogArticleList extends TesoftComponent { + private readonly listItemTemplate: HTMLTemplateElement; + private readonly loaderSection: TesoftLoaderSection; + private readonly listDiv: HTMLDivElement; + private readonly tagsDiv: HTMLDivElement; + private readonly pagination: TesoftPagination; + private readonly error: TesoftError; + + constructor() { + super(); + + const template = document.getElementById("tesoft-blog-article-list-template") as HTMLTemplateElement; + const templateContent = template.content; + + const shadowRoot = this.attachShadow({mode: "open"}); + shadowRoot.appendChild(templateContent.cloneNode(true)); + + this.listItemTemplate = document.getElementById("tesoft-blog-article-list-item-template") as HTMLTemplateElement; + this.loaderSection = shadowRoot.querySelector("tesoft-loader-section")!; + this.listDiv = shadowRoot.querySelector(".list")!; + this.tagsDiv = shadowRoot.querySelector(".tags")!; + this.pagination = shadowRoot.querySelector("tesoft-pagination")!; + this.error = shadowRoot.querySelector("tesoft-error")!; + } + + async load(page: number, tags: string[], onParametersChange: (page: number, tags: string[]) => void) { + try { + this.tagsDiv.innerHTML = ""; + for (const tagName of await this.fetchTags()) { + const tag = document.createElement("tesoft-tag") as TesoftTag; + tag.textContent = tagName; + this.tagsDiv.appendChild(tag); + } + } catch (error: any) { + // Ignore + } + + this.pagination.addEventListener("update-page", (e) => { + const page = (e as CustomEvent).detail.page; + onParametersChange(page, tags); + this.reload(page, tags, onParametersChange); + }); + + await this.reload(page, tags, onParametersChange); + } + + private async reload(page: number, tags: string[], onParametersChange: (page: number, tags: string[]) => void) { + const itemsPerPage = 20; + + for (const tag of this.tagsDiv.children as any as TesoftTag[]) { + const tagName = tag.textContent.trim(); + + tag.onclick = () => { + const tagSet = new Set(tags); + if (tagSet.has(tagName)) { + tagSet.delete(tagName); + } else { + tagSet.add(tagName); + } + const newTags = Array.from(tagSet); + + onParametersChange(page, newTags); + this.reload(page, newTags, onParametersChange); + }; + + if (tags.includes(tagName)) { + tag.selected = ""; + } else { + tag.selected = null; + } + } + + this.loaderSection.reset(); + this.listDiv.innerHTML = ""; + this.pagination.classList.add("hidden"); + + const parameters: Record = { + offset: (page - 1) * itemsPerPage, + limit: itemsPerPage, + }; + if (tags) { + parameters.tags = tags.join(","); + } + + try { + const response = await sendApiGet("blog", parameters); + if (response.ok) { + const body = await response.json(); + + if (body.articles.length === 0) { + throw "No articles found."; + } + + for (const article of body.articles) { + const anchor = this.listItemTemplate.content.children[0].cloneNode(true) as HTMLAnchorElement; + anchor.href = `/blog?id=${article.id}`; + + const heading = anchor.querySelector("h3")!; + heading.textContent = article.title; + const badgeText = getBadgeText(body.status); + if (badgeText) { + const badge = document.createElement("tesoft-badge") as TesoftBadge; + badge.textContent = badgeText; + heading.appendChild(badge); + } + + anchor.querySelector(".dates")!.textContent = formatArticleDate(new Date(article.date), article['mod-date'] ? new Date(article['mod-date']) : null); + + const tags = anchor.querySelector(".tags")!; + for (const tagName of article.tags) { + const badge = document.createElement("tesoft-badge") as TesoftBadge; + badge.textContent = tagName; + tags.appendChild(badge); + } + + this.listDiv.appendChild(anchor); + } + + this.pagination.curPage = page; + this.pagination.lastPage = Math.ceil(body.total / itemsPerPage); + this.pagination.classList.remove("hidden"); + } else { + const body = await response.json(); + throw `${capitalize(body.message)}.`; + } + } catch (error: any) { + this.error.textContent = error; + this.error.classList.remove("hidden"); + this.listDiv.classList.add("hidden"); + } + + this.loaderSection.finish(); + } + + private async fetchTags(): Promise { + const tags: string[] = []; + + let offset = 0; + const limit = 50; + + while (true) { + const response = await sendApiGet("blog/tags", { + offset: offset, + limit: limit, + }); + if (!response.ok) { + const body = await response.json(); + throw `${capitalize(body.message)}.`; + } + + const body = await response.json(); + + for (const tag of body.tags) { + tags.push(tag); + } + + if (body.tags.length < limit) { + break; + } + } + + return tags; + } +} + +customElements.define("tesoft-blog-article-list", TesoftBlogArticleList); diff --git a/frontend/src/components/blog-article.njk b/frontend/src/components/blog-article.njk index 6082465..a43ec92 100644 --- a/frontend/src/components/blog-article.njk +++ b/frontend/src/components/blog-article.njk @@ -6,8 +6,8 @@ display: block; } - tesoft-loader-section { - min-height: calc(100vh - 8rem); + tesoft-loader-section, tesoft-error { + min-height: var(--full-content-height); } .article { diff --git a/frontend/src/components/blog-article.ts b/frontend/src/components/blog-article.ts index c741e8a..016236b 100644 --- a/frontend/src/components/blog-article.ts +++ b/frontend/src/components/blog-article.ts @@ -41,42 +41,30 @@ export class TesoftBlogArticle extends TesoftComponent { const response = await sendApiGet(`blog/${id}`, {}); if (response.ok) { const body = await response.json(); - const date = new Date(body.date); - const modDate = body['mod-date'] ? new Date(body['mod-date']) : null; this.heading.textContent = body.title; - let badgeText: string; - switch (body.status) { - case ArticleStatus.Draft: - badgeText = "Draft"; - break; - case ArticleStatus.Offline: - badgeText = "Offline"; - break; - default: - badgeText = ""; - break; - } + const badgeText = getBadgeText(body.status); if (badgeText) { const badge = document.createElement("tesoft-badge") as TesoftBadge; badge.textContent = badgeText; this.heading.appendChild(badge); } - this.datesDiv.textContent = `Posted on ${formatDate(date)}${modDate === null ? "" : `, last modified on ${formatDate(modDate)}`}`; + this.datesDiv.textContent = formatArticleDate(new Date(body.date), body['mod-date'] ? new Date(body['mod-date']) : null); for (const tagName of body.tags) { const tag = document.createElement("tesoft-tag") as TesoftTag; tag.innerText = tagName; tag.href = constructUrl(tagName); + tag.selected = ""; this.tagsDiv.appendChild(tag); } this.contentDiv.innerHTML = body.content; } else { const body = await response.json(); - this.error.textContent = capitalize(body.message); + this.error.textContent = `${capitalize(body.message)}.`; this.error.classList.remove("hidden"); this.article.classList.add("hidden"); } @@ -85,4 +73,19 @@ export class TesoftBlogArticle extends TesoftComponent { } } +export function getBadgeText(status: ArticleStatus): string | null { + switch (status) { + case ArticleStatus.Draft: + return "Draft"; + case ArticleStatus.Offline: + return "Offline"; + default: + return null; + } +} + +export function formatArticleDate(date: Date, modDate: Date | null): string { + return `Posted on ${formatDate(date)}${modDate === null ? "" : `, last modified on ${formatDate(modDate)}`}`; +} + customElements.define("tesoft-blog-article", TesoftBlogArticle); diff --git a/frontend/src/components/button.njk b/frontend/src/components/button.njk index 26141d7..c50d580 100644 --- a/frontend/src/components/button.njk +++ b/frontend/src/components/button.njk @@ -29,7 +29,6 @@ :host([selected]) button, :host([selected]) a { background-color: var(--light-gray); - border-style: none; color: var(--gray); font-weight: 800; } diff --git a/frontend/src/components/button.ts b/frontend/src/components/button.ts index cc12654..e4db0a1 100644 --- a/frontend/src/components/button.ts +++ b/frontend/src/components/button.ts @@ -64,6 +64,14 @@ export class TesoftButton extends TesoftComponent { this.setAttribute("selected", ""); } } + + set transparent(value: string | null) { + if (value === null) { + this.removeAttribute("transparent"); + } else { + this.setAttribute("transparent", ""); + } + } } customElements.define("tesoft-button", TesoftButton); diff --git a/frontend/src/components/error.njk b/frontend/src/components/error.njk index 672cb3b..bf8d820 100644 --- a/frontend/src/components/error.njk +++ b/frontend/src/components/error.njk @@ -3,7 +3,7 @@ @import "/src/styles/default.css"; :host { - display: block; + display: grid; } :host > div { diff --git a/frontend/src/components/nav.ts b/frontend/src/components/nav.ts index 2deecee..44fb307 100644 --- a/frontend/src/components/nav.ts +++ b/frontend/src/components/nav.ts @@ -72,6 +72,7 @@ export class TesoftNav extends TesoftComponent { for (const child of this.children) { const button = document.createElement("tesoft-button") as TesoftButton; button.innerText = child.textContent.trim(); + button.transparent = ""; this.smallMenuDiv.appendChild(button); button.href = (child as HTMLAnchorElement).href; diff --git a/frontend/src/pages/blog.njk b/frontend/src/pages/blog.njk index 388a59d..63c6925 100644 --- a/frontend/src/pages/blog.njk +++ b/frontend/src/pages/blog.njk @@ -11,7 +11,14 @@ {% endblock %} {% block components %} + {% includeOnce 'components/blog-article.njk' %} + {% includeOnce 'components/blog-article-list.njk' %} {% endblock %} {% block content %} + + + {% endblock %} diff --git a/frontend/src/scripts/blog.ts b/frontend/src/scripts/blog.ts index e69de29..6b08437 100644 --- a/frontend/src/scripts/blog.ts +++ b/frontend/src/scripts/blog.ts @@ -0,0 +1,96 @@ +import {onReady} from "./main.ts"; +import {TesoftBlogArticle} from "../components/blog-article.ts"; +import {TesoftBlogArticleList} from "../components/blog-article-list.ts"; + +class Parameters { + readonly id: number | null; + readonly page: number | null; + readonly tags: string[]; + + constructor(id: number | null, page: number | null, tags: string[]) { + this.id = id; + this.page = page; + this.tags = tags; + } + + constructUrl(): URL { + const url = new URL(window.location.origin + window.location.pathname); + + if (this.id !== null) { + url.searchParams.set("id", this.id.toString()); + } + if (this.page !== null) { + url.searchParams.set("page", this.page.toString()); + } + if (this.tags.length > 0) { + url.searchParams.set("tags", this.tags.join(",")); + } + + return url; + } + + static parse(searchParams: URLSearchParams): Parameters { + let id: number | null = null; + if (searchParams.has("id")) { + id = parseInt(searchParams.get("id")!); + } + + let page: number | null = null; + if (searchParams.has("page")) { + page = parseInt(searchParams.get("page")!); + } + + let tags: string[] = []; + const tagsStr = searchParams.get("tags"); + if (tagsStr) { + tags = tagsStr.split(","); + } + + return new Parameters(id, page, tags); + } +} + +class BlogSite { + private readonly blogArticle: TesoftBlogArticle; + private readonly blogArticleList: TesoftBlogArticleList; + private parameters: Parameters; + + constructor(blogArticle: TesoftBlogArticle, blogArticleList: TesoftBlogArticleList, parameters: Parameters) { + this.blogArticle = blogArticle; + this.blogArticleList = blogArticleList; + this.parameters = parameters; + } + + load() { + if (this.parameters.id === null) { + this.blogArticleList.load(this.parameters.page ?? 1, this.parameters.tags, (page, tags) => { + this.parameters = new Parameters( + null, + page, + tags, + ); + window.history.pushState(null, "", this.parameters.constructUrl()); + }); + this.blogArticleList.classList.remove("hidden"); + } else { + this.blogArticle.load(this.parameters.id, (tag) => { + return new Parameters( + null, + null, + Array.from(new Set(this.parameters.tags).add(tag)), + ).constructUrl().toString(); + }); + this.blogArticle.classList.remove("hidden"); + } + } +} + +onReady(async () => { + const blogSite = new BlogSite( + document.querySelector("tesoft-blog-article")!, + document.querySelector("tesoft-blog-article-list")!, + Parameters.parse(new URLSearchParams(window.location.search)), + ); + + blogSite.load(); +}); diff --git a/frontend/src/styles/blog.css b/frontend/src/styles/blog.css index e69de29..bb6776b 100644 --- a/frontend/src/styles/blog.css +++ b/frontend/src/styles/blog.css @@ -0,0 +1,3 @@ +/*tesoft-blog-article { + min-height: calc(100vh - 8rem); +}*/ diff --git a/frontend/src/styles/default.css b/frontend/src/styles/default.css index 4d50495..04901fd 100644 --- a/frontend/src/styles/default.css +++ b/frontend/src/styles/default.css @@ -8,6 +8,8 @@ --medium-padding: 0.8rem; --large-padding: 1.2rem; --button-border-radius: 0.5rem; + + --full-content-height: calc(100vh - 8rem); } html { @@ -41,6 +43,10 @@ html { } a { + color: var(--light-gray); +} + +a:hover { color: var(--gold); } diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 3606cad..5421feb 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -35,7 +35,7 @@ tesoft-nav { } main { - min-height: calc(100vh - 8rem); + min-height: var(--full-content-height); padding: 0 0.5rem; }