feat: user public profile

scarlett
NGPixel 5 days ago
parent 7dfb9abe32
commit a857f96bb0
No known key found for this signature in database

@ -218,6 +218,8 @@ async function routes(app: FastifyInstance) {
tags?: string
editor?: string
publishState?: string
creatorId?: string
authorId?: string
orderBy?: SearchOrderBy
orderByDirection?: 'asc' | 'desc'
offset?: number
@ -229,7 +231,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Search pages',
description:
'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\nA password-protected page is listed like any other — its title and description are not what the password covers — but for a searcher who would have to enter that password it can only be matched on those two, never on the text behind the lock, and it comes back with no `highlight`.\n\n`highlight` is an excerpt with the matched terms wrapped in `<b>`, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.',
'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\nA password-protected page is listed like any other — its title and description are not what the password covers — but for a searcher who would have to enter that password it can only be matched on those two, never on the text behind the lock, and it comes back with no `highlight`.\n\n`creatorId` and `authorId` narrow the results to the pages of one person — who wrote a page and who touched it last are two different questions, and a public user profile asks both. They are a filter like any other, so a page marked as not searchable stays out of them too.\n\n`highlight` is an excerpt with the matched terms wrapped in `<b>`, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.',
tags: ['Pages'],
params: siteIdParam,
querystring: {
@ -263,6 +265,16 @@ async function routes(app: FastifyInstance) {
type: 'string',
enum: ['draft', 'published', 'scheduled']
},
creatorId: {
type: 'string',
format: 'uuid',
description: 'Only pages this user created.'
},
authorId: {
type: 'string',
format: 'uuid',
description: 'Only pages this user edited last.'
},
orderBy: {
type: 'string',
enum: SEARCH_ORDER_BY,
@ -303,6 +315,7 @@ async function routes(app: FastifyInstance) {
description: { type: ['string', 'null'] },
icon: { type: ['string', 'null'] },
tags: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
relevancy: { type: 'number' },
highlight: {
@ -332,6 +345,8 @@ async function routes(app: FastifyInstance) {
tags: splitList(req.query.tags),
editor: req.query.editor,
publishState: req.query.publishState,
creatorId: req.query.creatorId,
authorId: req.query.authorId,
orderBy: req.query.orderBy,
orderByDirection: req.query.orderByDirection,
offset: req.query.offset,

@ -163,6 +163,50 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* PUBLIC USER PROFILE - What a user's profile page shows anyone who opens it
*
* Everything here is either something the user typed into their own profile to be seen, or the fact
* that they were here. The email is absent on purpose see `PublicUserProfile` in the model.
*/
app.addSchema({
$id: 'PublicUserProfile',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string'
},
hasAvatar: {
type: 'boolean'
},
location: {
type: 'string'
},
jobTitle: {
type: 'string'
},
pronouns: {
type: 'string'
},
timezone: {
type: 'string',
description:
'IANA time zone name, or an empty string for a user who never picked one — in which case there is no local time to show for them.'
},
lastLoginAt: {
// -> `nullable` rather than a type array, as on `UserCore`: the emitted spec is OpenAPI 3.0
type: 'string',
nullable: true,
format: 'date-time',
description: 'RFC 3339 Date Time, or null if the user has never logged in'
}
}
})
/**
* USER PROFILE UPDATE - The fields a user may change on its own profile
*

@ -1225,6 +1225,56 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PUBLIC USER PROFILE
*/
app.get<{ Params: { userId: string } }>(
'/:userId/profile',
{
/*
No route-level permissions: this is the public profile page, readable by whoever can read the
wiki the same reach an avatar already has under `/_user/<id>/avatar`, and for the same reason.
A page names its author, so a reader who can open the page can look them up.
Nothing here enumerates users either: the ID has to be known, and `GET /users` which is what
turns a wiki into a list of names and addresses still wants `read:users`.
*/
schema: {
summary: 'Get a user public profile',
description:
'What a user chose to say about themselves, plus when they were last here. Answers 404 for a system account — the guest an anonymous reader is, and the account content nobody authored is attributed to, are not people and have no profile.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
response: {
200: {
description: 'Public user profile',
type: 'object',
$ref: 'PublicUserProfile#'
}
}
}
},
async (req, reply) => {
const profile = await WIKI.models.users.getPublicProfile(req.params.userId)
if (!profile) {
return reply.notFound('User does not exist.')
}
return profile
}
)
/**
* GET USER
*/
app.get<{ Params: { userId: string } }>(
'/:userId',
{

@ -53,6 +53,9 @@ const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml']
* and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* shell needs is "does something here serve this", which is this list, and it has to be kept in step
* with the registrations below.
*
* `_user` is registered below but absent here: it is shared with the frontend router, and `isServerUrl`
* is what splits it.
*/
const SERVER_ROUTE_SEGMENTS = new Set([
'_api',
@ -64,10 +67,30 @@ const SERVER_ROUTE_SEGMENTS = new Set([
'_render',
'_site',
'_terminal',
'_thumb',
'_user'
'_thumb'
])
/**
* Whether the server answers this URL, as opposed to the app shell being handed over for the frontend
* router to resolve.
*
* `_user` is the one segment the two SHARE, so it cannot be settled by its first segment alone: the
* server serves avatars at `/_user/<id>/avatar`, while the frontend owns the public profile page at
* `/_user/<id>`. Only the avatar is the server's, and everything else under there is the app's so a
* mistyped avatar URL hands back a profile page that says the user does not exist, which is the same
* answer by a different route.
*
* `frontend/vite.config.js` draws the same line from the other side: its dev proxy forwards only the
* avatar path to this server, and the two have to agree.
*/
function isServerUrl(urlPath: string): boolean {
const segments = urlPath.split('/')
if (segments[1] === '_user') {
return segments[3] === 'avatar'
}
return SERVER_ROUTE_SEGMENTS.has(segments[1] ?? '')
}
/**
* Whether a URL addresses the page tree rather than the server itself.
*
@ -727,7 +750,7 @@ async function initHTTPServer() {
app.setNotFoundHandler(async (req, reply) => {
const urlPath = req.raw.url!.split('?')[0]!
const firstSegment = urlPath.split('/')[1] ?? ''
const isSystemPath = SERVER_ROUTE_SEGMENTS.has(firstSegment)
const isSystemPath = isServerUrl(urlPath)
const isReservedRootFile = RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
// -> HEAD as well as GET: it has to answer what GET would, or a monitor pointed at the wiki reads a
// 404 for a page the browser beside it loads. Node drops the body for HEAD on its own.

@ -2416,6 +2416,18 @@
"tags.searchWithinResultsPlaceholder": "Search within results...",
"tags.selectOneMoreTags": "Select one or more tags",
"tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
"userProfile.lastLogin": "Last Login",
"userProfile.loadMore": "Load More",
"userProfile.loadingFailed": "Failed to load user profile.",
"userProfile.localTime": "Local Time",
"userProfile.location": "Location",
"userProfile.noPagesCreated": "This user hasn't created any page yet.",
"userProfile.noPagesUpdated": "This user hasn't modified any page yet.",
"userProfile.notFound": "There is no such user.",
"userProfile.pagesCreated": "Pages Created",
"userProfile.pagesLoadingFailed": "Failed to load pages.",
"userProfile.pagesUpdated": "Last Modified Pages",
"userProfile.title": "User Profile",
"welcome.admin": "Administration Area",
"welcome.createHome": "Create the homepage",
"welcome.homeDefault.content": "Write some content here...",

@ -54,7 +54,7 @@ export interface RebuildResult {
locales: { locale: string; dictionary: string; pages: number }[]
}
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'updatedAt'] as const
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'createdAt', 'updatedAt'] as const
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
export interface SearchResult {
@ -65,6 +65,7 @@ export interface SearchResult {
description: string | null
icon: string | null
tags: string[]
createdAt: string
updatedAt: string
relevancy: number
highlight: string | null
@ -83,6 +84,15 @@ export interface SearchPagesParams {
tags?: string[]
editor?: string
publishState?: string
/**
* Only pages this user created, and only pages this user edited last.
*
* Two separate columns rather than one "by this person": a page is created once and edited by
* whoever touched it most recently, and the profile page asks both questions of the same user in
* two different tabs.
*/
creatorId?: string
authorId?: string
orderBy?: SearchOrderBy
orderByDirection?: 'asc' | 'desc'
offset?: number
@ -221,6 +231,8 @@ class Search {
tags = [],
editor = '',
publishState = '',
creatorId = '',
authorId = '',
orderBy = 'relevancy',
orderByDirection = 'desc',
offset = 0,
@ -293,6 +305,12 @@ class Search {
if (editor) {
conditions.push(sql`p.editor = ${editor}`)
}
if (creatorId) {
conditions.push(sql`p."creatorId" = ${creatorId}`)
}
if (authorId) {
conditions.push(sql`p."authorId" = ${authorId}`)
}
const direction = orderByDirection === 'asc' ? sql`ASC` : sql`DESC`
// -> Every page ranks 0 without a query, which would leave the order down to the planner
@ -300,6 +318,7 @@ class Search {
const ordering = {
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
title: sql`p.title ${direction}`,
createdAt: sql`p."createdAt" ${direction}`,
updatedAt: sql`p."updatedAt" ${direction}`
}[effectiveOrderBy]
@ -327,6 +346,7 @@ class Search {
p.description,
p.icon,
p.tags,
to_char(p."createdAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "createdAt",
to_char(p."updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt",
${hasQuery ? sql`ts_rank(p.ts, ${tsQuery})` : sql`0`} AS relevancy,
${highlight} AS highlight,
@ -360,6 +380,7 @@ class Search {
description: row.description ?? null,
icon: row.icon ?? null,
tags: (row.tags ?? []) as string[],
createdAt: row.createdAt as string,
updatedAt: row.updatedAt as string,
relevancy: Number(row.relevancy ?? 0),
// -> Escaped first, so the only markup that survives is the emphasis postgres marked

@ -109,6 +109,26 @@ export interface UserProfile {
cvd: string
}
/**
* What a user's public profile page shows to whoever opens it.
*
* A strict subset of `UserProfile`, and deliberately not built by trimming one: the email is absent
* because a profile page is readable by anyone who can read the wiki, and every preference that only
* decides how the wiki is DRAWN for its owner date format, appearance, colour vision says nothing
* about the person. The time zone is the one preference that does, since it is what the card needs to
* say what time it is where they are.
*/
export interface PublicUserProfile {
id: string
name: string
hasAvatar: boolean
location: string
jobTitle: string
pronouns: string
timezone: string
lastLoginAt: Date | null
}
/** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */
export interface UserProfilePatch {
name?: string
@ -555,6 +575,37 @@ class Users {
}
}
/**
* A user as their public profile page presents them, which is anybody who can read the wiki.
*
* `meta` and `prefs` are defaulted here for the same reason `getProfile` defaults them: they are
* free-form blobs, and a user created before a key existed simply has none.
*
* @returns The profile, or null when there is no person behind the ID no such user, or a system
* account, which is what the guest every anonymous reader is and what content nobody
* authored is attributed to. Neither has a profile to show.
*/
async getPublicProfile(id: string): Promise<PublicUserProfile | null> {
const user = await this.getById(id)
if (!user || user.isSystem) {
return null
}
const meta = (user.meta ?? {}) as Record<string, any>
const prefs = (user.prefs ?? {}) as Record<string, any>
return {
id: user.id,
name: user.name,
hasAvatar: user.hasAvatar,
location: meta.location ?? '',
jobTitle: meta.jobTitle ?? '',
pronouns: meta.pronouns ?? '',
// -> Empty for a user who never picked one, which leaves the card with no local time to show
// rather than one from a zone nobody chose
timezone: prefs.timezone ?? '',
lastLoginAt: user.lastLoginAt
}
}
/**
* A user's own settings for one editor.
*

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
272 icons.
274 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -91,6 +91,7 @@ export const BUNDLED_ICONS = {
"la:lock": {"body":"<path fill=\"currentColor\" d=\"M16 3c-3.844 0-7 3.156-7 7v3H6v16h20V13h-3v-3c0-3.844-3.156-7-7-7m0 2c2.754 0 5 2.246 5 5v3H11v-3c0-2.754 2.246-5 5-5M8 15h16v12H8z\"/>","width":32,"height":32},
"la:lock-open": {"body":"<path fill=\"currentColor\" d=\"M16 3c-3.035 0-5.586 1.965-6.625 4.625l1.844.75C11.977 6.434 13.836 5 16 5c2.754 0 5 2.246 5 5v3H6v16h20V13h-3v-3c0-3.844-3.156-7-7-7M8 15h16v12H8z\"/>","width":32,"height":32},
"la:magic": {"body":"<path fill=\"currentColor\" d=\"m20.875 2.563l-.688.75l-1.687 1.78h-3.594v3.5l-1.719 1.813l-.687.719l2.188 2.188L3.03 25l-.719.719l.72.687l3.28 3.282l.688-.72l11.688-11.655l2.187 2.187l.719-.688l1.812-1.718h3.5V13.5l1.782-1.688l.75-.687l-2.532-2.531v-3.5h-3.5zm.031 2.874l1.375 1.375l.313.282h2.312v2.312l.282.313l1.375 1.375l-1.344 1.281l-.313.281v2.438h-2.312l-.282.281l-1.406 1.344l-.812-.813l4.531-4.531l-3.969-3.969l-.718.688l-3.813 3.844l-.844-.844l1.344-1.406l.281-.282V7.094h2.438l.281-.313zm-.25 4.782l1.125 1.156l-15.468 15.5l-1.157-1.156zM19 21v1h-1v2h1v1h2v-1h1v-2h-1v-1zm6 2v2h-2v2h2v2h2v-2h2v-2h-2v-2z\"/>","width":32,"height":32},
"la:map-marker": {"body":"<path fill=\"currentColor\" d=\"M16 3c-4.957 0-9 4.043-9 9c0 1.406.57 3.02 1.344 4.781c.773 1.762 1.77 3.633 2.781 5.375a101 101 0 0 0 4.063 6.407L16 29.75l.813-1.188s2.039-2.917 4.062-6.406c1.012-1.742 2.008-3.613 2.781-5.375C24.43 15.02 25 13.406 25 12c0-4.957-4.043-9-9-9m0 2c3.879 0 7 3.121 7 7c0 .8-.43 2.316-1.156 3.969c-.727 1.652-1.73 3.484-2.719 5.187c-1.57 2.711-2.547 4.145-3.125 5c-.578-.855-1.555-2.289-3.125-5c-.988-1.703-1.992-3.535-2.719-5.187S9 12.8 9 12c0-3.879 3.121-7 7-7m0 5a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:microchip": {"body":"<path fill=\"currentColor\" d=\"M7 6v2H3v18h4v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h2v2h2v-2h4V8h-4V6h-2v2h-2V6h-2v2h-2V6h-2v2h-2V6h-2v2H9V6zm-2 4h22v14H5zm3 2c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 16c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m16 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1M8 20c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m4 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:minus": {"body":"<path fill=\"currentColor\" d=\"M5 15v2h22v-2z\"/>","width":32,"height":32},
"la:mountain": {"body":"<path fill=\"currentColor\" d=\"m17.012 3.021l-.912 1.66l-6.522 11.856l-1.916-1.916l-.66 1.098l-5.86 9.767L.235 27h31.284l-.598-1.395l-3-7l-.582-1.357l-2.068 2.068l-7.403-14.605zm-.073 4.282l3.04 5.996l-.774.664l-2.28-1.953l-2.279 1.953l-.93-.799zm-.013 7.34l2.28 1.953l1.702-1.46l3.2 6.315l.622 1.233l1.932-1.932L28.482 25H3.766l4.293-7.154l1.988 1.988l.642-1.166l2.043-3.713l1.914 1.64z\"/>","width":32,"height":32},
@ -149,6 +150,7 @@ export const BUNDLED_ICONS = {
"la:user-friends": {"body":"<path fill=\"currentColor\" d=\"M9 7c-3.3 0-6 2.7-6 6c0 1.984.977 3.75 2.469 4.844C2.832 19.152 1 21.864 1 25h2c0-3.324 2.676-6 6-6s6 2.676 6 6h2c0-3.324 2.676-6 6-6s6 2.676 6 6h2c0-3.137-1.832-5.848-4.469-7.156A6 6 0 0 0 29 13c0-3.3-2.7-6-6-6s-6 2.7-6 6c0 1.984.977 3.75 2.469 4.844A8.06 8.06 0 0 0 16 21.125a8.06 8.06 0 0 0-3.469-3.281A6 6 0 0 0 15 13c0-3.3-2.7-6-6-6m0 2c2.223 0 4 1.777 4 4s-1.777 4-4 4s-4-1.777-4-4s1.777-4 4-4m14 0c2.223 0 4 1.777 4 4s-1.777 4-4 4s-4-1.777-4-4s1.777-4 4-4\"/>","width":32,"height":32},
"la:user-minus": {"body":"<path fill=\"currentColor\" d=\"M14 4c-3.9 0-7 3.1-7 7c0 2.4 1.2 4.6 3.1 5.8C6.5 18.3 4 21.9 4 26h2c0-4.4 3.6-8 8-8c1.4 0 2.7.4 3.8 1c-1.1 1.4-1.8 3.1-1.8 5c0 4.4 3.6 8 8 8s8-3.6 8-8s-3.6-8-8-8c-1.7 0-3.4.6-4.7 1.5c-.4-.3-.9-.5-1.4-.7c1.9-1.3 3.1-3.4 3.1-5.8c0-3.9-3.1-7-7-7m0 2c2.8 0 5 2.2 5 5s-2.2 5-5 5s-5-2.2-5-5s2.2-5 5-5m10 12c3.3 0 6 2.7 6 6s-2.7 6-6 6s-6-2.7-6-6s2.7-6 6-6m-4 5v2h8v-2z\"/>","width":32,"height":32},
"la:user-plus": {"body":"<path fill=\"currentColor\" d=\"M12 2C8.145 2 5 5.145 5 9c0 2.41 1.23 4.55 3.094 5.813C4.527 16.343 2 19.883 2 24h2c0-4.43 3.57-8 8-8c1.375 0 2.656.36 3.781.969A8 8 0 0 0 14 22c0 4.406 3.594 8 8 8s8-3.594 8-8s-3.594-8-8-8a7.96 7.96 0 0 0-4.688 1.531a10 10 0 0 0-1.406-.719A7.02 7.02 0 0 0 19 9c0-3.855-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m10 12c3.324 0 6 2.676 6 6s-2.676 6-6 6s-6-2.676-6-6s2.676-6 6-6m-1 2v3h-3v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":32,"height":32},
"la:user-slash": {"body":"<path fill=\"currentColor\" d=\"M3.7 2.3L2.3 3.7l6.821 6.82l-.021.08l1.9 1.9v-.102L15.602 17H15.5l2.2 2.2c.05.01.096.032.146.044l5.814 5.815c.01.048.03.092.04.14L25.5 27h.102l2.699 2.7l1.398-1.4l-4.105-4.105c-.844-2.88-2.946-5.25-5.694-6.394C21.8 16.5 23 14.4 23 12c0-3.9-3.1-7-7-7c-2.609 0-4.853 1.42-6.078 3.523L3.699 2.301zM16 7c2.8 0 5 2.2 5 5c0 2.087-1.224 3.838-3.006 4.596l-6.59-6.59C12.162 8.224 13.913 7 16 7m-6.9 6.3c.4 1.9 1.4 3.5 3 4.5C8.5 19.3 6 22.9 6 27h2c0-4.1 3-7.4 6.9-7.9z\"/>","width":32,"height":32},
"la:users": {"body":"<path fill=\"currentColor\" d=\"M11.5 6A3.514 3.514 0 0 0 8 9.5c0 1.922 1.578 3.5 3.5 3.5S15 11.422 15 9.5S13.422 6 11.5 6m9 0A3.514 3.514 0 0 0 17 9.5c0 1.922 1.578 3.5 3.5 3.5S24 11.422 24 9.5S22.422 6 20.5 6m-9 2c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5m9 0c.84 0 1.5.66 1.5 1.5s-.66 1.5-1.5 1.5s-1.5-.66-1.5-1.5s.66-1.5 1.5-1.5M7 12c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 2 23h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C10.523 18.117 11 17.114 11 16c0-2.2-1.8-4-4-4m5 11c-.625.836-1 1.887-1 3h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.02 5.02 0 0 0-1-3c-.34-.453-.75-.84-1.219-1.156C19.523 21.117 20 20.114 20 19c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5 5 0 0 0 12 23m8 0h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C28.523 18.117 29 17.114 29 16c0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 20 23M7 14c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m18 0c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-9 3c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2\"/>","width":32,"height":32},
"la:window-close": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm4.688 3.313l-1.407 1.406L14.562 16l-4.343 4.344l1.406 1.406l4.344-4.344l4.312 4.313l1.407-1.407L17.375 16l4.25-4.25l-1.406-1.406l-4.25 4.25z\"/>","width":32,"height":32},
"mdi:account-edit": {"body":"<path fill=\"currentColor\" d=\"m21.7 13.35l-1 1l-2.05-2.05l1-1a.55.55 0 0 1 .77 0l1.28 1.28c.21.21.21.56 0 .77M12 18.94l6.06-6.06l2.05 2.05L14.06 21H12zM12 14c-4.42 0-8 1.79-8 4v2h6v-1.89l4-4c-.66-.08-1.33-.11-2-.11m0-10a4 4 0 0 0-4 4a4 4 0 0 0 4 4a4 4 0 0 0 4-4a4 4 0 0 0-4-4\"/>","width":24,"height":24},

@ -53,6 +53,21 @@
<w-item-section>{{ sc.text }}</w-item-section>
</w-item>
</template>
<!--
A new tab rather than a router link: this overlay is an edit form with its own Save button,
and routing away from it unmounts it -- taking whatever has been typed into it and not saved
with it. `href` is what says so, since `to` would have the router resolve the path in place.
Left out for a system account, which has no profile to view: the guest every anonymous reader
is appears in the user list like any other row, and its profile answers 404.
-->
<template v-if="!state.user.isSystem">
<w-separator class="my-2" dark inset />
<w-item :href="`/_user/${state.user.id}`" target="_blank">
<w-item-section side><w-icon name="la:id-card" color="white" /></w-item-section>
<w-item-section>{{ t('profile.viewPublicProfile') }}</w-item-section>
</w-item>
</template>
</w-list>
</w-drawer>
<w-page-container>

@ -1,6 +1,6 @@
/**
* Paths the server owns rather than the page tree: build assets, the API, block bundles, uploaded
* files, icons, per-site files, thumbnails and avatars.
* files, icons, per-site files and thumbnails. Avatars are the exception -- see `isServerPath`.
*
* One list, because two different things ask the same question of a URL and must not drift apart:
* which links the router should keep its hands off (`renderedContent.js`), and which image sources
@ -13,11 +13,20 @@ export const SERVER_PATHS = [
'/_files/',
'/_icons/',
'/_site/',
'/_thumb/',
'/_user/'
'/_thumb/'
]
/** Whether a root-relative path is one of them. */
/**
* Whether a root-relative path is one of them.
*
* `/_user/` is the one prefix the server shares with the router, so it is not in the list above: the
* server serves avatars at `/_user/<id>/avatar`, while the app owns the public profile page at
* `/_user/<id>`. A link to somebody's profile is therefore a link the router follows, and only the
* avatar underneath it is a file. `backend/index.ts` splits the same segment the same way.
*/
export function isServerPath(path) {
if (path.startsWith('/_user/')) {
return path.endsWith('/avatar')
}
return SERVER_PATHS.some((prefix) => path.startsWith(prefix))
}

@ -42,17 +42,15 @@
</w-item-section>
</w-item>
</template>
<template v-if="flagsStore.experimental">
<w-separator inset spaced="sm" />
<w-item clickable :to="`/_user/` + userStore.id">
<w-item-section side>
<w-icon name="la:id-card" />
</w-item-section>
<w-item-section>
<w-item-label>{{ t('profile.viewPublicProfile') }}</w-item-label>
</w-item-section>
</w-item>
</template>
<w-separator inset spaced="sm" />
<w-item clickable :to="`/_user/` + userStore.id">
<w-item-section side>
<w-icon name="la:id-card" />
</w-item-section>
<w-item-section>
<w-item-label>{{ t('profile.viewPublicProfile') }}</w-item-label>
</w-item-section>
</w-item>
<w-separator inset spaced="sm" />
<w-item clickable @click="userStore.logout()">
<w-item-section side>

@ -0,0 +1,567 @@
<template>
<w-layout>
<w-header><header-nav /></w-header>
<w-page-container class="layout-userprofile">
<div class="layout-userprofile-inner">
<w-btn
class="layout-userprofile-back"
icon="la:arrow-circle-left"
color="white"
flat
round
@click="goBack">
<w-tooltip anchor="center left" self="center right">{{
t('common.actions.goback')
}}</w-tooltip>
</w-btn>
<!--
IDENTITY
========
Only ever what the person put on their own profile, so a field they left empty is left out
rather than drawn as a row saying nothing.
-->
<w-card v-if="state.notFound" class="layout-userprofile-card">
<w-card-section class="text-center">
<w-icon name="la:user-slash" size="48px" class="text-grey" />
<div class="mt-2 text-body1">{{ t('userProfile.notFound') }}</div>
</w-card-section>
</w-card>
<w-card v-else class="layout-userprofile-card">
<w-card-section class="layout-userprofile-identity">
<w-avatar
class="layout-userprofile-avatar"
size="128px"
:color="state.profile.hasAvatar ? `dark-1` : `primary`"
text-color="white">
<img v-if="state.profile.hasAvatar" :src="`/_user/${userId}/avatar`" alt="" />
<w-icon v-else name="la:user" />
</w-avatar>
<div class="layout-userprofile-identity-text">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 class="text-h6 leading-tight">{{ state.profile.name }}</h1>
<span v-if="state.profile.pronouns" class="text-caption text-grey">{{
state.profile.pronouns
}}</span>
</div>
<div v-if="state.profile.jobTitle" class="text-subtitle2 mt-1">
{{ state.profile.jobTitle }}
</div>
<dl class="layout-userprofile-facts">
<div v-if="state.profile.location">
<dt><w-icon name="la:map-marker" size="xs" />{{ t('userProfile.location') }}</dt>
<dd>{{ state.profile.location }}</dd>
</div>
<div v-if="localTime">
<dt><w-icon name="la:clock" size="xs" />{{ t('userProfile.localTime') }}</dt>
<dd>
{{ localTime }}
<span class="text-caption text-grey">{{ state.profile.timezone }}</span>
</dd>
</div>
<div v-if="state.profile.lastLoginAt">
<dt>
<w-icon name="la:sign-in-alt" size="xs" />{{ t('userProfile.lastLogin') }}
</dt>
<dd>{{ userStore.formatDateTime(t, state.profile.lastLoginAt) }}</dd>
</div>
</dl>
</div>
</w-card-section>
</w-card>
<!--
CONTRIBUTIONS
=============
Two questions of the same person -- what they started and what they touched last -- so the
two tabs are two searches differing only in which column they filter on and which date they
order and display by.
-->
<w-card v-if="!state.notFound" class="layout-userprofile-card mt-6">
<w-card-section>
<w-tabs v-model="state.tab" no-caps inline-label>
<w-tab
v-for="tab of tabs"
:key="tab.name"
:name="tab.name"
:label="tab.label"
:icon="tab.icon" />
</w-tabs>
</w-card-section>
<w-tab-panels v-model="state.tab">
<w-tab-panel v-for="tab of tabs" :key="tab.name" :name="tab.name">
<div
v-if="lists[tab.name].fetched && lists[tab.name].results.length < 1"
class="p-6 text-center">
<em class="text-grey">{{ tab.empty }}</em>
</div>
<w-list separator>
<w-item
v-for="item of lists[tab.name].results"
:key="item.id"
clickable
:to="pageUrl(item)">
<w-item-section avatar>
<w-avatar color="primary" text-color="white" rounded>
<w-icon :name="item.icon || defaultPageIcon" size="24px" />
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
<w-item-label v-if="item.description" caption>{{
item.description
}}</w-item-label>
<w-item-label class="text-grey" caption>{{ pageUrl(item) }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-caption text-right">
{{ userStore.formatDateTime(t, item[tab.dateField]) }}
</div>
<div class="mt-1 flex flex-wrap items-center justify-end gap-1">
<w-chip
v-for="tag of item.tags"
:key="`tag-` + tag"
square
color="secondary"
text-color="white"
icon="la:hashtag"
size="sm"
>{{ tag }}</w-chip
>
</div>
</w-item-section>
</w-item>
</w-list>
<!--
Offered against the offset rather than against how many rows are on screen, for the
same reason the offset is advanced the way it is: rows this reader may not see were
counted by the database and dropped afterwards, so "fewer rows than the total" stays
true of a list that has already reached the end of what there is.
-->
<div v-if="lists[tab.name].offset < lists[tab.name].total" class="p-4 text-center">
<w-btn
outline
no-caps
color="primary"
:label="t(`userProfile.loadMore`)"
:disable="lists[tab.name].loading > 0"
@click="loadMoreCurrent" />
</div>
</w-tab-panel>
</w-tab-panels>
<w-inner-loading :showing="lists[state.tab].loading > 0" />
</w-card>
</div>
<w-footer><footer-nav /></w-footer>
</w-page-container>
<main-overlay-dialog />
</w-layout>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { DEFAULT_PAGE_ICON } from '@/stores/page'
import HeaderNav from '@/components/HeaderNav.vue'
import FooterNav from '@/components/FooterNav.vue'
import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
/** How many pages one tab fetches at a time. Load More asks for the next batch of the same size. */
const PAGE_SIZE = 25
/**
* How often the local-time line is recomputed.
*
* It shows hours and minutes, so a minute is the resolution -- and half of one is what keeps the
* displayed minute at most 30 seconds stale without the page having to work out when the next one
* starts.
*/
const CLOCK_INTERVAL = 30000
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
const route = useRoute()
const router = useRouter()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
profile: {
id: '',
name: '',
hasAvatar: false,
location: '',
jobTitle: '',
pronouns: '',
timezone: '',
lastLoginAt: null
},
notFound: false,
tab: 'created'
})
/**
* One list per tab, fetched the first time its tab is looked at rather than both up front: a reader
* arriving here is shown the pages this person created, and the other list is a second query for a
* tab that may never be opened.
*/
const lists = reactive({
created: { results: [], total: 0, offset: 0, loading: 0, fetched: false },
updated: { results: [], total: 0, offset: 0, loading: 0, fetched: false }
})
/** The moment the clock line is drawn from, ticked by the interval below. */
const now = ref(Temporal.Now.instant())
let clockTimer = null
const defaultPageIcon = DEFAULT_PAGE_ICON
// COMPUTED
const userId = computed(() => route.params.userId)
/**
* What separates the two tabs, in one place: which column the search filters on, and which of the two
* dates it orders and labels the rows by.
*/
const tabs = computed(() => [
{
name: 'created',
label: t('userProfile.pagesCreated'),
icon: 'la:file-alt',
empty: t('userProfile.noPagesCreated'),
filter: 'creatorId',
dateField: 'createdAt',
orderBy: 'createdAt'
},
{
name: 'updated',
label: t('userProfile.pagesUpdated'),
icon: 'la:history',
empty: t('userProfile.noPagesUpdated'),
filter: 'authorId',
dateField: 'updatedAt',
orderBy: 'updatedAt'
}
])
const currentTab = computed(() => tabs.value.find((tb) => tb.name === state.tab))
/**
* What time it is where this person is.
*
* Their zone decides the moment; the READER's 12h/24h preference decides how it is written, because
* that is a preference about reading a clock and not about whose clock it is. A zone that no longer
* resolves -- one renamed since they picked it -- leaves the line out rather than throwing mid-render.
*/
const localTime = computed(() => {
if (!state.profile.timezone) {
return null
}
try {
return now.value
.toZonedDateTimeISO(state.profile.timezone)
.toLocaleString(
undefined,
userStore.timeFormat === '24h'
? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }
: { hour: 'numeric', minute: '2-digit', hour12: true }
)
} catch {
return null
}
})
// META
useMeta(() => {
const siteTitle = siteStore.title
const name = state.profile.name || t('userProfile.title')
return {
title: name,
titleTemplate: (title) => `${title} - ${siteTitle}`
}
})
// WATCHERS
watch(
userId,
async (newValue) => {
if (!newValue) {
return
}
for (const list of Object.values(lists)) {
Object.assign(list, { results: [], total: 0, offset: 0, fetched: false })
}
await fetchProfile()
if (!state.notFound) {
fetchPages()
}
},
{ immediate: true }
)
// -> A tab is fetched when it is first looked at, and never again
watch(
() => state.tab,
() => {
if (!state.notFound && !lists[state.tab].fetched) {
fetchPages()
}
}
)
// METHODS
/**
* Where a page listed here leads.
*
* Per row rather than once for the list, as on the search screen: these are every locale this person
* has written in, and a bare path is the primary locale's address.
*/
function pageUrl(item) {
return `${siteStore.localeUrlPrefix(item.locale)}/${item.path}`
}
function goBack() {
if (history.length > 0) {
router.back()
} else {
router.push('/')
}
}
async function fetchProfile() {
state.notFound = false
try {
const profile = await API_CLIENT.get(`users/${userId.value}/profile`).json()
Object.assign(state.profile, profile)
} catch (err) {
// -> A profile nobody can be shown is the page's own empty state rather than a notification: the
// reader followed a link to a person, and what they need to be told is that there is none
if (err?.response?.status === 404) {
state.notFound = true
return
}
notify({
type: 'negative',
message: t('userProfile.loadingFailed'),
caption: apiErrorMessage(err)
})
}
}
/**
* Fetch one batch of the current tab's list, appending to what is already there.
*
* The listing is the page search with nothing to search for: the filters alone decide the results,
* which is what makes it one endpoint rather than two. That also means it obeys the same rules --
* a page this reader may not open is not listed, and neither is one marked as not searchable.
*/
async function fetchPages() {
const tab = currentTab.value
const list = lists[tab.name]
list.loading++
try {
const resp = await API_CLIENT.get(`sites/${siteStore.id}/pages/search`, {
searchParams: {
[tab.filter]: userId.value,
orderBy: tab.orderBy,
orderByDirection: 'desc',
offset: list.offset,
limit: PAGE_SIZE
}
}).json()
list.results.push(
...(resp?.results ?? []).map((r) => ({ ...r, tags: [...(r.tags ?? [])].sort() }))
)
/*
Advanced by what was ASKED for, not by what came back.
The search filters its rows against this reader's page rules after the database has already
applied the limit, so a batch can arrive short -- or empty -- with more behind it. Counting the
rows that survived would ask for the same batch again on the next press, and a list whose whole
first batch was filtered away would do it for ever.
*/
list.offset += PAGE_SIZE
list.total = resp?.totalHits ?? 0
list.fetched = true
} catch (err) {
notify({
type: 'negative',
message: t('userProfile.pagesLoadingFailed'),
caption: apiErrorMessage(err)
})
} finally {
list.loading--
}
}
function loadMoreCurrent() {
fetchPages()
}
// MOUNTED
onMounted(() => {
clockTimer = setInterval(() => {
now.value = Temporal.Now.instant()
}, CLOCK_INTERVAL)
})
onUnmounted(() => {
clearInterval(clockTimer)
})
</script>
<style lang="scss">
.layout-userprofile {
@at-root .body--light & {
background-color: $grey-3;
}
@at-root .body--dark & {
background-color: $dark-6;
}
/*
The same tinted band the search and profile screens open with, so the three read as one family of
full-screen cards. Fixed rather than scrolled with the content, as it is there.
*/
&:before {
content: '';
height: 200px;
position: fixed;
top: 0;
width: 100%;
background: radial-gradient(ellipse at bottom, $dark-3, $dark-6);
border-bottom: 1px solid #fff;
@at-root .body--dark & {
border-bottom-color: $dark-3;
}
}
&:after {
content: '';
height: 1px;
position: fixed;
top: 64px;
width: 100%;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
}
/*
Narrower than the search and profile cards' 1400px: this page is one column of prose-width content
rather than a sidebar beside a list, and a 1400px identity card leaves its four facts marooned at
either end of an empty band.
*/
&-inner {
position: relative;
width: 90%;
max-width: 900px;
margin: 50px auto;
}
&-back {
position: absolute;
left: -50px;
}
&-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5rem;
}
/*
`min-width` on the text column is what lets the row above actually wrap: with none, the column
shrinks to nothing beside the avatar instead of taking a line of its own on a narrow screen.
*/
&-identity-text {
flex: 1 1 auto;
min-width: 15rem;
}
&-avatar {
// -> An uploaded avatar is a square image; the container's radius is what makes it a circle
overflow: hidden;
}
/*
The facts under the name: label above value, as many across as fit. A definition list because that
is what it is -- each row names a thing and gives its value.
*/
&-facts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 2rem;
margin-top: 1rem;
dt {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: var(--text-caption);
letter-spacing: var(--text-caption--letter-spacing);
text-transform: uppercase;
color: $grey-7;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.55);
}
}
dd {
margin: 0;
font-size: var(--text-body2);
line-height: var(--text-body2--line-height);
}
}
/*
Below 1024px there is no gutter left to work with. The card's is 5% of the window on each side --
it only stops being a percentage once the window is wider than the 900px cap -- so under 1000px it
is narrower than the 50px the back button is offset by, and the button slides off the left edge of
the screen. Stated at the breakpoint just above that, since a button half in the gutter is no
better than one outside it.
It is dropped rather than moved: inside the card the only place for it is on top of the avatar, and
a reader on a narrow screen has both the browser's own back gesture and the header above it.
*/
@media (max-width: $breakpoint-sm-max) {
&-inner {
width: 95%;
margin: 25px auto;
}
&-back {
display: none;
}
}
}
</style>

@ -49,6 +49,15 @@ const routes = [
path: '/_search',
component: () => import('@/pages/Search.vue')
},
/*
The public profile of one user. `/_user` is shared with the server, which serves avatars at
`/_user/<id>/avatar` -- both `backend/index.ts` and the dev proxy in `frontend/vite.config.js`
split the segment the same way, so this route only ever sees the profile half.
*/
{
path: '/_user/:userId',
component: () => import('@/pages/UserProfile.vue')
},
{
path: '/_admin',
component: () => import('@/layouts/AdminLayout.vue'),

@ -211,17 +211,24 @@ export default defineConfig(({ mode }) => {
allowedHosts: true,
port: userConfig.dev?.port,
proxy: [
'_api',
'_blocks',
'_collab',
'_files',
'_icons',
'_site',
'_terminal',
'_thumb',
'_user'
'/_api',
'/_blocks',
'/_collab',
'/_files',
'/_icons',
'/_site',
'/_terminal',
'/_thumb',
/*
Not `/_user`: that segment is shared. The backend serves avatars under it, while the app's
own router owns the public profile page at `/_user/<id>` -- which has to be served by THIS
dev server, or it would come back as the built shell from `assets/` and boot yesterday's
bundle. A key starting with `^` is a regular expression to Vite, which is how the two are
told apart. `backend/index.ts` draws the same line from the other side.
*/
'^/_user/[^/]+/avatar'
].reduce((result, key) => {
result[`/${key}`] = {
result[key] = {
target: {
host: '127.0.0.1',
port: userConfig.port

Loading…
Cancel
Save