mirror of https://github.com/requarks/wiki
parent
76845ab462
commit
11cba35777
@ -0,0 +1,201 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { mayOnPage } from './pages.ts'
|
||||
import { normalizePagePath } from '../helpers/common.ts'
|
||||
|
||||
const siteIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
}
|
||||
|
||||
/** The locale a request that named none is answered in. */
|
||||
function defaultLocale(siteId: string): string {
|
||||
return WIKI.sites[siteId]?.config?.locales?.primary ?? 'en'
|
||||
}
|
||||
|
||||
/**
|
||||
* Blogs API
|
||||
*
|
||||
* One route, because a blog listing is one question: which posts to draw, where the reader is in the
|
||||
* blog, and what the sidebar should offer to narrow it by. Those three come out of a single read (see
|
||||
* `MAX_POSTS` in `models/blogs.ts`), and splitting them across two requests would only make it
|
||||
* possible for a tag cloud to disagree with the listing beside it.
|
||||
*
|
||||
* No route-level permissions: a blog is read by whoever may read its front page, which is a PAGE
|
||||
* rule and not a group-wide one — `config.permissions` reads only the group-wide list, so declaring
|
||||
* anything here would refuse the anonymous reader a public blog exists for. The front page is
|
||||
* checked in the handler and each post is checked again in the model.
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* LIST A BLOG'S POSTS
|
||||
*/
|
||||
app.get<{
|
||||
Params: { siteId: string }
|
||||
Querystring: {
|
||||
path: string
|
||||
locale?: string
|
||||
tag?: string
|
||||
year?: number
|
||||
month?: number
|
||||
page?: number
|
||||
}
|
||||
}>(
|
||||
'/sites/:siteId/blogs/posts',
|
||||
{
|
||||
schema: {
|
||||
summary: "List a blog's posts",
|
||||
description:
|
||||
"One page of a blog's listing, with the tag and archive facets beside it.\n\n`path` is the blog's own path — the page written with the `blog` editor — and the posts are the pages under it, as deep as that blog is configured to collect them. A page with no body of its own is never a post (a redirection is a doorway, and a nested blog is its own blog), and neither is anything under a nested blog: that blog lists its own posts, so no post is ever listed twice.\n\nReadable without a session, because the blog's front page is: an anonymous request sees only published posts, the same set the page view would serve it. Every post is additionally held to `read:pages` at its own path, and the facets are counted over what survives that — a tag cloud that counted pages the reader may not open would be telling them those pages exist.\n\nThe facets describe the whole blog rather than the filtered set, so that picking a tag never removes the control that would undo it.",
|
||||
tags: ['Pages'],
|
||||
params: siteIdParam,
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
maxLength: 2048,
|
||||
description: "The blog's own path, without a leading slash."
|
||||
},
|
||||
locale: {
|
||||
type: 'string',
|
||||
maxLength: 10,
|
||||
description: "The site's primary locale when absent."
|
||||
},
|
||||
tag: {
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
description: 'Only posts carrying this tag.'
|
||||
},
|
||||
year: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 9999,
|
||||
description: 'Only posts published in this year.'
|
||||
},
|
||||
month: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 12,
|
||||
description: 'Only posts published in this month of `year`. Ignored without it.'
|
||||
},
|
||||
page: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
description:
|
||||
'Which page of the listing to answer with. Clamped to the last one that exists.'
|
||||
}
|
||||
},
|
||||
required: ['path']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The posts, and what the sidebar should offer',
|
||||
type: 'object',
|
||||
properties: {
|
||||
posts: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
path: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
publishedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
"The post's publication date: its scheduled start, or when it was created."
|
||||
},
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
authorId: { type: 'string', format: 'uuid' },
|
||||
authorName: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: 'How many posts match, ignoring which page was asked for.'
|
||||
},
|
||||
page: { type: 'integer' },
|
||||
pageCount: { type: 'integer' },
|
||||
facets: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: { type: 'string' },
|
||||
count: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
},
|
||||
archive: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
year: { type: 'integer' },
|
||||
month: { type: 'integer' },
|
||||
count: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the blog holds more posts than one request will read, and the listing is therefore a prefix of it.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!WIKI.sites[req.params.siteId]) {
|
||||
return reply.notFound('This site does not exist.')
|
||||
}
|
||||
const locale = req.query.locale ?? defaultLocale(req.params.siteId)
|
||||
const path = normalizePagePath(req.query.path)
|
||||
const blog = await WIKI.models.blogs.blogAt(req.params.siteId, locale, path)
|
||||
if (!blog) {
|
||||
return reply.notFound('There is no blog at this path.')
|
||||
}
|
||||
/*
|
||||
The front page's own rules decide whether there is a listing to hand out at all. Refused as
|
||||
not-found rather than forbidden, which is the same answer the page itself gives: whether a
|
||||
blog exists at a path the reader may not read is not something to tell them.
|
||||
*/
|
||||
if (!mayOnPage(req, 'read:pages', { siteId: req.params.siteId, path, locale, tags: [] })) {
|
||||
return reply.notFound('There is no blog at this path.')
|
||||
}
|
||||
return WIKI.models.blogs.listing({
|
||||
siteId: req.params.siteId,
|
||||
blog,
|
||||
actor: WIKI.models.groups.actorForRequest(req),
|
||||
publicOnly: !req.session?.authenticated,
|
||||
filter: {
|
||||
tag: req.query.tag,
|
||||
year: req.query.year,
|
||||
month: req.query.month,
|
||||
page: req.query.page
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,507 @@
|
||||
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
|
||||
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
|
||||
import { decodeTreePath, encodeTreePath } from '../helpers/common.ts'
|
||||
import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts'
|
||||
import type { AccessActor } from './groups.ts'
|
||||
|
||||
/**
|
||||
* How many of a blog's posts are read out of the database for one request.
|
||||
*
|
||||
* Every post is read, not just the page of them being shown, because the ordering that matters is
|
||||
* the one that survives the page rules: which posts a reader may open cannot be expressed in SQL —
|
||||
* a rule matches on path, locale and tags and is resolved a page at a time — so a `LIMIT 10` here
|
||||
* would be ten CANDIDATES, of which this reader might see six, and page 2 would then start in the
|
||||
* wrong place. Filtering first and slicing afterwards is what makes "posts 11-20 of 47" true.
|
||||
*
|
||||
* The facets are the same read. A tag cloud and an archive counted in SQL would count posts this
|
||||
* reader may not open, which is a listing of pages they were told nothing about — the same reasoning
|
||||
* `tags.getTags` applies site-wide.
|
||||
*
|
||||
* The ceiling is what keeps that honest rather than unbounded. A blog past it is not a blog any more
|
||||
* and wants a search page; the listing says so rather than quietly serving a prefix.
|
||||
*/
|
||||
const MAX_POSTS = 2000
|
||||
|
||||
/** A blog's front page: where it is, and how its author set it up. */
|
||||
export interface BlogRef {
|
||||
id: string
|
||||
/** Slash-separated path of the front page, i.e. the blog's own URL within the site. */
|
||||
path: string
|
||||
title: string
|
||||
locale: string
|
||||
settings: BlogContent
|
||||
}
|
||||
|
||||
/** One post, as a listing draws it. */
|
||||
export interface BlogPost {
|
||||
id: string
|
||||
/** Slash-separated path of the post, i.e. its URL within the site. */
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
/** The post's icon, as an Iconify reference. Empty when it has none. */
|
||||
icon: string
|
||||
tags: string[]
|
||||
/**
|
||||
* When the post counts as having been published: its `publishStartDate`, or when it was created.
|
||||
*
|
||||
* One field rather than two, because a blog is ordered by one thing and a reader is shown one
|
||||
* date, and a post written today about last week belongs where its author dated it.
|
||||
*
|
||||
* It is not what decides whether the post is LISTED. That is `publishState`, here as everywhere
|
||||
* else in the wiki — so a post left `published` with a start date next Tuesday is in the blog
|
||||
* today, dated next Tuesday. `scheduled` is what holds one back.
|
||||
*/
|
||||
publishedAt: Date
|
||||
updatedAt: Date
|
||||
authorId: string
|
||||
/** Who wrote the version that stands. Empty once that account is deleted. */
|
||||
authorName: string
|
||||
}
|
||||
|
||||
/** What the column beside a listing offers to narrow it by. */
|
||||
export interface BlogFacets {
|
||||
/** Every tag carried by a readable post, most used first. */
|
||||
tags: { tag: string; count: number }[]
|
||||
/** One entry per month that has posts, newest first. */
|
||||
archive: { year: number; month: number; count: number }[]
|
||||
}
|
||||
|
||||
/** A listing, as one request answers it. */
|
||||
export interface BlogListing {
|
||||
posts: BlogPost[]
|
||||
/** How many posts match the filters, ignoring which page of them was asked for. */
|
||||
total: number
|
||||
/** Which page of the listing this is, counted from 1. */
|
||||
page: number
|
||||
pageCount: number
|
||||
/**
|
||||
* The facets over every readable post of the blog, NOT over the filtered set — a tag cloud that
|
||||
* emptied itself as soon as a tag was picked would leave a reader with no way back out of the
|
||||
* filter they just applied.
|
||||
*/
|
||||
facets: BlogFacets
|
||||
/** Whether the blog holds more posts than one request will read. See `MAX_POSTS`. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** What a listing may be narrowed by, as a reader picks it out of the sidebar. */
|
||||
export interface BlogFilter {
|
||||
/** Only posts carrying this tag. */
|
||||
tag?: string | null
|
||||
/** Only posts published in this year, and in this month of it when one is given. */
|
||||
year?: number | null
|
||||
month?: number | null
|
||||
/** Which page of the listing to answer with, counted from 1. */
|
||||
page?: number
|
||||
}
|
||||
|
||||
/** One candidate row, before the page rules and the nesting rule have had their say. */
|
||||
interface Candidate extends BlogPost {
|
||||
editor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Blogs
|
||||
*
|
||||
* A blog is a page written with the `blog` editor plus the pages underneath it. That is the whole of
|
||||
* the data model: the front page's content column holds how the blog should look (`BlogContent`), and
|
||||
* WHICH pages are its posts is worked out here, at read time, from where they sit.
|
||||
*
|
||||
* Nothing is written on a post to say it is one. That is deliberate and it is what makes the feature
|
||||
* cost nothing to the rest of the wiki: a post is an ordinary page with ordinary history, ordinary
|
||||
* permissions and an ordinary place in the tree, and moving it out of the blog's path is how it stops
|
||||
* being a post. The tree already supports a page and a folder sharing a name — `/my-blog` is both the
|
||||
* front page and the way into `/my-blog/…`, the same way `/guide` is — so nothing had to change there
|
||||
* either.
|
||||
*
|
||||
* The cost of that choice is that the blog's membership is a fact about PATHS, and paths move. Moving
|
||||
* the front page away from its posts, or renaming the folder out from under it, leaves a blog with
|
||||
* nothing in it and no row anywhere recording that anything is wrong. `listing` reports the empty
|
||||
* case as its own answer rather than as a listing that happens to be empty, so the failure is at
|
||||
* least legible to whoever caused it.
|
||||
*/
|
||||
class Blogs {
|
||||
/**
|
||||
* The blog whose front page IS this path, or null where that page is not a blog.
|
||||
*
|
||||
* One lookup on `(siteId, locale, path)`, which is a unique index.
|
||||
*/
|
||||
async blogAt(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: pagesTable.id,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
locale: pagesTable.locale,
|
||||
content: pagesTable.content,
|
||||
editor: pagesTable.editor
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.locale, locale),
|
||||
eq(pagesTable.path, path),
|
||||
eq(pagesTable.editor, 'blog')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
return row ? { ...row, settings: parseBlogContent(row.content) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The blog a page belongs to, or null for a page that is not in one.
|
||||
*
|
||||
* Asked of every page view, so it is one indexed read and not a scan: the candidates are the page's
|
||||
* own ancestors, which is a handful of paths a path already names, so the lookup is an `IN` over the
|
||||
* same unique index `blogAt` uses. A page at the site root has no ancestors and costs no query at
|
||||
* all.
|
||||
*
|
||||
* The NEAREST ancestor wins. A blog inside a blog is its own blog — its posts belong to it and not
|
||||
* to the one above, which is also what `postsFor` implements coming the other way.
|
||||
*
|
||||
* The page itself is never the answer: a blog's front page is not one of its own posts.
|
||||
*/
|
||||
async blogFor(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
|
||||
const parts = path.split('/').filter((part) => part.length > 0)
|
||||
if (parts.length < 2) {
|
||||
return null
|
||||
}
|
||||
const ancestors: string[] = []
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
ancestors.push(parts.slice(0, i).join('/'))
|
||||
}
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: pagesTable.id,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
locale: pagesTable.locale,
|
||||
content: pagesTable.content
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.locale, locale),
|
||||
eq(pagesTable.editor, 'blog'),
|
||||
inArray(pagesTable.path, ancestors)
|
||||
)
|
||||
)
|
||||
if (rows.length < 1) {
|
||||
return null
|
||||
}
|
||||
// -> The nearest one, which is the longest path: every row here is an ancestor of the same page
|
||||
const nearest = rows.reduce((best, row) => (row.path.length > best.path.length ? row : best))
|
||||
return { ...nearest, settings: parseBlogContent(nearest.content) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the folder a blog's posts will go in exists.
|
||||
*
|
||||
* A blog's front page is a page at `my-blog`, and its posts are pages at `my-blog/…` — which means
|
||||
* the folder `my-blog` is where an author is about to be working, and until something is saved
|
||||
* under it the folder is not there at all. The tree creates folders from the middle out as pages
|
||||
* arrive, so nothing is BROKEN without this: the folder appears the moment the first post is saved.
|
||||
* What is missing is somewhere to save that first post FROM — the file manager and the tree browser
|
||||
* have no folder to open, so the blog looks like a dead end until somebody types the path by hand.
|
||||
*
|
||||
* A page and a folder of the same name sit side by side quite happily — that is how `/guide` gets
|
||||
* to be both a page and the way into `/guide/…` — so this adds a folder rather than changing
|
||||
* anything about the page.
|
||||
*
|
||||
* **It never fails the caller.** The folder is a convenience and the blog is complete without it,
|
||||
* so the two ways this can legitimately not work are logged and stepped over rather than raised:
|
||||
* a folder name is held to `[a-z0-9-]` while a page path may also carry underscores (a blog at
|
||||
* `my_blog` has no legal folder name), and an ASSET already sitting at that name blocks a folder
|
||||
* where it would not have blocked the page.
|
||||
*
|
||||
* @returns Whether a folder was created. False when one was already there, and when one could not be.
|
||||
*/
|
||||
async ensureFolder({
|
||||
siteId,
|
||||
locale,
|
||||
path,
|
||||
title
|
||||
}: {
|
||||
siteId: string
|
||||
locale: string
|
||||
path: string
|
||||
title: string
|
||||
}): Promise<boolean> {
|
||||
const encoded = encodeTreePath(path)
|
||||
const parts = encoded.split('.')
|
||||
const fileName = parts.at(-1)!
|
||||
const folderPath = parts.slice(0, -1).join('.')
|
||||
|
||||
/*
|
||||
Asked directly rather than through `tree.getFolder({ createIfMissing: true })`, which would find
|
||||
or create in one call: that one titles what it creates after the path segment, and this folder
|
||||
IS the blog — `Engineering Notes` is what somebody making it by hand would have called it, and
|
||||
the title is what the file manager and the browse menu show.
|
||||
*/
|
||||
const existing = await WIKI.db
|
||||
.select({ id: treeTable.id })
|
||||
.from(treeTable)
|
||||
.where(
|
||||
and(
|
||||
eq(treeTable.siteId, siteId),
|
||||
eq(treeTable.locale, locale),
|
||||
eq(treeTable.folderPath, folderPath),
|
||||
eq(treeTable.fileName, fileName),
|
||||
eq(treeTable.type, 'folder')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (existing.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await WIKI.models.tree.createFolder({
|
||||
parentPath: decodeTreePath(folderPath) ?? '',
|
||||
pathName: fileName,
|
||||
title,
|
||||
locale,
|
||||
siteId
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(
|
||||
`Could not create the folder for the blog at /${path}: ${err.message}. Posts saved under it will create it.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every post of a blog this reader may open, newest or oldest first as the blog is set up.
|
||||
*
|
||||
* Three things are dropped on the way, and each for its own reason:
|
||||
*
|
||||
* - **A page with no body of its own** — a redirection, or the front page of a blog nested inside
|
||||
* this one. A doorway is not a post, and neither is another blog.
|
||||
* - **A page under a nested blog.** Its own blog lists it; this one stops where that one starts, so
|
||||
* a post never appears in two listings under two different bylines.
|
||||
* - **A page the reader may not open**, by the same rule the page view applies. Filtered here rather
|
||||
* than in SQL because a page rule is not a `WHERE` clause.
|
||||
*
|
||||
* @param publicOnly Restrict to what a reader with no session may see, i.e. published posts only.
|
||||
*/
|
||||
async postsFor({
|
||||
siteId,
|
||||
blog,
|
||||
actor,
|
||||
publicOnly = true
|
||||
}: {
|
||||
siteId: string
|
||||
blog: BlogRef
|
||||
actor?: AccessActor
|
||||
publicOnly?: boolean
|
||||
}): Promise<{ posts: BlogPost[]; truncated: boolean }> {
|
||||
const encodedPath = encodeTreePath(blog.path)
|
||||
const depth = blog.settings.depth
|
||||
const levels = depth > 0 ? `*{,${depth}}` : '*{0}'
|
||||
const pathQuery = encodedPath ? `${encodedPath}.${levels}` : levels
|
||||
|
||||
/*
|
||||
`publishStartDate` when the author gave one and the creation date otherwise, which is the one
|
||||
date a blog orders by and shows. Computed in SQL so the order it produces is the order the rows
|
||||
arrive in, and `MAX_POSTS` therefore cuts the far end of the blog rather than an arbitrary set.
|
||||
|
||||
`mapWith` and not a bare `sql<Date>`: that generic is an assertion and nothing more, so without
|
||||
a mapper the driver hands back postgres's own `2026-08-22 09:00:00` and every caller downstream
|
||||
gets a string where the column beside it is a `Date`. Mapped through `updatedAt`, which is the
|
||||
same `timestamp` type, so both dates on a post are the same kind of value.
|
||||
*/
|
||||
const publishedAt =
|
||||
sql<Date>`coalesce(${pagesTable.publishStartDate}, ${pagesTable.createdAt})`.mapWith(
|
||||
pagesTable.updatedAt
|
||||
)
|
||||
const conditions: (SQL | undefined)[] = [
|
||||
eq(treeTable.siteId, siteId),
|
||||
eq(treeTable.locale, blog.locale),
|
||||
eq(treeTable.type, 'page'),
|
||||
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`
|
||||
]
|
||||
if (publicOnly) {
|
||||
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: treeTable.id,
|
||||
folderPath: treeTable.folderPath,
|
||||
fileName: treeTable.fileName,
|
||||
title: treeTable.title,
|
||||
tags: treeTable.tags,
|
||||
description: pagesTable.description,
|
||||
icon: pagesTable.icon,
|
||||
editor: pagesTable.editor,
|
||||
updatedAt: pagesTable.updatedAt,
|
||||
authorId: pagesTable.authorId,
|
||||
authorName: usersTable.name,
|
||||
publishedAt
|
||||
})
|
||||
.from(treeTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
|
||||
.leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(blog.settings.sort === 'oldest' ? sql`${publishedAt} asc` : sql`${publishedAt} desc`)
|
||||
.limit(MAX_POSTS + 1)
|
||||
|
||||
const truncated = rows.length > MAX_POSTS
|
||||
const candidates: Candidate[] = rows.slice(0, MAX_POSTS).map((row) => {
|
||||
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
|
||||
return {
|
||||
id: row.id,
|
||||
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
|
||||
title: row.title,
|
||||
description: row.description ?? '',
|
||||
icon: row.icon ?? '',
|
||||
tags: row.tags ?? [],
|
||||
editor: row.editor,
|
||||
publishedAt: row.publishedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
authorId: row.authorId,
|
||||
authorName: row.authorName ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
Where this blog stops. A blog nested inside this one owns everything below it, so the outer
|
||||
listing ends at its front page — otherwise the same post appears in two blogs, dated and
|
||||
attributed by two sets of settings that need not agree.
|
||||
*/
|
||||
const nestedRoots = candidates
|
||||
.filter((row) => row.editor === 'blog')
|
||||
.map((row) => `${row.path}/`)
|
||||
|
||||
const posts = candidates.filter((row) => {
|
||||
if (isBodylessEditor(row.editor)) {
|
||||
return false
|
||||
}
|
||||
if (nestedRoots.some((root) => row.path.startsWith(root))) {
|
||||
return false
|
||||
}
|
||||
if (!actor) {
|
||||
return true
|
||||
}
|
||||
return WIKI.models.groups.checkAccess(actor, 'read:pages', {
|
||||
siteId,
|
||||
path: row.path,
|
||||
locale: blog.locale,
|
||||
tags: row.tags
|
||||
})
|
||||
})
|
||||
|
||||
// -> Which editor wrote a post is how it was filtered, not something a listing shows
|
||||
return {
|
||||
posts: posts.map((post) => ({
|
||||
id: post.id,
|
||||
path: post.path,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
icon: post.icon,
|
||||
tags: post.tags,
|
||||
publishedAt: post.publishedAt,
|
||||
updatedAt: post.updatedAt,
|
||||
authorId: post.authorId,
|
||||
authorName: post.authorName
|
||||
})),
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count what the sidebar offers to narrow a listing by.
|
||||
*
|
||||
* Over the posts as they were handed in, which is to say over the posts this reader may open: a
|
||||
* count is a statement about how many pages exist, and one that included pages they were never
|
||||
* shown would be answering a question they did not ask about content they cannot reach.
|
||||
*/
|
||||
facetsFor(posts: BlogPost[]): BlogFacets {
|
||||
const tagCounts = new Map<string, number>()
|
||||
const monthCounts = new Map<string, number>()
|
||||
for (const post of posts) {
|
||||
for (const tag of post.tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
|
||||
}
|
||||
const at = new Date(post.publishedAt)
|
||||
// -> UTC, because the stored instant is: a blog's archive must not put a post in a different
|
||||
// month for a reader in a different place, and the server's own zone is nobody's
|
||||
const key = `${at.getUTCFullYear()}-${at.getUTCMonth() + 1}`
|
||||
monthCounts.set(key, (monthCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return {
|
||||
tags: [...tagCounts.entries()]
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)),
|
||||
archive: [...monthCounts.entries()]
|
||||
.map(([key, count]) => {
|
||||
const [year, month] = key.split('-')
|
||||
return { year: Number(year), month: Number(month), count }
|
||||
})
|
||||
.sort((a, b) => b.year - a.year || b.month - a.month)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a blog's listing, with the facets beside it.
|
||||
*
|
||||
* The whole answer to one request: the posts to draw, where the reader is in the blog, and what
|
||||
* the sidebar should offer. Read once and narrowed in memory — see `MAX_POSTS` for why the filters
|
||||
* are not a `WHERE` clause.
|
||||
*/
|
||||
async listing({
|
||||
siteId,
|
||||
blog,
|
||||
actor,
|
||||
publicOnly = true,
|
||||
filter = {}
|
||||
}: {
|
||||
siteId: string
|
||||
blog: BlogRef
|
||||
actor?: AccessActor
|
||||
publicOnly?: boolean
|
||||
filter?: BlogFilter
|
||||
}): Promise<BlogListing> {
|
||||
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly })
|
||||
const facets = this.facetsFor(readable)
|
||||
|
||||
const matching = readable.filter((post) => {
|
||||
if (filter.tag && !post.tags.includes(filter.tag)) {
|
||||
return false
|
||||
}
|
||||
if (filter.year) {
|
||||
const at = new Date(post.publishedAt)
|
||||
if (at.getUTCFullYear() !== filter.year) {
|
||||
return false
|
||||
}
|
||||
if (filter.month && at.getUTCMonth() + 1 !== filter.month) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const perPage = blog.settings.perPage
|
||||
const pageCount = Math.max(1, Math.ceil(matching.length / perPage))
|
||||
// -> Clamped rather than answered empty: a `?p=99` typed into the bar, or left over from a filter
|
||||
// that used to have more pages, should land on the last page of the blog and not on nothing
|
||||
const page = Math.min(Math.max(1, Math.trunc(filter.page ?? 1)), pageCount)
|
||||
|
||||
return {
|
||||
posts: matching.slice((page - 1) * perPage, page * perPage),
|
||||
total: matching.length,
|
||||
page,
|
||||
pageCount,
|
||||
facets,
|
||||
truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const blogs = new Blogs()
|
||||
@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div class="editor-blog">
|
||||
<w-scroll-area style="height: 100%">
|
||||
<div class="editor-blog-form">
|
||||
<!-- ----------------------- -->
|
||||
<!-- The blog itself -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2">
|
||||
<w-card-header>{{ t('editor.blog.title') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="new-document" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.pageTitle') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.pageTitleHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<!--
|
||||
The same title the header edits in place, so the two are one field with two places to
|
||||
type it: both write to the store, and the header's watcher follows what is typed here.
|
||||
-->
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:model-value="pageStore.title"
|
||||
:aria-label="t(`editor.blog.pageTitle`)"
|
||||
@update:model-value="setTitle" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="subtitles" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.pageDescription') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.pageDescriptionHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<!--
|
||||
The page's own description, not a blog setting -- the same field the properties panel
|
||||
calls Short Description, offered here because it is what a reader sees under the
|
||||
blog's name and an author on this screen should not have to go looking for it.
|
||||
-->
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:model-value="pageStore.description"
|
||||
:aria-label="t(`editor.blog.pageDescription`)"
|
||||
@update:model-value="setDescription" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="quote-left" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.intro') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.introHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:model-value="state.intro"
|
||||
:aria-label="t(`editor.blog.intro`)"
|
||||
@update:model-value="setIntro" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- Which pages are posts -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="mt-4 pb-2">
|
||||
<w-card-header>{{ t('editor.blog.postsTitle') }}</w-card-header>
|
||||
<!--
|
||||
The one rule of the whole feature, said out loud where the author is deciding where to
|
||||
save the page: what is under this path is a post. Nothing is written on a post to mark it
|
||||
as one, so this sentence is the entire contract.
|
||||
-->
|
||||
<div class="editor-blog-note">
|
||||
<w-icon name="la:info-circle" />
|
||||
<div class="pl-3">{{ t('editor.blog.postsHint', { path: postsPath }) }}</div>
|
||||
</div>
|
||||
<w-item>
|
||||
<blueprint-icon icon="depth" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.depth') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.depthHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-input
|
||||
class="editor-blog-number"
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="BLOG_MAX_DEPTH"
|
||||
:model-value="state.depth"
|
||||
:aria-label="t(`editor.blog.depth`)"
|
||||
@update:model-value="setDepth"
|
||||
@blur="settleDepth" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="sort-by-follow-up-date" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.sort') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.sortHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-select
|
||||
class="editor-blog-select"
|
||||
outlined
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
:options="sortOptions"
|
||||
:model-value="state.sort"
|
||||
:aria-label="t(`editor.blog.sort`)"
|
||||
@update:model-value="setSort" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- How the listing looks -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="mt-4 pb-2">
|
||||
<w-card-header>{{ t('editor.blog.listingTitle') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="index" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.layout') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.layoutHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-select
|
||||
class="editor-blog-select"
|
||||
outlined
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
:options="layoutOptions"
|
||||
:model-value="state.layout"
|
||||
:aria-label="t(`editor.blog.layout`)"
|
||||
@update:model-value="setLayout" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="list" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.perPage') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.perPageHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-input
|
||||
class="editor-blog-number"
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="BLOG_MAX_PER_PAGE"
|
||||
:model-value="state.perPage"
|
||||
:aria-label="t(`editor.blog.perPage`)"
|
||||
@update:model-value="setPerPage"
|
||||
@blur="settlePerPage" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<!--
|
||||
What a post entry carries, as one row of switches rather than five rows of their own: they
|
||||
are one decision made five times, and a row apiece would be most of this screen.
|
||||
-->
|
||||
<w-item>
|
||||
<blueprint-icon icon="tune" top />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.showFields') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.showFieldsHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<div class="editor-blog-checks">
|
||||
<w-checkbox
|
||||
v-for="field of SHOW_FIELDS"
|
||||
:key="field"
|
||||
:label="t(`editor.blog.show.${field}`)"
|
||||
:model-value="state.show[field]"
|
||||
@update:model-value="setShow(field, $event)" />
|
||||
</div>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- What the sidebar offers -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="mt-4 pb-2">
|
||||
<w-card-header>{{ t('editor.blog.sidebarTitle') }}</w-card-header>
|
||||
<w-item>
|
||||
<blueprint-icon icon="filtration" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.sidebarTags') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.sidebarTagsHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="state.sidebar.tags"
|
||||
:aria-label="t(`editor.blog.sidebarTags`)"
|
||||
@update:model-value="setSidebar('tags', $event)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="calendar" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t('editor.blog.sidebarArchive') }}</w-item-label>
|
||||
<w-item-label caption>{{ t('editor.blog.sidebarArchiveHint') }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="state.sidebar.archive"
|
||||
:aria-label="t(`editor.blog.sidebarArchive`)"
|
||||
@update:model-value="setSidebar('archive', $event)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
BLOG_LAYOUTS,
|
||||
BLOG_MAX_DEPTH,
|
||||
BLOG_MAX_INTRO,
|
||||
BLOG_MAX_PER_PAGE,
|
||||
BLOG_SORTS,
|
||||
parseBlog,
|
||||
serializeBlog
|
||||
} from '@/helpers/pageBlog'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
|
||||
/**
|
||||
* As long a description as the page's column will take. Mirrors the `maxLength` the API schema puts
|
||||
* on it (`api/schemas/page.ts`), which is what makes it a ceiling rather than a preference.
|
||||
*/
|
||||
const PAGE_DESCRIPTION_MAX = 255
|
||||
|
||||
/**
|
||||
* The `blog` editor: the front page of a blog.
|
||||
*
|
||||
* There is no content to write, so this is a form rather than an editor — a title, a description, an
|
||||
* introduction, and how the listing beneath it should behave. All of it except the title and the
|
||||
* description is the page's content, as JSON; those two are columns on the page itself and are
|
||||
* written straight to the store, the same ones the properties panel edits. See `helpers/pageBlog.js`,
|
||||
* and `models/blogs.ts` on the server for what a POST is: nothing here names one, because a post is a
|
||||
* post by sitting under this page's path.
|
||||
*
|
||||
* What the page then DOES with that is `PageBlog.vue`, which the page view draws in place of an
|
||||
* article.
|
||||
*/
|
||||
|
||||
/** The fields of a post whose display is a switch, in the order they are offered. */
|
||||
const SHOW_FIELDS = ['icon', 'description', 'author', 'date', 'tags']
|
||||
|
||||
// STORES
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const pageStore = usePageStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
/**
|
||||
* The blog being edited, which is also exactly what gets saved — every field of the form is one of
|
||||
* these. Seeded from the stored content and written back by the watcher below.
|
||||
*/
|
||||
const state = reactive(parseBlog(pageStore.content))
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const layoutOptions = computed(() =>
|
||||
BLOG_LAYOUTS.map((value) => ({ value, label: t(`editor.blog.layouts.${value}`) }))
|
||||
)
|
||||
|
||||
const sortOptions = computed(() =>
|
||||
BLOG_SORTS.map((value) => ({ value, label: t(`editor.blog.sorts.${value}`) }))
|
||||
)
|
||||
|
||||
/**
|
||||
* The path posts go under, as the sentence above the settings names it.
|
||||
*
|
||||
* Read off the page being written rather than off a saved blog, so that moving the page in the path
|
||||
* field moves the sentence with it — this is the one rule of the feature and it has to describe where
|
||||
* the author is actually about to save.
|
||||
*/
|
||||
const postsPath = computed(() => `/${pageStore.path}/`)
|
||||
|
||||
// WATCHERS
|
||||
|
||||
/*
|
||||
The form IS the content, so the store follows it on every keystroke — there is nothing here that a
|
||||
save would collect afterwards.
|
||||
|
||||
Immediate, and deliberately not a change: this also writes the canonical spelling of what was
|
||||
already stored, and seeds a page being created with an empty blog. Neither is an edit, so neither
|
||||
may set the unsaved-changes flag — `touch` is called by the handlers instead, where a person
|
||||
actually did something.
|
||||
*/
|
||||
watch(
|
||||
state,
|
||||
(value) => {
|
||||
pageStore.content = serializeBlog(value)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/** Say that the page has unsaved changes, which is what turns the header's Save button on. */
|
||||
function touch() {
|
||||
editorStore.lastChangeTimestamp = Temporal.Now.instant()
|
||||
}
|
||||
|
||||
function setTitle(title) {
|
||||
pageStore.title = title
|
||||
touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Held to the column's own 255 characters, in the form rather than at the save — the same reason
|
||||
* {@link setIntro} holds the introduction to its ceiling: a limit the field does not enforce is a
|
||||
* save refused after the typing instead of during it.
|
||||
*/
|
||||
function setDescription(description) {
|
||||
pageStore.description = (description ?? '').slice(0, PAGE_DESCRIPTION_MAX)
|
||||
touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Held to the same ceiling the server holds it to, here rather than with a `maxlength` attribute: the
|
||||
* field is a `w-input`, which passes on the props it declares and nothing else, and a limit the form
|
||||
* does not enforce would be a save refused after the typing rather than during it.
|
||||
*/
|
||||
function setIntro(intro) {
|
||||
state.intro = (intro ?? '').slice(0, BLOG_MAX_INTRO)
|
||||
touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* A number field, as it is being typed.
|
||||
*
|
||||
* Two things a field like this gets wrong if it is written naively, and both of them here:
|
||||
*
|
||||
* - **An emptied field hands back an empty string**, which `Number.parseInt` reads as `NaN`. Left
|
||||
* alone until it parses again, so that clearing the field in order to type a new number does not
|
||||
* reset it to the default under the typist.
|
||||
* - **The CEILING is not enforced while typing.** Editing `10` into `500` passes through `50` and
|
||||
* then `500`, and a handler that clamped each keystroke would write `100` the moment the third
|
||||
* digit landed — after which every further keystroke appends to `100` and clamps back to it, so
|
||||
* the field can never be typed down again without being cleared first. The floor has no such
|
||||
* problem (nothing types its way up through a number that is too small), so it is applied here and
|
||||
* the ceiling waits for `settleNumber`.
|
||||
*/
|
||||
function setNumber(key, value, min) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return
|
||||
}
|
||||
state[key] = Math.max(parsed, min)
|
||||
touch()
|
||||
}
|
||||
|
||||
/**
|
||||
* The same field, once the typist has left it.
|
||||
*
|
||||
* Where the ceiling is applied, so that what is on screen at rest is what will be saved — the server
|
||||
* clamps to the same figure (`normalizeBlogContent`), and a field that showed 500 while the page
|
||||
* stored 100 would be the form disagreeing with itself.
|
||||
*/
|
||||
function settleNumber(key, max) {
|
||||
if (state[key] > max) {
|
||||
state[key] = max
|
||||
}
|
||||
}
|
||||
|
||||
function setDepth(value) {
|
||||
setNumber('depth', value, 0)
|
||||
}
|
||||
|
||||
function settleDepth() {
|
||||
settleNumber('depth', BLOG_MAX_DEPTH)
|
||||
}
|
||||
|
||||
function setPerPage(value) {
|
||||
setNumber('perPage', value, 1)
|
||||
}
|
||||
|
||||
function settlePerPage() {
|
||||
settleNumber('perPage', BLOG_MAX_PER_PAGE)
|
||||
}
|
||||
|
||||
function setLayout(layout) {
|
||||
state.layout = layout
|
||||
touch()
|
||||
}
|
||||
|
||||
function setSort(sort) {
|
||||
state.sort = sort
|
||||
touch()
|
||||
}
|
||||
|
||||
function setShow(field, value) {
|
||||
state.show[field] = value
|
||||
touch()
|
||||
}
|
||||
|
||||
function setSidebar(field, value) {
|
||||
state.sidebar[field] = value
|
||||
touch()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.editor-blog {
|
||||
height: 100%;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-3;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-6;
|
||||
}
|
||||
|
||||
/* -> A form, not a document: it stops widening well before the column does */
|
||||
&-form {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
/* -> Wide enough for the longest option and no wider; these sit in a `side` section */
|
||||
&-select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&-number {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
/*
|
||||
Lined up with the main section of the row above it: `w-item` pads 16px and its avatar section is
|
||||
56px wide, so the switches start where that row's label does.
|
||||
*/
|
||||
&-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 20px;
|
||||
padding: 0 16px 8px 72px;
|
||||
}
|
||||
|
||||
/* -> The one rule of the feature, stated where the author is deciding where to save the page */
|
||||
&-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 0 16px 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(25, 118, 210, 0.1);
|
||||
color: $blue-9;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
|
||||
@at-root .body--dark & {
|
||||
color: $blue-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,787 @@
|
||||
<template>
|
||||
<div class="page-blog">
|
||||
<!--
|
||||
The blog's own words, above the listing. Plain text, deliberately: the front page has no
|
||||
renderer and no editor for one -- see `helpers/pageBlog.js` -- so `white-space: pre-line` is
|
||||
what gives the author their paragraph breaks back without any of the rest of it.
|
||||
-->
|
||||
<p class="page-blog-intro" v-if="settings.intro">{{ settings.intro }}</p>
|
||||
<!--
|
||||
What the reader has narrowed the blog to, and the way back out of it. Only drawn once something
|
||||
is selected: an empty filter bar over an unfiltered blog is a control that does nothing.
|
||||
-->
|
||||
<div class="page-blog-filters" v-if="activeFilter">
|
||||
<w-icon name="la:filter" size="sm" />
|
||||
<span class="pl-2">{{ activeFilter }}</span>
|
||||
<w-btn
|
||||
class="ml-2"
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
no-caps
|
||||
icon="la:times"
|
||||
color="primary"
|
||||
:label="t(`common.blog.clearFilter`)"
|
||||
@click="clearFilter" />
|
||||
</div>
|
||||
<div class="page-blog-status" v-if="blogState.loading && !blogState.loaded">
|
||||
{{ t('common.blog.loading') }}
|
||||
</div>
|
||||
<div class="page-blog-status" v-else-if="blogState.failed">
|
||||
{{ t('common.blog.loadFailed') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!--
|
||||
An empty blog is reported as an empty blog, and an empty FILTER as an empty filter: they are
|
||||
different situations and only one of them is something the reader did. The first is also where
|
||||
a blog whose posts have been moved out from under it ends up -- there is no row anywhere
|
||||
recording that, so naming the path is the only thing that makes it legible.
|
||||
-->
|
||||
<div class="page-blog-empty" v-if="blogState.posts.length < 1">
|
||||
<w-icon class="page-blog-empty-icon" name="la:newspaper" />
|
||||
<template v-if="activeFilter">
|
||||
<div class="text-h6">{{ t('common.blog.noMatches') }}</div>
|
||||
<w-btn
|
||||
class="mt-4"
|
||||
outline
|
||||
no-caps
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:label="t(`common.blog.clearFilter`)"
|
||||
@click="clearFilter" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-h6">{{ t('common.blog.empty') }}</div>
|
||||
<div class="text-body2 mt-1 opacity-60">
|
||||
{{ t('common.blog.emptyHint', { path: `/${pageStore.path}/` }) }}
|
||||
</div>
|
||||
<!--
|
||||
The way out of an empty blog, for whoever can take it. Under the sentence that says what
|
||||
a post IS, because it is that sentence acted on: the menu writes the new page under this
|
||||
blog's path, which is the whole of what makes it a post.
|
||||
|
||||
Only under the empty BLOG. The filtered branch above is empty because of something the
|
||||
reader did, and the answer there is to undo it rather than to write a post.
|
||||
-->
|
||||
<w-btn
|
||||
class="mt-6"
|
||||
v-if="canWriteHere"
|
||||
unelevated
|
||||
no-caps
|
||||
icon="la:plus"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:label="t(`common.blog.newPost`)">
|
||||
<page-new-menu hide-asset-btn :only="POST_EDITORS" :base-path="pageStore.path" />
|
||||
</w-btn>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="page-blog-list" :class="`is-` + settings.layout">
|
||||
<router-link
|
||||
class="page-blog-post"
|
||||
v-for="(post, index) of blogState.posts"
|
||||
:key="post.id"
|
||||
:to="postLink(post)">
|
||||
<!--
|
||||
The card's lid: a band of brand colour across the top of it, holding the post's icon.
|
||||
One element for both layouts rather than two -- in `list` it is `display: contents` and
|
||||
leaves the layout entirely, so the icon is the flex item beside the text it always was.
|
||||
|
||||
It goes with the icon rather than standing on its own, since a band whose only content
|
||||
is switched off is a stripe of colour saying nothing.
|
||||
-->
|
||||
<span class="page-blog-post-media" v-if="settings.show.icon">
|
||||
<!-- -> The size is a prop rather than a class: `WIcon` writes it as an inline style,
|
||||
which beats anything a rule here could say -->
|
||||
<w-icon
|
||||
class="page-blog-post-icon"
|
||||
:size="iconSizeFor(index)"
|
||||
:name="post.icon || defaultPageIcon" />
|
||||
</span>
|
||||
<span class="page-blog-post-body">
|
||||
<span class="page-blog-post-title">{{ post.title }}</span>
|
||||
<span
|
||||
class="page-blog-post-desc"
|
||||
v-if="settings.show.description && post.description">
|
||||
{{ post.description }}
|
||||
</span>
|
||||
<!--
|
||||
Who wrote it and when, on one line under the text: they are the byline, and two lines
|
||||
for two short facts is most of a card spent on its footer. Separated only where both
|
||||
are on, so a blog showing one of them has no orphan bullet.
|
||||
-->
|
||||
<span class="page-blog-post-meta" v-if="settings.show.date || settings.show.author">
|
||||
<span v-if="settings.show.date">{{ publishedOn(post) }}</span>
|
||||
<span class="page-blog-post-sep" v-if="settings.show.date && settings.show.author"
|
||||
>·</span
|
||||
>
|
||||
<span v-if="settings.show.author && post.authorName">{{ post.authorName }}</span>
|
||||
</span>
|
||||
<span class="page-blog-post-tags" v-if="settings.show.tags && post.tags.length > 0">
|
||||
<!--
|
||||
Plain text, not links: a tag here is inside the link to the post, and an anchor
|
||||
inside an anchor is invalid HTML that browsers unnest -- the filter is picked from
|
||||
the column beside the listing instead, where it can be a control of its own.
|
||||
-->
|
||||
<span class="page-blog-post-tag" v-for="tag of post.tags" :key="tag">{{
|
||||
tag
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<!--
|
||||
Only once there is more than one page of the blog. The count above it says what the reader
|
||||
is looking at, because a bare row of numbers does not say how many posts they stand for.
|
||||
-->
|
||||
<div class="page-blog-pager" v-if="blogState.pageCount > 1">
|
||||
<w-pagination
|
||||
:model-value="blogState.page"
|
||||
:max="blogState.pageCount"
|
||||
:max-pages="7"
|
||||
boundary-numbers
|
||||
direction-links
|
||||
:aria-label="t(`common.blog.pagination`)"
|
||||
@update:model-value="goToPage" />
|
||||
<div class="page-blog-count">
|
||||
{{ t('common.blog.count', blogState.total, { count: blogState.total }) }}
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
The one thing a listing cannot do quietly: a blog past the ceiling is served as a prefix of
|
||||
itself, and a reader paging to the end of it would otherwise be told they had reached the
|
||||
end of the blog.
|
||||
-->
|
||||
<div class="page-blog-truncated" v-if="blogState.truncated">
|
||||
<w-icon name="la:exclamation-triangle" />
|
||||
<div class="pl-3">{{ t('common.blog.truncated') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { blogState, loadBlog, resetBlog } from '@/composables/blog'
|
||||
|
||||
import { parseBlog } from '@/helpers/pageBlog'
|
||||
import { blogFilterFromQuery } from '@/helpers/blogFilter'
|
||||
|
||||
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import PageNewMenu from '@/components/PageNewMenu.vue'
|
||||
|
||||
/**
|
||||
* What a post may be written with, offered by the New Post button on an empty blog.
|
||||
*
|
||||
* The editors that author an ARTICLE, which is what a post is. Not `redirect` and not `blog` — both
|
||||
* write a page with no body, and `models/blogs.ts` does not count either as a post: a redirection is
|
||||
* a doorway and a nested blog is its own blog, so creating one here would add nothing to this
|
||||
* listing. Filtered against what the site has enabled, so this is a ceiling and not a list.
|
||||
*/
|
||||
const POST_EDITORS = ['markdown', 'visual']
|
||||
|
||||
/**
|
||||
* A blog's front page: its posts, rather than an article.
|
||||
*
|
||||
* Drawn by the page view in place of the content, the way `PageRedirect.vue` is — but inside the
|
||||
* scrolling column rather than instead of it, because a blog is a destination: a reader stays on it,
|
||||
* scrolls it and reaches the footer at the bottom of it.
|
||||
*
|
||||
* **Which pages are posts is not decided here and cannot be.** A post is a page under this page's
|
||||
* path, and which of those this reader may open is a page rule resolved on the server — so this
|
||||
* component draws what `api/blogs.ts` hands it and filters nothing. See `models/blogs.ts`.
|
||||
*
|
||||
* The reader's selection lives in the query string (`?tag=…&year=…&month=…&p=…`) and not in the path,
|
||||
* because the path namespace under a blog belongs to its posts: `/my-blog/2026/03` is a page somebody
|
||||
* may well have written. It also makes a filtered blog a link that can be handed to somebody, which
|
||||
* is what `/_tags?t=a,b` does for the same reason.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ROUTER
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const defaultPageIcon = DEFAULT_PAGE_ICON
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/** How this blog is set up, which is the front page's content. See `helpers/pageBlog.js`. */
|
||||
const settings = computed(() => parseBlog(pageStore.content))
|
||||
|
||||
/**
|
||||
* Whether this reader may write a post here.
|
||||
*
|
||||
* `write:pages` from the PAGE rules and not from the group-wide list, which is the same check the
|
||||
* missing-page screen makes and the same one the create endpoint will make — the group-wide list
|
||||
* answers "may write pages somewhere", which is how a button ends up leading to a 403.
|
||||
*
|
||||
* Asked at the blog's own path, since that is where this reader is standing and a post goes directly
|
||||
* under it. A rule written deeper could still refuse one particular path, and the editor's own save
|
||||
* is what answers that.
|
||||
*/
|
||||
const canWriteHere = computed(() => userStore.pagePermissions.includes('write:pages'))
|
||||
|
||||
/** The reader's selection, as the URL carries it. */
|
||||
const filter = computed(() => blogFilterFromQuery(route.query))
|
||||
|
||||
/**
|
||||
* What the blog is currently narrowed to, in words, or null for a blog showing everything.
|
||||
*
|
||||
* One line rather than a chip per part, because the parts are not separately removable: a month
|
||||
* without its year means nothing, so the way out is out of all of it at once.
|
||||
*/
|
||||
const activeFilter = computed(() => {
|
||||
const parts = []
|
||||
if (filter.value.tag) {
|
||||
parts.push(t('common.blog.filterTag', { tag: filter.value.tag }))
|
||||
}
|
||||
if (filter.value.year) {
|
||||
parts.push(
|
||||
filter.value.month
|
||||
? monthLabel(filter.value.year, filter.value.month)
|
||||
: String(filter.value.year)
|
||||
)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : null
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* How big a post's icon is drawn.
|
||||
*
|
||||
* A prop and not a rule, because `WIcon` writes the size as an inline `font-size` — the stylesheet
|
||||
* cannot reach it, so this is the only place the featured card's icon can be made bigger. Which
|
||||
* card is featured is the same answer the stylesheet gives: the first one of a `cards` listing. A
|
||||
* `list` listing has no featured row, and every other card keeps the one size.
|
||||
*
|
||||
* Note it does not follow the breakpoint the LAYOUT does. Under 600px the featured card puts its
|
||||
* panel back across the top, and the big icon goes with it into a taller band — an inline style has
|
||||
* no media query, and a band that says "this one is the lead" is the right answer there anyway.
|
||||
*/
|
||||
function iconSizeFor(index) {
|
||||
return index === 0 && settings.value.layout === 'cards' ? '56px' : '28px'
|
||||
}
|
||||
|
||||
/** Where a post lives, as a route on this site — locale prefix included where the site uses one. */
|
||||
function postLink(post) {
|
||||
return `${siteStore.localeUrlPrefix(pageStore.locale)}/${post.path}`
|
||||
}
|
||||
|
||||
/** A post's publication date, as a reader reads a date rather than as the column stores one. */
|
||||
function publishedOn(post) {
|
||||
return Temporal.Instant.from(post.publishedAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A month, named. Built from a date at noon UTC rather than midnight: the archive counts months in
|
||||
* UTC (see `facetsFor` on the server), and midnight on the first falls into the previous month for
|
||||
* every reader west of Greenwich.
|
||||
*/
|
||||
function monthLabel(year, month) {
|
||||
return new Date(Date.UTC(year, month - 1, 15)).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
}
|
||||
|
||||
/** Drop every narrowing, which also takes the reader back to the first page of the blog. */
|
||||
function clearFilter() {
|
||||
router.push({ path: route.path })
|
||||
}
|
||||
|
||||
/** Move within the current filter, so paging never silently widens what is being paged through. */
|
||||
function goToPage(page) {
|
||||
const query = { ...route.query }
|
||||
if (page > 1) {
|
||||
query.p = String(page)
|
||||
} else {
|
||||
delete query.p
|
||||
}
|
||||
router.push({ path: route.path, query })
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (!pageStore.path && pageStore.path !== '') {
|
||||
return
|
||||
}
|
||||
loadBlog({
|
||||
siteId: siteStore.id,
|
||||
path: pageStore.path,
|
||||
locale: pageStore.locale,
|
||||
filter: filter.value
|
||||
})
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
|
||||
/*
|
||||
Both halves of "which listing is this": the page, because a reader can walk from one blog to another
|
||||
without this component being torn down, and the filter, because narrowing a blog is a navigation
|
||||
that leaves the component in place.
|
||||
*/
|
||||
watch(
|
||||
() => [pageStore.id, route.query.tag, route.query.year, route.query.month, route.query.p],
|
||||
load
|
||||
)
|
||||
|
||||
/*
|
||||
Emptied on the way out rather than left standing. The listing is a module singleton shared with the
|
||||
sidebar, so a stale one would be a tag cloud drawn beside the next page the reader opens.
|
||||
*/
|
||||
onBeforeUnmount(resetBlog)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.page-blog {
|
||||
/*
|
||||
Centred in the column rather than filling it. This is a listing and not prose: stretched across a
|
||||
full-width window `is-cards` reaches four columns and `is-list` draws a one-line title on a band
|
||||
the width of the screen. The cap is set so that a wide screen still gets the three columns the
|
||||
360px floor was chosen for, and no more.
|
||||
|
||||
On the block rather than on the listing, so the intro above it and the pager below sit over the
|
||||
same measure -- a paragraph starting at the column's edge over a centred grid is the half-centred
|
||||
layout this is avoiding. The sidebar, where a blog asks for one, is outside this element and is
|
||||
unaffected.
|
||||
*/
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
/*
|
||||
Stated per theme, for the same reason `.page-placeholder` states it: this block sits BESIDE
|
||||
`.page-contents` rather than inside it, so none of the ink `_page-contents.scss` declares reaches
|
||||
it and it would inherit the document's black and go invisible on the dark surface. Everything
|
||||
below that carries no colour of its own -- the empty state and its icon, the filter bar, the
|
||||
status line, the count under the pager -- reads off this one.
|
||||
*/
|
||||
@at-root .body--light & {
|
||||
color: $grey-9;
|
||||
|
||||
/*
|
||||
The hairline closing a card's gradient panel, which an ordinary card draws along its bottom and
|
||||
the featured card down its right. One property rather than the pair written twice per edge: the
|
||||
two edges are one line, and a value that has to agree in four places is a value that eventually
|
||||
will not.
|
||||
|
||||
Light: white, a highlight along the edge of the gradient.
|
||||
*/
|
||||
--blog-lid-seam: #{'#fff'};
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: #fff;
|
||||
|
||||
/*
|
||||
Dark: the page's own background behind the card -- `$dark-6`, set on `body.body--dark` in
|
||||
`MainLayout.vue`, since nothing between the body and the article column paints over it. So the
|
||||
line reads as a sliver of the page showing through rather than as a border drawn on the card,
|
||||
which is what white was doing there: a bright rule across a dark card, and the first thing the
|
||||
eye landed on in the whole listing.
|
||||
*/
|
||||
--blog-lid-seam: #{$dark-6};
|
||||
}
|
||||
|
||||
/* -> The author's own words, in the article's voice rather than in the listing's */
|
||||
&-intro {
|
||||
margin: 0 0 24px;
|
||||
white-space: pre-line;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
|
||||
@at-root .body--light & {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
&-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-2;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
}
|
||||
}
|
||||
|
||||
&-status {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&-empty {
|
||||
padding: 56px 16px;
|
||||
text-align: center;
|
||||
|
||||
&-icon {
|
||||
font-size: 64px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* The three layouts. Same markup, three ways of arranging it -- the */
|
||||
/* post is a link with an icon and a stack of text in every one. */
|
||||
/* ---------------------------------------------------------------- */
|
||||
&-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
/*
|
||||
One column per row otherwise, which is `list` and needs no rule of its own.
|
||||
|
||||
The floor is what decides how many columns fit, and it is deliberately high: a post entry is a
|
||||
title, a blurb and a byline, all of which read badly in a narrow column, and `auto-fill` will
|
||||
otherwise put four of them across a wide screen. At this width a normal window gets two and a
|
||||
very wide one three, and the cards stay wide enough for a description to be worth showing.
|
||||
*/
|
||||
&.is-cards {
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&-post {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
&:hover {
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-2;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A card stacks its lid over its text; a row puts icon and text side by side.
|
||||
|
||||
The padding moves off the card and onto the body, because the lid is drawn to the card's four
|
||||
edges and cannot be inset by it -- cancelling it with a negative margin instead would be the
|
||||
same figure written twice, in two places that have to agree. `overflow` is what then hands the
|
||||
lid the rounded corners it is sitting in.
|
||||
|
||||
`align-items` has to be restated: the base rule starts the icon at the TOP of a row, and the
|
||||
same property means the LEFT of a column -- so left alone it sizes the lid to the icon in it
|
||||
rather than to the card. Stretch is what a stacked card wants throughout: the lid spans it, and
|
||||
the text below fills it instead of being as wide as its longest line.
|
||||
*/
|
||||
@at-root .page-blog-list.is-cards & {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
|
||||
/*
|
||||
A card is lifted off the page; a list row is not. Two layers rather than one -- a tight
|
||||
near-black edge that reads as the card's own weight, and a wider soft one that is the light
|
||||
falling past it -- because a single blurred shadow at this opacity reads as a grey smudge
|
||||
under the card rather than as depth.
|
||||
|
||||
Deeper in the dark theme for the same apparent subtlety: shadow is black on both, and against
|
||||
`dark-4` the light theme's opacities are not a faint shadow but no shadow at all.
|
||||
*/
|
||||
@at-root .body--light & {
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.06),
|
||||
0 2px 6px rgb(0 0 0 / 0.05);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.3),
|
||||
0 2px 6px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
/*
|
||||
The first post, across every column: the featured card.
|
||||
|
||||
`1 / -1` counts to the last line of the EXPLICIT grid, which `auto-fill` still defines -- the
|
||||
track count comes from the template, so this is the whole row however many columns the window
|
||||
got. Narrow enough for one column it is already the full width and the rule changes nothing.
|
||||
|
||||
It is the first card of whatever is on screen rather than the first post of the blog, so page
|
||||
two of the listing leads with one too. That is the layout staying put as a reader pages
|
||||
through it: a grid whose top row is wide on one page and not on the next reads as something
|
||||
having gone wrong, and the card is a shape here rather than a claim about the post in it.
|
||||
*/
|
||||
&:first-child {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
/*
|
||||
And it lays its panel down the LEFT rather than across the top. A full-width card is wide
|
||||
enough that a band over it is a stripe with a lot of nothing under it; beside the text it
|
||||
is a panel, which is what the extra width bought.
|
||||
|
||||
Back to a band below `xs`, where a third of a phone-width card is about 110px of gradient
|
||||
and the text is left with 220px to hold a title, a blurb, a byline and its tags. The
|
||||
featured card keeps its full width there -- the grid is one column anyway -- and only the
|
||||
arrangement inside it goes back to the stacked one.
|
||||
*/
|
||||
@media (min-width: #{$breakpoint-xs-max + 0.02px}) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Further off the page under the pointer: the soft layer alone, pushed down and spread wide,
|
||||
which is what a shadow does as its caster rises.
|
||||
|
||||
The tight layer goes rather than growing with it. It is a contact shadow -- the dark seam
|
||||
where an object meets the surface it is resting on -- and a card that has lifted is no longer
|
||||
resting on anything, so kept underneath it reads as a hard line drawn under the card rather
|
||||
than as height.
|
||||
|
||||
White with it, up from `grey-1`: a card that has risen off the page catches more light, so it
|
||||
brightens as it lifts. That is the light theme's answer and not the dark theme's -- white on
|
||||
`dark-4` is not a lift but an inversion, and the card's own ink is white. The dark theme keeps
|
||||
its step towards the lighter surface, which is the same move in its own register.
|
||||
*/
|
||||
@at-root .body--light &:hover {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 6px 16px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
@at-root .body--dark &:hover {
|
||||
box-shadow: 0 6px 16px rgb(0 0 0 / 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The lid. Out of the way in the list layout: `display: contents` draws the children and no box,
|
||||
so the icon below is a flex item of the card itself and every rule in this block is inert.
|
||||
*/
|
||||
&-media {
|
||||
display: contents;
|
||||
|
||||
@at-root .page-blog-list.is-cards & {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
/* -> 16px at the left, which is the body's own inset below: the icon sits over the title
|
||||
rather than a few pixels off it */
|
||||
padding: 18px 16px;
|
||||
|
||||
/*
|
||||
Lit from the bottom-right corner: an ellipse centred there, the lighter brand shade at the
|
||||
corner falling to the brand colour as it spreads back across the card.
|
||||
|
||||
The custom properties rather than the SCSS `$primary` beside them -- a site themes itself by
|
||||
rewriting `--q-primary` at runtime (see `css/tailwind.css`), which a value compiled into the
|
||||
stylesheet cannot follow. `primary-lighter` is itself a `color-mix` off the same property,
|
||||
so both ends of the gradient re-theme together.
|
||||
*/
|
||||
background: radial-gradient(
|
||||
ellipse at bottom right,
|
||||
var(--color-primary-lighter),
|
||||
var(--color-primary)
|
||||
);
|
||||
|
||||
/* -> The hairline closing the lid; the colour is per theme, see `--blog-lid-seam` above */
|
||||
border-bottom: 1px solid var(--blog-lid-seam);
|
||||
}
|
||||
|
||||
/*
|
||||
The featured card's panel: a third of the card, down its left, full height.
|
||||
|
||||
`0 0 33.333%` rather than `1 1` -- a third is the figure, not a starting point, so the panel
|
||||
does not grow into the space a short title leaves. The seam turns the corner with it: the
|
||||
same line, drawn on the edge that now divides the two halves.
|
||||
*/
|
||||
@at-root .page-blog-list.is-cards .page-blog-post:first-child & {
|
||||
@media (min-width: #{$breakpoint-xs-max + 0.02px}) {
|
||||
flex: 0 0 33.333%;
|
||||
border-right: 1px solid var(--blog-lid-seam);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
And it is the SECONDARY colour, which is what separates the lead card from the listing
|
||||
under it at a glance -- the width says it is different, the hue says which one it is.
|
||||
|
||||
Outside the media query, unlike the arrangement above: below `xs` the panel goes back to a
|
||||
band across the top, and it is still the featured card while it does.
|
||||
*/
|
||||
background: radial-gradient(
|
||||
ellipse at bottom right,
|
||||
var(--color-secondary-lighter),
|
||||
var(--color-secondary)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 14px;
|
||||
color: $primary;
|
||||
|
||||
/* -> On the gradient rather than on the card, so it is drawn in the surface's own ink */
|
||||
@at-root .page-blog-list.is-cards & {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
|
||||
/* -> The padding the card gave up, so the text keeps the inset the lid may not have */
|
||||
@at-root .page-blog-list.is-cards & {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
|
||||
/*
|
||||
A step up on a card, where the title is the thing being scanned and the rest of the card is
|
||||
support: it inherits the app's 14px otherwise, which is the same size as the body text a row
|
||||
in the `list` layout sits in a line of. 1rem is 16px against that 14 -- stated in `rem` like
|
||||
the description and byline below it, which are 0.8 and 0.75 of the same unit.
|
||||
|
||||
A list row keeps the inherited size. There the title IS the row, with nothing above it to be
|
||||
distinguished from.
|
||||
*/
|
||||
@at-root .page-blog-list.is-cards & {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
One step further on the featured card, which is the only entry in the listing whose title is
|
||||
competing with anything: it sits beside a third of a card of colour and an icon at twice the
|
||||
size, and at the same 16px as the grid below it read as a caption to the panel rather than as
|
||||
the lead.
|
||||
|
||||
A step and not a headline -- these are all one listing, and a title that jumps to a heading
|
||||
size stops reading as the first of twenty-five posts and starts reading as a section above
|
||||
them.
|
||||
*/
|
||||
@at-root .page-blog-list.is-cards .page-blog-post:first-child & {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&-sep {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&-tag {
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-3;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-pager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&-truncated {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 152, 0, 0.12);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: $orange-9;
|
||||
|
||||
@at-root .body--dark & {
|
||||
color: $orange-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="page-blog-sidebar">
|
||||
<!-- Tags -->
|
||||
<template v-if="showTags">
|
||||
<div class="p-4 flex items-center">
|
||||
<w-icon class="mr-2" name="la:tags" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.blog.tags') }}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<!--
|
||||
A cloud rather than a list: the counts are what make a blog's tags worth scanning, and a
|
||||
column of rows carrying one number each is a lot of height for that. The selected one stays
|
||||
in place and reads as pressed, because it is also the way back out of itself.
|
||||
-->
|
||||
<router-link
|
||||
class="page-blog-tag"
|
||||
v-for="entry of blogState.facets.tags"
|
||||
:key="entry.tag"
|
||||
:class="{ 'is-active': filter.tag === entry.tag }"
|
||||
:to="tagLink(entry.tag)">
|
||||
{{ entry.tag }}
|
||||
<span class="page-blog-tag-count">{{ entry.count }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Archive -->
|
||||
<template v-if="showArchive">
|
||||
<w-separator v-if="showTags" />
|
||||
<div class="p-4 flex items-center">
|
||||
<w-icon class="mr-2" name="la:calendar-alt" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.blog.archive') }}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<!--
|
||||
Years, each opening onto its own months. The year is a filter in its own right as well as a
|
||||
heading -- a reader wanting "everything from 2025" should not have to pick twelve months --
|
||||
so the row is a link and the chevron beside it is what opens the list.
|
||||
-->
|
||||
<div class="page-blog-year" v-for="year of years" :key="year.year">
|
||||
<div class="page-blog-year-row">
|
||||
<!-- -> `color` and not the inherited text colour: this column states its own ink per
|
||||
theme for the rows, and a button left to inherit drew the chevron in the
|
||||
document's black and vanished against the dark surface -->
|
||||
<w-btn
|
||||
class="page-blog-year-toggle"
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
color="grey"
|
||||
:icon="isOpen(year.year) ? `la:angle-down` : `la:angle-right`"
|
||||
:aria-label="t(`common.blog.toggleYear`, { year: year.year })"
|
||||
:aria-expanded="isOpen(year.year)"
|
||||
@click="toggleYear(year.year)" />
|
||||
<router-link
|
||||
class="page-blog-month is-year"
|
||||
:class="{ 'is-active': filter.year === year.year && !filter.month }"
|
||||
:to="archiveLink(year.year, null)">
|
||||
{{ year.year }}
|
||||
<span class="page-blog-month-count">{{ year.count }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="page-blog-months" v-if="isOpen(year.year)">
|
||||
<router-link
|
||||
class="page-blog-month"
|
||||
v-for="entry of year.months"
|
||||
:key="entry.month"
|
||||
:class="{ 'is-active': filter.year === year.year && filter.month === entry.month }"
|
||||
:to="archiveLink(year.year, entry.month)">
|
||||
{{ monthName(entry.month) }}
|
||||
<span class="page-blog-month-count">{{ entry.count }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!--
|
||||
A blog with nothing in it yet has neither a tag nor a month, so both sections above are empty
|
||||
and the column would be a blank strip beside the listing. It says why instead.
|
||||
-->
|
||||
<div class="p-4 text-caption text-grey-6" v-if="!showTags && !showArchive && blogState.loaded">
|
||||
{{ t('common.blog.noFacets') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { blogState } from '@/composables/blog'
|
||||
|
||||
import { blogFilterFromQuery, blogFilterQuery } from '@/helpers/blogFilter'
|
||||
import { parseBlog } from '@/helpers/pageBlog'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
|
||||
/**
|
||||
* The column beside a blog's listing: what the blog is about, and when it was written.
|
||||
*
|
||||
* It replaces the contents/tags/rating column on a blog's front page, which has none of those to
|
||||
* offer — there are no headings to list, the front page's own tags are not the blog's, and a page
|
||||
* with no body is not something to rate.
|
||||
*
|
||||
* The facets come from the same request the listing does (`composables/blog.js`), so a count here can
|
||||
* never disagree with the posts next to it — and they describe the WHOLE blog rather than the
|
||||
* filtered set, which is what keeps a tag from disappearing the moment it is picked and stranding the
|
||||
* reader inside a filter they cannot see the way out of.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
|
||||
// ROUTER
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
/**
|
||||
* Which years are open, by year.
|
||||
*
|
||||
* Local rather than in the URL: it is how the reader is looking at the list, not what they are
|
||||
* looking at, so it has no business in a link somebody else opens.
|
||||
*/
|
||||
const openYears = reactive(new Set())
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const settings = computed(() => parseBlog(pageStore.content))
|
||||
|
||||
const filter = computed(() => blogFilterFromQuery(route.query))
|
||||
|
||||
/** Both halves are asked twice: the blog has to want the section AND have something to put in it. */
|
||||
const showTags = computed(() => settings.value.sidebar.tags && blogState.facets.tags.length > 0)
|
||||
|
||||
const showArchive = computed(
|
||||
() => settings.value.sidebar.archive && blogState.facets.archive.length > 0
|
||||
)
|
||||
|
||||
/**
|
||||
* The archive as years holding months, newest first.
|
||||
*
|
||||
* The server answers one row per month, which is the shape a count comes in; a reader browses by
|
||||
* year, so the grouping is done here rather than sent twice. The year's own count is its months
|
||||
* added up, so the two can never say different things.
|
||||
*/
|
||||
const years = computed(() => {
|
||||
const grouped = new Map()
|
||||
for (const entry of blogState.facets.archive) {
|
||||
if (!grouped.has(entry.year)) {
|
||||
grouped.set(entry.year, { year: entry.year, count: 0, months: [] })
|
||||
}
|
||||
const year = grouped.get(entry.year)
|
||||
year.count += entry.count
|
||||
year.months.push({ month: entry.month, count: entry.count })
|
||||
}
|
||||
return [...grouped.values()]
|
||||
.sort((a, b) => b.year - a.year)
|
||||
.map((year) => ({ ...year, months: year.months.sort((a, b) => b.month - a.month) }))
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* Whether a year's months are shown.
|
||||
*
|
||||
* The year being filtered on is always open, whether or not anybody clicked it: a reader who arrived
|
||||
* on a link to March 2026 must be able to see which month of the year they are in.
|
||||
*/
|
||||
function isOpen(year) {
|
||||
return openYears.has(year) || filter.value.year === year
|
||||
}
|
||||
|
||||
function toggleYear(year) {
|
||||
if (openYears.has(year)) {
|
||||
openYears.delete(year)
|
||||
} else {
|
||||
openYears.add(year)
|
||||
}
|
||||
}
|
||||
|
||||
/** Picking the tag already selected clears it, so the cloud is its own way back out. */
|
||||
function tagLink(tag) {
|
||||
const next = filter.value.tag === tag ? null : tag
|
||||
return { path: route.path, query: blogFilterQuery({ ...filter.value, tag: next }) }
|
||||
}
|
||||
|
||||
/** Likewise for a month, and for a year picked while that year is already the whole selection. */
|
||||
function archiveLink(year, month) {
|
||||
const isCurrent = filter.value.year === year && (filter.value.month ?? null) === month
|
||||
return {
|
||||
path: route.path,
|
||||
query: blogFilterQuery({
|
||||
tag: filter.value.tag,
|
||||
year: isCurrent ? null : year,
|
||||
month: isCurrent ? null : month
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A month's name on its own. Built from the 15th rather than the 1st: the archive counts months in
|
||||
* UTC, and midnight on the first falls into the previous month for every reader west of Greenwich.
|
||||
*/
|
||||
function monthName(month) {
|
||||
return new Date(Date.UTC(2000, month - 1, 15)).toLocaleString(undefined, {
|
||||
month: 'long',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.page-blog-sidebar {
|
||||
/* -> A cloud: every tag inline, wrapping, with its count tucked behind it */
|
||||
.page-blog-tag {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
margin: 0 4px 4px 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-3;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
&:hover {
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-4;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-2;
|
||||
}
|
||||
}
|
||||
&.is-active {
|
||||
background-color: $primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&-count {
|
||||
padding-left: 5px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.page-blog-year {
|
||||
&-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -> Squared off and small: it is a disclosure triangle, not a button in its own right */
|
||||
&-toggle {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
margin-right: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-blog-months {
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.page-blog-month {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
|
||||
@at-root .body--light & {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
&:hover {
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-3;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
}
|
||||
}
|
||||
&.is-active {
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
&.is-year {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&-count {
|
||||
padding-left: 8px;
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,86 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* The listing of the blog currently on screen.
|
||||
*
|
||||
* A module singleton, because the blog is drawn in two places at once and they are one answer: the
|
||||
* article column shows the posts, and the column beside it shows the tags and the archive to narrow
|
||||
* them by. Both come out of a single request (see `api/blogs.ts` — the facets ride along with the
|
||||
* posts so that a tag cloud can never disagree with the listing next to it), so a second component
|
||||
* fetching for itself would be a second request and a second chance to disagree.
|
||||
*
|
||||
* Only one blog is ever on screen, so there is nothing to key this by: `PageBlog.vue` loads it as the
|
||||
* route settles and `reset()` empties it on the way out, the same way the page store empties itself
|
||||
* for a page that turns out not to be there.
|
||||
*/
|
||||
|
||||
export const blogState = reactive({
|
||||
loading: false,
|
||||
/** Whether a listing has arrived, which is what separates "no posts" from "not asked yet". */
|
||||
loaded: false,
|
||||
/** Set when the request failed, so the column can say so rather than show an empty blog. */
|
||||
failed: false,
|
||||
posts: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
/** Every tag and month of the WHOLE blog, not of the filtered set — see `BlogListing` on the server. */
|
||||
facets: { tags: [], archive: [] },
|
||||
/** Whether the blog holds more posts than one request will read. */
|
||||
truncated: false
|
||||
})
|
||||
|
||||
/** Empty the listing, for a reader leaving the blog. */
|
||||
export function resetBlog() {
|
||||
blogState.loading = false
|
||||
blogState.loaded = false
|
||||
blogState.failed = false
|
||||
blogState.posts = []
|
||||
blogState.total = 0
|
||||
blogState.page = 1
|
||||
blogState.pageCount = 1
|
||||
blogState.facets = { tags: [], archive: [] }
|
||||
blogState.truncated = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one page of a blog's listing.
|
||||
*
|
||||
* Every narrowing is the server's: which posts this reader may open, what the tag cloud should say
|
||||
* and which page of the result they are on are all answered there, because none of them can be
|
||||
* answered here without holding posts this reader was never shown.
|
||||
*
|
||||
* @param filter `{ tag, year, month, page }`, each optional — the reader's selection as the URL
|
||||
* carries it. See `PageBlog.vue`.
|
||||
*/
|
||||
export async function loadBlog({ siteId, path, locale, filter = {} }) {
|
||||
blogState.loading = true
|
||||
blogState.failed = false
|
||||
try {
|
||||
const search = new URLSearchParams({ path, locale })
|
||||
if (filter.tag) {
|
||||
search.set('tag', filter.tag)
|
||||
}
|
||||
if (filter.year) {
|
||||
search.set('year', String(filter.year))
|
||||
if (filter.month) {
|
||||
search.set('month', String(filter.month))
|
||||
}
|
||||
}
|
||||
if (filter.page && filter.page > 1) {
|
||||
search.set('page', String(filter.page))
|
||||
}
|
||||
const resp = await API_CLIENT.get(`sites/${siteId}/blogs/posts?${search.toString()}`).json()
|
||||
blogState.posts = resp.posts ?? []
|
||||
blogState.total = resp.total ?? 0
|
||||
blogState.page = resp.page ?? 1
|
||||
blogState.pageCount = resp.pageCount ?? 1
|
||||
blogState.facets = resp.facets ?? { tags: [], archive: [] }
|
||||
blogState.truncated = resp.truncated === true
|
||||
blogState.loaded = true
|
||||
} catch (err) {
|
||||
blogState.failed = true
|
||||
console.warn(err)
|
||||
}
|
||||
blogState.loading = false
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* What a blog listing is narrowed to, as the URL carries it.
|
||||
*
|
||||
* The selection lives in the query string rather than in the path, because the path namespace under
|
||||
* a blog belongs to its posts — `/my-blog/2026/03` is a page somebody may well have written, and an
|
||||
* archive addressed that way would collide with it. It also makes a narrowed blog a link somebody can
|
||||
* be handed, which is what `/_tags?t=a,b` does for the same reason.
|
||||
*
|
||||
* Both halves live here so that the component reading a filter and the component writing one cannot
|
||||
* disagree about the spelling: `PageBlog.vue` reads, `PageBlogSidebar.vue` writes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read a filter out of a route's query.
|
||||
*
|
||||
* Every field is optional and anything unreadable is simply absent — a filter is a reader's
|
||||
* selection, and a `?year=soon` typed into the bar should show them the blog rather than an error.
|
||||
* A month without a year is dropped for the same reason the server ignores it: it does not name a
|
||||
* period.
|
||||
*/
|
||||
export function blogFilterFromQuery(query = {}) {
|
||||
const year = Number.parseInt(query.year, 10)
|
||||
const month = Number.parseInt(query.month, 10)
|
||||
const page = Number.parseInt(query.p, 10)
|
||||
const hasYear = Number.isFinite(year) && year > 0
|
||||
const hasMonth = hasYear && Number.isFinite(month) && month >= 1 && month <= 12
|
||||
return {
|
||||
tag: typeof query.tag === 'string' && query.tag.length > 0 ? query.tag : null,
|
||||
year: hasYear ? year : null,
|
||||
month: hasMonth ? month : null,
|
||||
page: Number.isFinite(page) && page > 1 ? page : 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a filter back into a route query, leaving out everything that is not set.
|
||||
*
|
||||
* The page is never carried across: every one of these changes WHICH posts there are, so staying on
|
||||
* page 4 of the previous selection would land the reader somewhere arbitrary in the new one — or, for
|
||||
* a narrower selection, on a page that no longer exists.
|
||||
*/
|
||||
export function blogFilterQuery({ tag, year, month } = {}) {
|
||||
const query = {}
|
||||
if (tag) {
|
||||
query.tag = tag
|
||||
}
|
||||
if (year) {
|
||||
query.year = String(year)
|
||||
if (month) {
|
||||
query.month = String(month)
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* How a blog behaves, as its front page holds it.
|
||||
*
|
||||
* A blog is a page authored with the `blog` editor: it has a path, a title and a place in the tree,
|
||||
* and nothing to read. What an author fills in is how the blog should look, and that is its content,
|
||||
* as JSON — see `BlogContent` and `normalizeBlogContent` in the backend's `models/pages.ts`, which is
|
||||
* the authority on the shape and rewrites a save into it. This file is the same reading, in front of
|
||||
* the author: the editor round-trips through it and the blog view follows what it returns.
|
||||
*
|
||||
* Which pages are the blog's POSTS is not here and is not stored anywhere — they are the pages under
|
||||
* the blog's path, worked out by the server at read time. See `models/blogs.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ways a listing can draw its posts. See `BLOG_LAYOUTS` on the server for why there are two.
|
||||
*
|
||||
* Order matters here and not there: the editor's layout dropdown is this array mapped to labels, so
|
||||
* this is the order an author reads them in, default first.
|
||||
*/
|
||||
export const BLOG_LAYOUTS = ['cards', 'list']
|
||||
|
||||
/** Which end of the blog a listing starts at. */
|
||||
export const BLOG_SORTS = ['newest', 'oldest']
|
||||
|
||||
/** The widest a page of the listing may be set to. Mirrors `BLOG_MAX_PER_PAGE` on the server. */
|
||||
export const BLOG_MAX_PER_PAGE = 100
|
||||
|
||||
/** As deep below itself as a blog may collect posts from. Mirrors `BLOG_MAX_DEPTH`. */
|
||||
export const BLOG_MAX_DEPTH = 10
|
||||
|
||||
/** As long an introduction as a front page will carry. Mirrors `BLOG_MAX_INTRO`. */
|
||||
export const BLOG_MAX_INTRO = 2000
|
||||
|
||||
/** A blog with nothing filled in, which is what a page being created starts as. */
|
||||
export function emptyBlog() {
|
||||
return {
|
||||
// -> Cards; see `BLOG_DEFAULTS` on the server, which is the authority on every default here
|
||||
layout: 'cards',
|
||||
perPage: 10,
|
||||
sort: 'newest',
|
||||
depth: 5,
|
||||
intro: '',
|
||||
show: { icon: true, description: true, author: true, date: true, tags: true },
|
||||
sidebar: { tags: true, archive: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored blog's settings. Never throws: content that is missing or unparseable comes back as
|
||||
* an empty blog, which is a working blog rather than a broken screen — every field here is a display
|
||||
* decision and none of them is a destination the way a redirection's target is.
|
||||
*/
|
||||
export function parseBlog(content) {
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(content ?? '')
|
||||
} catch {
|
||||
// -> An empty blog is the answer; see above
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
parsed = {}
|
||||
}
|
||||
const defaults = emptyBlog()
|
||||
const perPage = Number.parseInt(parsed.perPage, 10)
|
||||
const depth = Number.parseInt(parsed.depth, 10)
|
||||
return {
|
||||
layout: BLOG_LAYOUTS.includes(parsed.layout) ? parsed.layout : defaults.layout,
|
||||
perPage:
|
||||
Number.isFinite(perPage) && perPage > 0
|
||||
? Math.min(perPage, BLOG_MAX_PER_PAGE)
|
||||
: defaults.perPage,
|
||||
sort: BLOG_SORTS.includes(parsed.sort) ? parsed.sort : defaults.sort,
|
||||
depth: Number.isFinite(depth) && depth >= 0 ? Math.min(depth, BLOG_MAX_DEPTH) : defaults.depth,
|
||||
intro: typeof parsed.intro === 'string' ? parsed.intro : defaults.intro,
|
||||
show: {
|
||||
icon: parsed.show?.icon ?? defaults.show.icon,
|
||||
description: parsed.show?.description ?? defaults.show.description,
|
||||
author: parsed.show?.author ?? defaults.show.author,
|
||||
date: parsed.show?.date ?? defaults.show.date,
|
||||
tags: parsed.show?.tags ?? defaults.show.tags
|
||||
},
|
||||
sidebar: {
|
||||
tags: parsed.sidebar?.tags ?? defaults.sidebar.tags,
|
||||
archive: parsed.sidebar?.archive ?? defaults.sidebar.archive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The canonical spelling of a blog's settings, which is what gets saved. */
|
||||
export function serializeBlog(blog = {}) {
|
||||
const value = parseBlog(JSON.stringify(blog))
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
Loading…
Reference in new issue