@@ -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;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template id="tesoft-blog-article-list-template">
|
||||
<style>
|
||||
@import "/src/styles/default.css";
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host > div {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
tesoft-loader-section, .list, tesoft-error {
|
||||
min-height: calc(var(--full-content-height) - 10rem);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item > h3 {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-auto-columns: auto;
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.item > .dates {
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
tesoft-pagination {
|
||||
justify-self: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div>
|
||||
<div class="tags"></div>
|
||||
<tesoft-loader-section>
|
||||
<div class="list">
|
||||
</div>
|
||||
|
||||
<tesoft-error class="hidden">
|
||||
</tesoft-error>
|
||||
</tesoft-loader-section>
|
||||
<tesoft-pagination class="hidden">
|
||||
</tesoft-pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tesoft-blog-article-list-item-template">
|
||||
<a class="item">
|
||||
<h3>Title</h3>
|
||||
<div class="dates">
|
||||
</div>
|
||||
<div class="tags">
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script src="/src/components/blog-article-list.ts" type="module"></script>
|
||||
{% includeOnce 'components/button.njk' %}
|
||||
{% includeOnce 'components/loader-section.njk' %}
|
||||
{% includeOnce 'components/pagination.njk' %}
|
||||
@@ -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<TesoftLoaderSection>("tesoft-loader-section")!;
|
||||
this.listDiv = shadowRoot.querySelector<HTMLDivElement>(".list")!;
|
||||
this.tagsDiv = shadowRoot.querySelector<HTMLDivElement>(".tags")!;
|
||||
this.pagination = shadowRoot.querySelector<TesoftPagination>("tesoft-pagination")!;
|
||||
this.error = shadowRoot.querySelector<TesoftError>("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<string, any> = {
|
||||
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<HTMLHeadingElement>("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<HTMLDivElement>(".dates")!.textContent = formatArticleDate(new Date(article.date), article['mod-date'] ? new Date(article['mod-date']) : null);
|
||||
|
||||
const tags = anchor.querySelector<HTMLDivElement>(".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<string[]> {
|
||||
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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
:host([selected]) button, :host([selected]) a {
|
||||
background-color: var(--light-gray);
|
||||
border-style: none;
|
||||
color: var(--gray);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@import "/src/styles/default.css";
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
:host > div {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block components %}
|
||||
{% includeOnce 'components/blog-article.njk' %}
|
||||
{% includeOnce 'components/blog-article-list.njk' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<tesoft-blog-article class="hidden">
|
||||
</tesoft-blog-article>
|
||||
|
||||
<tesoft-blog-article-list class="hidden">
|
||||
</tesoft-blog-article-list>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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<TesoftBlogArticle>("tesoft-blog-article")!,
|
||||
document.querySelector<TesoftBlogArticleList>("tesoft-blog-article-list")!,
|
||||
Parameters.parse(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
|
||||
blogSite.load();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/*tesoft-blog-article {
|
||||
min-height: calc(100vh - 8rem);
|
||||
}*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ tesoft-nav {
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - 8rem);
|
||||
min-height: var(--full-content-height);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user