feat: page ratings + last edited by sections

pull/8104/head
NGPixel 4 days ago
parent a7330974a2
commit 7e6fcc09c3
No known key found for this signature in database

@ -50,6 +50,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./navigation.ts')) app.register(import('./navigation.ts'))
app.register(import('./pages.ts')) app.register(import('./pages.ts'))
app.register(import('./ratings.ts'))
app.register(import('./scheduler.ts'), { prefix: '/scheduler' }) app.register(import('./scheduler.ts'), { prefix: '/scheduler' })
app.register(import('./sites.ts'), { prefix: '/sites' }) app.register(import('./sites.ts'), { prefix: '/sites' })
app.register(import('./storage.ts')) app.register(import('./storage.ts'))

@ -676,7 +676,11 @@ async function routes(app: FastifyInstance) {
is what makes a page view one request instead of four. is what makes a page view one request instead of four.
*/ */
const actorId = actor?.id ?? null const actorId = actor?.id ?? null
const [approvalState, isWatching, commentsCount, blog] = await Promise.all([ // -> Both switches: the site's ratings mode and the page's own `allowRatings`
const ratingMode = page.allowRatings
? WIKI.models.pageRatings.modeFor(req.params.siteId)
: null
const [approvalState, isWatching, commentsCount, blog, ownRating] = await Promise.all([
WIKI.models.approvals.pageViewerState(req, req.params.siteId, { WIKI.models.approvals.pageViewerState(req, req.params.siteId, {
id: page.id, id: page.id,
path: page.path, path: page.path,
@ -699,11 +703,19 @@ async function routes(app: FastifyInstance) {
which is one lookup on the unique `(siteId, locale, path)` index over the page's own which is one lookup on the unique `(siteId, locale, path)` index over the page's own
ancestors, and no lookup at all for a page at the site root. ancestors, and no lookup at all for a page at the site root.
*/ */
WIKI.models.blogs.blogFor(req.params.siteId, page.locale, page.path) WIKI.models.blogs.blogFor(req.params.siteId, page.locale, page.path),
/*
The reader's own rating, which is theirs and so cannot be cached on the page the way the
totals are: one lookup on the primary key, and none for a guest or where ratings are off.
*/
ratingMode ? WIKI.models.pageRatings.valueFor(page.id, actorId, ratingMode) : 0
]) ])
const { ratings, ...pageFields } = page
return { return {
...page, ...pageFields,
commentsCount, commentsCount,
// -> Off the totals cached on the row just loaded, so no aggregate per view
rating: ratingMode ? WIKI.models.pageRatings.summaryFromCache(ratings, ratingMode) : null,
/* /*
Only what the page view draws: a post shows the name of the blog it is in and links to it. Only what the page view draws: a post shows the name of the blog it is in and links to it.
The blog's own settings are not a fact about this page -- the front page carries them, and The blog's own settings are not a fact about this page -- the front page carries them, and
@ -713,7 +725,8 @@ async function routes(app: FastifyInstance) {
viewer: { viewer: {
permissions: pagePermissionsFor(req, page), permissions: pagePermissionsFor(req, page),
...approvalState, ...approvalState,
isWatching isWatching,
rating: ownRating
} }
} }
} }

@ -0,0 +1,175 @@
import { audit } from '../helpers/audit.ts'
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { RatingMode } from '../models/pageRatings.ts'
import type { Page } from '../models/pages.ts'
const pageParams = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
}
const ratingResponse = {
description: 'How the page is rated now, and what the caller gave it',
type: 'object',
properties: {
ok: { type: 'boolean' },
rating: { $ref: 'PageRatingSummary#' },
value: {
type: 'integer',
description: 'The caller’s own rating, 0 once withdrawn.'
}
}
}
/**
* The page being rated and the scale it is rated on, or a refusal.
*
* Rating belongs to an account — one opinion per person needs a person to hang it on — so an
* anonymous request never gets as far as the page. Beyond that it is the same gate as reading: a page
* somebody may not read is answered as though it were not there. Both switches have to be on, the
* site's ratings mode and the page's own `allowRatings`.
*/
async function loadRateable(
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
reply: FastifyReply
): Promise<{ userId: string; mode: RatingMode; page: Page } | null> {
const actor = actorFrom(req)
if (!actor) {
reply.unauthorized('Rating a page requires a logged in user.')
return null
}
const mode = WIKI.models.pageRatings.modeFor(req.params.siteId)
if (!mode) {
reply.forbidden('Ratings are turned off for this site.')
return null
}
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId,
unlocked: (id: string) => unlockedFor(req, id)
})
if (!page || !mayOnPage(req, 'read:pages', page)) {
reply.notFound('This page does not exist.')
return null
}
if (!page.allowRatings) {
reply.forbidden('Ratings are turned off for this page.')
return null
}
return { userId: actor.id, mode, page }
}
/**
* Page Ratings API Routes
*
* A reader's thumbs or stars for a page. How a page is rated so far comes with the page itself
* (`rating`, and the reader's own under `viewer.rating`), so there is no read route here.
*
* No route-level `permissions` on either: the test is `read:pages` on the page, which a group's page
* rules decide and the hook in `index.ts` cannot see.
*/
async function routes(app: FastifyInstance) {
/**
* RATE A PAGE
*/
app.put<{ Params: { siteId: string; pageId: string }; Body: { value: number } }>(
'/sites/:siteId/pages/:pageId/rating',
{
schema: {
summary: 'Rate a page',
description:
'Records the caller’s rating of the page, replacing any they gave it before. The scale is the site’s ratings mode: `1` or `-1` for thumbs, `1` to `5` for stars.\n\nNeeds a logged in user who may read the page, with ratings on for both the site and the page.',
tags: ['Pages'],
params: pageParams,
body: {
type: 'object',
required: ['value'],
properties: {
value: { type: 'integer', minimum: -1, maximum: 5 }
}
},
response: { 200: ratingResponse }
}
},
async (req, reply) => {
const target = await loadRateable(req, reply)
if (!target) {
return reply
}
const { userId, mode, page } = target
if (!WIKI.models.pageRatings.isValid(mode, req.body.value)) {
return reply.badRequest(
mode === 'thumbs'
? 'A thumbs rating is 1 or -1.'
: 'A star rating is a whole number from 1 to 5.'
)
}
const totals = await WIKI.models.pageRatings.rate({
pageId: page.id,
userId,
mode,
value: req.body.value
})
await audit(req, 'page', 'ratePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
mode,
value: req.body.value
})
return {
ok: true,
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
value: req.body.value
}
}
)
/**
* WITHDRAW A RATING
*/
app.delete<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/rating',
{
schema: {
summary: 'Withdraw a rating',
description:
'Forgets the caller’s rating of the page. A page they had not rated answers the same way, since the outcome asked for already holds.',
tags: ['Pages'],
params: pageParams,
response: { 200: ratingResponse }
}
},
async (req, reply) => {
const target = await loadRateable(req, reply)
if (!target) {
return reply
}
const { userId, mode, page } = target
const totals = await WIKI.models.pageRatings.unrate({ pageId: page.id, userId })
await audit(req, 'page', 'unratePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path
})
return {
ok: true,
rating: WIKI.models.pageRatings.summaryFromCache(totals, mode),
value: 0
}
}
)
}
export default routes

@ -125,6 +125,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
allowComments: { type: 'boolean' }, allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' }, allowContributions: { type: 'boolean' },
allowRatings: { type: 'boolean' }, allowRatings: { type: 'boolean' },
showLastEditedBy: { type: 'boolean' },
showSidebar: { type: 'boolean' }, showSidebar: { type: 'boolean' },
showTags: { type: 'boolean' }, showTags: { type: 'boolean' },
showToc: { type: 'boolean' }, showToc: { type: 'boolean' },
@ -229,6 +230,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'How many comments this page has, which is what the Talk tab’s badge counts. Always 0 unless the site uses the built-in comments provider. Present when a page is fetched on its own.' 'How many comments this page has, which is what the Talk tab’s badge counts. Always 0 unless the site uses the built-in comments provider. Present when a page is fetched on its own.'
}, },
rating: {
anyOf: [{ type: 'null' }, { $ref: 'PageRatingSummary#' }],
description:
'How readers have rated the page, on the site’s current scale. Null when ratings are off for the site or for the page. Present when a page is fetched on its own.'
},
showLastEditedBy: { type: 'boolean' },
showSidebar: { type: 'boolean' }, showSidebar: { type: 'boolean' },
showTags: { type: 'boolean' }, showTags: { type: 'boolean' },
showToc: { type: 'boolean' }, showToc: { type: 'boolean' },
@ -246,6 +253,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
navigationMode: { type: 'string' }, navigationMode: { type: 'string' },
authorId: { type: 'string', format: 'uuid' }, authorId: { type: 'string', format: 'uuid' },
authorName: { type: 'string' }, authorName: { type: 'string' },
authorHasAvatar: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' },
blog: { blog: {
@ -287,6 +295,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'The requester has asked to be told about changes to this page. Always false without an account, since a watch belongs to one.' 'The requester has asked to be told about changes to this page. Always false without an account, since a watch belongs to one.'
}, },
rating: {
type: 'integer',
description:
'The requester’s own rating of this page on the site’s current scale, or 0 for none. Always 0 without an account, since a rating belongs to one.'
},
pendingSubmissions: { pendingSubmissions: {
type: 'array', type: 'array',
items: { $ref: 'PageEditSubmission#' }, items: { $ref: 'PageEditSubmission#' },
@ -297,6 +310,28 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
}) })
/**
* PAGE RATING SUMMARY - How readers have rated a page, on the site's current scale
*/
app.addSchema({
$id: 'PageRatingSummary',
type: 'object',
properties: {
mode: {
type: 'string',
enum: ['thumbs', 'stars'],
description: 'The scale. Only ratings given on it are counted.'
},
count: { type: 'integer', description: 'How many readers have rated the page.' },
average: {
type: 'number',
description: 'Mean rating: 1 to 5 for stars, -1 to 1 for thumbs. 0 when nobody has rated.'
},
up: { type: 'integer', description: 'Thumbs up. Always 0 under stars.' },
down: { type: 'integer', description: 'Thumbs down. Always 0 under stars.' }
}
})
/** /**
* WATCHED PAGE - A page somebody asked to be told about, as their inbox lists it * WATCHED PAGE - A page somebody asked to be told about, as their inbox lists it
*/ */

@ -91,18 +91,22 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
collaborativeEditing: { collaborativeEditing: {
type: 'boolean' type: 'boolean'
}, },
ratings: {
type: 'boolean'
},
ratingsMode: { ratingsMode: {
type: 'string', type: 'string',
enum: ['off', 'stars', 'thumbs'] enum: ['off', 'stars', 'thumbs'],
description:
'Whether readers may rate pages, and on which scale. Off by default. A page can still opt out on its own with `allowRatings`; only a logged in reader who may read the page can rate it.'
}, },
comments: { comments: {
type: 'boolean', type: 'boolean',
description: description:
'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.' 'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.'
}, },
lastEditedBy: {
type: 'boolean',
description:
'Whether a page may show who last edited it, in its sidebar. A page can still opt out on its own with `showLastEditedBy`.'
},
reasonForChange: { reasonForChange: {
type: 'string', type: 'string',
enum: ['off', 'optional', 'required'] enum: ['off', 'optional', 'required']

@ -487,11 +487,6 @@ async function routes(app: FastifyInstance) {
} }
} }
// -> Keep the legacy `features.ratings` flag in sync with the ratings mode
if (config.features?.ratingsMode !== undefined) {
config.features.ratings = config.features.ratingsMode !== 'off'
}
/* /*
The pasted-uploads destination is stored in one form, so that what the admin area reads back is The pasted-uploads destination is stored in one form, so that what the admin area reads back is
what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and

@ -0,0 +1,16 @@
CREATE TABLE "pageRatings" (
"pageId" uuid,
"userId" uuid,
"kind" varchar(16) NOT NULL,
"value" integer NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "pageRatings_pkey" PRIMARY KEY("pageId","userId")
);
--> statement-breakpoint
ALTER TABLE "pages" ADD COLUMN "ratings" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "pages" DROP COLUMN "ratingScore";--> statement-breakpoint
ALTER TABLE "pages" DROP COLUMN "ratingCount";--> statement-breakpoint
CREATE INDEX "pageRatings_userId_idx" ON "pageRatings" ("userId");--> statement-breakpoint
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageRatings" ADD CONSTRAINT "pageRatings_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;

@ -679,8 +679,12 @@ export const pages = pgTable(
(): SQL => sql`${pages.publishState} != 'draft' AND ${pages.isSearchable}` (): SQL => sql`${pages.publishState} != 'draft' AND ${pages.isSearchable}`
), ),
password: varchar({ length: 255 }), password: varchar({ length: 255 }),
ratingScore: integer().notNull().default(0), /**
ratingCount: timestamp().notNull().defaultNow(), * How readers have rated the page, per scale: `{ thumbs?: { count, sum, up, down }, stars?: … }`.
* A cache of the `pageRatings` rows, rewritten by every rating and withdrawal (`models/pageRatings.ts`)
* so that a page view reads it off the row it already loads instead of aggregating per view.
*/
ratings: jsonb().notNull().default({}),
scripts: jsonb().notNull().default({}), scripts: jsonb().notNull().default({}),
historyData: jsonb().notNull().default({}), historyData: jsonb().notNull().default({}),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
@ -962,6 +966,39 @@ export const pageWatching = pgTable(
] ]
) )
// PAGE RATINGS ------------------------
/**
* One reader's rating of one page.
*
* `kind` is the site's ratings mode the rating was given under — `thumbs` (`value` is 1 or -1) or
* `stars` (1 to 5) — because the two scales cannot be added together, and a site may switch between
* them. Only the rows of the mode in force are counted, so switching back finds the old ratings where
* they were. Rating again under the other mode replaces the row: a reader has one opinion of a page.
*
* The totals are cached on the page (`pages.ratings`), one entry per scale, and rewritten from these
* rows whenever one of the page's changes. A rating removed by a deleted account's cascade is not
* subtracted until the page is next rated.
*/
export const pageRatings = pgTable(
'pageRatings',
{
pageId: uuid()
.notNull()
.references(() => pages.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: varchar({ length: 16 }).notNull(),
value: integer().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [
primaryKey({ columns: [table.pageId, table.userId] }),
index('pageRatings_userId_idx').on(table.userId)
]
)
// PAGE RENDER QUEUE ------------------- // PAGE RENDER QUEUE -------------------
/** /**
* A page waiting for the server to render it, one row per page. * A page waiting for the server to render it, one row per page.

@ -182,6 +182,7 @@
"admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders", "admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders",
"admin.audit.actions.purgePageHistory": "Purged page history", "admin.audit.actions.purgePageHistory": "Purged page history",
"admin.audit.actions.purgeSampleContent": "Purged the sample content", "admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.ratePage": "Rated a page",
"admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index", "admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index",
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index", "admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
"admin.audit.actions.refreshIconSets": "Refreshed the icon sets", "admin.audit.actions.refreshIconSets": "Refreshed the icon sets",
@ -205,6 +206,7 @@
"admin.audit.actions.togglePasswordLogin": "Turned password sign-in on or off", "admin.audit.actions.togglePasswordLogin": "Turned password sign-in on or off",
"admin.audit.actions.unassignUserFromGroup": "Removed a user from a group", "admin.audit.actions.unassignUserFromGroup": "Removed a user from a group",
"admin.audit.actions.unlockPage": "Unlocked a password-protected page", "admin.audit.actions.unlockPage": "Unlocked a password-protected page",
"admin.audit.actions.unratePage": "Withdrew a page rating",
"admin.audit.actions.unwatchPage": "Stopped watching a page", "admin.audit.actions.unwatchPage": "Stopped watching a page",
"admin.audit.actions.updateAnalytics": "Changed the analytics configuration", "admin.audit.actions.updateAnalytics": "Changed the analytics configuration",
"admin.audit.actions.updateApiState": "Turned the API on or off", "admin.audit.actions.updateApiState": "Turned the API on or off",
@ -484,8 +486,10 @@
"admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.", "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.",
"admin.general.allowComments": "Allow Comments", "admin.general.allowComments": "Allow Comments",
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
"admin.general.allowLastEditedBy": "Allow Last Edited By",
"admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?",
"admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatings": "Allow Ratings",
"admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.", "admin.general.allowRatingsHint": "Can logged in users rate the pages they can read, and on which scale? A page can still turn ratings off in its properties.",
"admin.general.allowSearch": "Allow Search", "admin.general.allowSearch": "Allow Search",
"admin.general.allowSearchHint": "Can users search for content they have read access to?", "admin.general.allowSearchHint": "Can users search for content they have read access to?",
"admin.general.banner": "Site-wide Banner", "admin.general.banner": "Site-wide Banner",
@ -1936,6 +1940,11 @@
"common.page.private": "Private", "common.page.private": "Private",
"common.page.published": "Published", "common.page.published": "Published",
"common.page.ratePage": "Rate this page", "common.page.ratePage": "Rate this page",
"common.page.ratingAverage": "{average} out of 5",
"common.page.ratingCount": "No ratings yet | 1 rating | {count} ratings",
"common.page.ratingFailed": "Could not save your rating.",
"common.page.ratingThumbsDown": "Thumbs down",
"common.page.ratingThumbsUp": "Thumbs up",
"common.page.returnNormalView": "Return to Normal View", "common.page.returnNormalView": "Return to Normal View",
"common.page.share": "Share", "common.page.share": "Share",
"common.page.suggestDiscarded": "Your suggested edits have been discarded.", "common.page.suggestDiscarded": "Your suggested edits have been discarded.",
@ -2296,6 +2305,7 @@
"editor.props.selectIcon": "Select Icon...", "editor.props.selectIcon": "Select Icon...",
"editor.props.shortDescription": "Short Description", "editor.props.shortDescription": "Short Description",
"editor.props.showInTree": "Show in Site Navigation", "editor.props.showInTree": "Show in Site Navigation",
"editor.props.showLastEditedBy": "Show Last Edited By",
"editor.props.showSidebar": "Show Sidebar", "editor.props.showSidebar": "Show Sidebar",
"editor.props.showTags": "Show Tags", "editor.props.showTags": "Show Tags",
"editor.props.showToc": "Show Table of Contents", "editor.props.showToc": "Show Table of Contents",

@ -35,6 +35,8 @@ export const AUDIT_ACTIONS = {
'unlockPage', 'unlockPage',
'watchPage', 'watchPage',
'unwatchPage', 'unwatchPage',
'ratePage',
'unratePage',
'submitPageEdit', 'submitPageEdit',
'approvePageEdit', 'approvePageEdit',
'rejectPageEdit', 'rejectPageEdit',

@ -20,6 +20,7 @@ import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts' import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts' import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts' import { pageLinks } from './pageLinks.ts'
import { pageRatings } from './pageRatings.ts'
import { pages } from './pages.ts' import { pages } from './pages.ts'
import { pageWatching } from './pageWatching.ts' import { pageWatching } from './pageWatching.ts'
import { passkeys } from './passkeys.ts' import { passkeys } from './passkeys.ts'
@ -59,6 +60,7 @@ export default {
navigation, navigation,
pageHistory, pageHistory,
pageLinks, pageLinks,
pageRatings,
pages, pages,
pageWatching, pageWatching,
passkeys, passkeys,

@ -44,7 +44,7 @@ export type PurgeTimeframe = keyof typeof purgeTimeframes
* Taken straight off the stored row, so a field added to a page is captured here without this list * Taken straight off the stored row, so a field added to a page is captured here without this list
* being touched. The exclusions are either derived from the content (`render`, `searchContent`, * being touched. The exclusions are either derived from the content (`render`, `searchContent`,
* `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping * `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping
* that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratingScore`, `ratingCount`, * that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratings`,
* `historyData`, `isSearchableComputed`). * `historyData`, `isSearchableComputed`).
* *
* `toc` is derived as well and is kept regardless, because the version view draws a contents column * `toc` is derived as well and is kept regardless, because the version view draws a contents column
@ -66,8 +66,7 @@ const EXCLUDED_FROM_META = new Set([
'render', 'render',
'searchContent', 'searchContent',
'ts', 'ts',
'ratingScore', 'ratings',
'ratingCount',
'historyData', 'historyData',
'isSearchableComputed', 'isSearchableComputed',
// -> Held in columns of their own // -> Held in columns of their own
@ -91,8 +90,7 @@ const NOT_REPORTED_AS_CHANGED = new Set([
'hash', 'hash',
'authorId', 'authorId',
'updatedAt', 'updatedAt',
'ratingScore', 'ratings',
'ratingCount',
'historyData', 'historyData',
'isSearchableComputed' 'isSearchableComputed'
]) ])

@ -0,0 +1,188 @@
import { and, eq, sql } from 'drizzle-orm'
import { pageRatings as ratingsTable, pages as pagesTable } from '../db/schema.ts'
/** The two scales a site can rate its pages on. `off` is the absence of one. */
export const RATING_MODES = ['thumbs', 'stars'] as const
export type RatingMode = (typeof RATING_MODES)[number]
/** The totals of one scale, as cached on the page. */
interface RatingTotals {
count: number
sum: number
up: number
down: number
}
/** `pages.ratings`: the totals per scale, absent for a scale nobody has rated the page on. */
export type PageRatingsCache = Partial<Record<RatingMode, RatingTotals>>
/** How a page has been rated, under the mode in force. */
export interface RatingSummary {
mode: RatingMode
/** How many readers have rated it. */
count: number
/** Mean of the ratings, 1 to 5 for stars and -1 to 1 for thumbs. 0 when nobody has rated it. */
average: number
/** Thumbs up. Always 0 under stars. */
up: number
/** Thumbs down. Always 0 under stars. */
down: number
}
/**
* Page ratings model
*
* One rating per reader per page, on whichever scale the site uses — see the table's comment in
* `db/schema.ts`. Who may rate is the route's question (a logged in reader who may read the page);
* everything here is about the scale and the totals.
*
* **The totals are cached on the page**, in `pages.ratings`, and a page view reads them from there:
* pages are read far more often than they are rated, so the aggregate is worked out on the write.
* Every scale is kept, so switching the site between thumbs and stars and back finds the totals where
* they were.
*/
class PageRatings {
/**
* The scale this site rates on, or null when ratings are off for it.
*
* `features.ratingsMode` under General → Features. A page's own `allowRatings` is the other half,
* and is the caller's to check, since it is a property of the page in hand.
*/
modeFor(siteId: string): RatingMode | null {
const mode = WIKI.sites[siteId]?.config?.features?.ratingsMode
return (RATING_MODES as readonly string[]).includes(mode) ? mode : null
}
/** Whether `value` is a rating the scale accepts: ±1 for thumbs, a whole 1 to 5 for stars. */
isValid(mode: RatingMode, value: number): boolean {
if (!Number.isInteger(value)) {
return false
}
return mode === 'thumbs' ? value === 1 || value === -1 : value >= 1 && value <= 5
}
/** How a page has been rated under `mode`, from the totals cached on it. No query. */
summaryFromCache(cache: PageRatingsCache | null | undefined, mode: RatingMode): RatingSummary {
const totals = cache?.[mode]
const count = totals?.count ?? 0
return {
mode,
count,
average: count > 0 ? Math.round(((totals?.sum ?? 0) / count) * 100) / 100 : 0,
up: mode === 'thumbs' ? (totals?.up ?? 0) : 0,
down: mode === 'thumbs' ? (totals?.down ?? 0) : 0
}
}
/**
* This reader's own rating of a page under `mode`, or 0 for none.
*
* The one part of a page view's rating that cannot come off the page, being the reader's own: a
* single lookup on the primary key, and none for a guest. A rating given under the other scale
* reads as none, since it cannot be drawn on this one and rating again replaces it.
*/
async valueFor(pageId: string, userId: string | null, mode: RatingMode): Promise<number> {
if (!userId) {
return 0
}
const [row] = await WIKI.db
.select({ value: ratingsTable.value })
.from(ratingsTable)
.where(
and(
eq(ratingsTable.pageId, pageId),
eq(ratingsTable.userId, userId),
eq(ratingsTable.kind, mode)
)
)
return row?.value ?? 0
}
/**
* Rate a page, replacing whatever this reader had given it before, on either scale.
*
* @returns The page's totals as they stand after the rating.
*/
async rate({
pageId,
userId,
mode,
value
}: {
pageId: string
userId: string
mode: RatingMode
value: number
}): Promise<PageRatingsCache> {
return this.#changeAndRecount(pageId, (tx) =>
tx
.insert(ratingsTable)
.values({ pageId, userId, kind: mode, value })
.onConflictDoUpdate({
target: [ratingsTable.pageId, ratingsTable.userId],
set: { kind: mode, value, updatedAt: new Date() }
})
)
}
/**
* Withdraw this reader's rating of a page. Idempotent: no row is the outcome asked for.
*
* @returns The page's totals as they stand after the withdrawal.
*/
async unrate({ pageId, userId }: { pageId: string; userId: string }): Promise<PageRatingsCache> {
return this.#changeAndRecount(pageId, (tx) =>
tx
.delete(ratingsTable)
.where(and(eq(ratingsTable.pageId, pageId), eq(ratingsTable.userId, userId)))
)
}
/**
* Apply a change to a page's ratings and rewrite the totals cached on it, in one transaction.
*
* The page row is locked first. Two readers rating at once would otherwise each count the rows
* without the other's uncommitted one, and whichever committed last would cache a total that is one
* rating short for good. Recounted rather than adjusted by the difference, so a cache that has
* drifted (a rating removed by a deleted account's cascade) is put right by the next rating.
*/
async #changeAndRecount(
pageId: string,
change: (tx: any) => Promise<unknown>
): Promise<PageRatingsCache> {
return WIKI.db.transaction(async (tx) => {
await tx
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, pageId))
.for('update')
await change(tx)
const rows = await tx
.select({
kind: ratingsTable.kind,
count: sql<number>`count(*)::int`,
sum: sql<number>`coalesce(sum(${ratingsTable.value}), 0)::int`,
up: sql<number>`(count(*) filter (where ${ratingsTable.value} > 0))::int`,
down: sql<number>`(count(*) filter (where ${ratingsTable.value} < 0))::int`
})
.from(ratingsTable)
.where(eq(ratingsTable.pageId, pageId))
.groupBy(ratingsTable.kind)
const cache: PageRatingsCache = {}
for (const row of rows) {
if ((RATING_MODES as readonly string[]).includes(row.kind)) {
cache[row.kind as RatingMode] = {
count: Number(row.count),
sum: Number(row.sum),
up: Number(row.up),
down: Number(row.down)
}
}
}
await tx.update(pagesTable).set({ ratings: cache }).where(eq(pagesTable.id, pageId))
return cache
})
}
}
export const pageRatings = new PageRatings()

@ -13,6 +13,7 @@ import type { FastifyRequest } from 'fastify'
import type { RenderPermissions, TocNode } from './rendering.ts' import type { RenderPermissions, TocNode } from './rendering.ts'
import type { DeletedEntry } from './tree.ts' import type { DeletedEntry } from './tree.ts'
import type { StoragePageContent, StoragePageRef } from './storage.ts' import type { StoragePageContent, StoragePageRef } from './storage.ts'
import type { PageRatingsCache } from './pageRatings.ts'
/** What each editor produces, which is what the content column holds. */ /** What each editor produces, which is what the content column holds. */
const EDITOR_CONTENT_TYPES: Record<string, string> = { const EDITOR_CONTENT_TYPES: Record<string, string> = {
@ -205,6 +206,7 @@ const CONFIG_FIELDS = [
'allowComments', 'allowComments',
'allowContributions', 'allowContributions',
'allowRatings', 'allowRatings',
'showLastEditedBy',
'showSidebar', 'showSidebar',
'showTags', 'showTags',
'showToc', 'showToc',
@ -294,6 +296,7 @@ export interface Page {
allowComments: boolean allowComments: boolean
allowContributions: boolean allowContributions: boolean
allowRatings: boolean allowRatings: boolean
showLastEditedBy: boolean
showSidebar: boolean showSidebar: boolean
showTags: boolean showTags: boolean
showToc: boolean showToc: boolean
@ -305,6 +308,13 @@ export interface Page {
navigationMode: string navigationMode: string
authorId: string authorId: string
authorName: string authorName: string
/** Whether the account that saved the version that stands has uploaded an avatar. */
authorHasAvatar: boolean
/**
* The cached rating totals, per scale. Internal: the API answers with `rating`, the summary on the
* site's current scale — see `pageRatings.summaryFromCache`.
*/
ratings: PageRatingsCache
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
} }
@ -340,6 +350,7 @@ export interface PageInput {
allowComments?: boolean allowComments?: boolean
allowContributions?: boolean allowContributions?: boolean
allowRatings?: boolean allowRatings?: boolean
showLastEditedBy?: boolean
showSidebar?: boolean showSidebar?: boolean
showTags?: boolean showTags?: boolean
showToc?: boolean showToc?: boolean
@ -798,6 +809,7 @@ class Pages {
allowComments: config.allowComments ?? true, allowComments: config.allowComments ?? true,
allowContributions: config.allowContributions ?? true, allowContributions: config.allowContributions ?? true,
allowRatings: config.allowRatings ?? true, allowRatings: config.allowRatings ?? true,
showLastEditedBy: config.showLastEditedBy ?? true,
showSidebar: config.showSidebar ?? true, showSidebar: config.showSidebar ?? true,
showTags: config.showTags ?? true, showTags: config.showTags ?? true,
showToc: config.showToc ?? true, showToc: config.showToc ?? true,
@ -809,6 +821,8 @@ class Pages {
navigationMode: row.navigationMode ?? 'inherit', navigationMode: row.navigationMode ?? 'inherit',
authorId: row.authorId, authorId: row.authorId,
authorName: row.authorName ?? '', authorName: row.authorName ?? '',
authorHasAvatar: row.authorHasAvatar ?? false,
ratings: row.ratings ?? {},
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt updatedAt: row.updatedAt
} }
@ -1556,6 +1570,7 @@ class Pages {
.select({ .select({
page: pagesTable, page: pagesTable,
authorName: usersTable.name, authorName: usersTable.name,
authorHasAvatar: usersTable.hasAvatar,
navigationId: treeTable.navigationId, navigationId: treeTable.navigationId,
navigationMode: treeTable.navigationMode navigationMode: treeTable.navigationMode
}) })
@ -1583,6 +1598,7 @@ class Pages {
{ {
...row.page, ...row.page,
authorName: row.authorName, authorName: row.authorName,
authorHasAvatar: row.authorHasAvatar,
navigationId: row.navigationId, navigationId: row.navigationId,
navigationMode: row.navigationMode, navigationMode: row.navigationMode,
// -> A second query only for a page that is part of a set, which is a column read away // -> A second query only for a page that is part of a set, which is a column read away
@ -2877,6 +2893,7 @@ class Pages {
allowComments: input.allowComments ?? existing.allowComments ?? true, allowComments: input.allowComments ?? existing.allowComments ?? true,
allowContributions: input.allowContributions ?? existing.allowContributions ?? true, allowContributions: input.allowContributions ?? existing.allowContributions ?? true,
allowRatings: input.allowRatings ?? existing.allowRatings ?? true, allowRatings: input.allowRatings ?? existing.allowRatings ?? true,
showLastEditedBy: input.showLastEditedBy ?? existing.showLastEditedBy ?? true,
showSidebar: input.showSidebar ?? existing.showSidebar ?? true, showSidebar: input.showSidebar ?? existing.showSidebar ?? true,
showTags: input.showTags ?? existing.showTags ?? true, showTags: input.showTags ?? existing.showTags ?? true,
showToc: input.showToc ?? existing.showToc ?? true, showToc: input.showToc ?? existing.showToc ?? true,

@ -370,6 +370,60 @@ class Search {
? sql`CASE WHEN p.password IS NULL THEN ${headline} ELSE NULL END` ? sql`CASE WHEN p.password IS NULL THEN ${headline} ELSE NULL END`
: headline : headline
const where = sql.join(conditions, sql` AND `)
const relevancy = hasQuery ? sql`ts_rank(p.ts, ${tsQuery})` : sql`0`
/*
Which pages this searcher may read is decided BEFORE the results are paged, never after.
A page rule can be a regular expression or a set of tags, so the deciding rule is only knowable
per row and cannot go in the `WHERE`. Filtering the rows of one `LIMIT`ed batch instead used to
leave that batch short -- or empty, with a Load More under it -- and a total still counting every
page the searcher had been refused, which is itself a statement about pages they may not see. A
title and a count are content too.
So with rules to apply, the first query ranks every match but reads only what a rule looks at,
and the second fetches the full row -- excerpt included, the expensive part -- for the one batch
that is returned. An actor who is above the rules gets the database's own paging and count.
*/
const checkRules = !!actor && !actor.permissions.includes('manage:system')
let pageIds: string[]
let totalHits: number
if (checkRules) {
const candidates = await WIKI.db.execute(sql`
SELECT p.id, p.path, p.locale, p.tags, ${relevancy} AS relevancy
FROM pages p
WHERE ${where}
ORDER BY ${ordering}
`)
const readable = ((candidates.rows ?? candidates) as any[]).filter((row) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId,
path: row.path as string,
locale: row.locale as string,
tags: (row.tags ?? []) as string[]
})
)
pageIds = readable.slice(offset, offset + limit).map((row) => row.id as string)
totalHits = readable.length
} else {
const candidates = await WIKI.db.execute(sql`
SELECT p.id, ${relevancy} AS relevancy, COUNT(*) OVER() AS "totalHits"
FROM pages p
WHERE ${where}
ORDER BY ${ordering}
LIMIT ${limit} OFFSET ${offset}
`)
const rows = (candidates.rows ?? candidates) as any[]
pageIds = rows.map((row) => row.id as string)
totalHits = Number(rows[0]?.totalHits ?? 0)
}
if (pageIds.length < 1) {
return { results: [], totalHits }
}
const rows = await WIKI.db.execute(sql` const rows = await WIKI.db.execute(sql`
SELECT SELECT
p.id, p.id,
@ -381,65 +435,37 @@ class Search {
p.tags, p.tags,
to_char(p."createdAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "createdAt", 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", 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, ${relevancy} AS relevancy,
${highlight} AS highlight, ${highlight} AS highlight
COUNT(*) OVER() AS "totalHits"
FROM pages p FROM pages p
WHERE ${sql.join(conditions, sql` AND `)} WHERE p.id = ANY(${sql.param(pageIds)}::uuid[])
ORDER BY ${ordering}
LIMIT ${limit} OFFSET ${offset}
`) `)
// -> `ANY` answers in whatever order the planner likes; the order is the first query's
/* const byId = new Map(((rows.rows ?? rows) as any[]).map((row) => [row.id as string, row]))
Filtered here rather than in SQL: a page rule can be a regular expression or a set of tags, so
the deciding rule is only knowable per row. Search must not be a way around page permissions — const result = pageIds
a title and an excerpt are content too. .map((id) => byId.get(id))
*/ .filter(Boolean)
const visible = actor .map((row) => ({
? ((rows.rows ?? rows) as any[]).filter((row) => id: row.id as string,
WIKI.models.groups.checkAccess(actor, 'read:pages', { path: row.path as string,
siteId, locale: row.locale as string,
path: row.path as string, title: row.title as string,
locale: row.locale as string, description: row.description ?? null,
tags: (row.tags ?? []) as string[] icon: row.icon ?? null,
}) tags: (row.tags ?? []) as string[],
) createdAt: row.createdAt as string,
: ((rows.rows ?? rows) as any[]) updatedAt: row.updatedAt as string,
relevancy: Number(row.relevancy ?? 0),
const result = visible.map((row) => ({ // -> Escaped first, so the only markup that survives is the emphasis postgres marked
id: row.id as string, highlight: row.highlight
path: row.path as string, ? escapeHtml(row.highlight as string)
locale: row.locale as string, .replaceAll(HL_START, '<b>')
title: row.title as string, .replaceAll(HL_STOP, '</b>')
description: row.description ?? null, : null
icon: row.icon ?? null, }))
tags: (row.tags ?? []) as string[],
createdAt: row.createdAt as string, return { results: result, totalHits }
updatedAt: row.updatedAt as string,
relevancy: Number(row.relevancy ?? 0),
// -> Escaped first, so the only markup that survives is the emphasis postgres marked
highlight: row.highlight
? escapeHtml(row.highlight as string)
.replaceAll(HL_START, '<b>')
.replaceAll(HL_STOP, '</b>')
: null
}))
return {
results: result,
/*
The count postgres reported, less whatever the rules just removed from this page of results.
Not exact when rows are dropped -- the window function counted every match, including ones on
later pages this reader may not see -- but a total that ignored the filtering entirely would
promise results that do not exist.
*/
totalHits: Math.max(
0,
Number((rows.rows ?? rows)[0]?.totalHits ?? 0) -
((rows.rows ?? rows) as any[]).length +
visible.length
)
}
} }
/** /**

@ -133,12 +133,12 @@ class Sites {
backlinks: true, backlinks: true,
browse: true, browse: true,
collaborativeEditing: true, collaborativeEditing: true,
ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
// -> On, because what decides whether a site has comments is whether a provider has // -> On, because what decides whether a site has comments is whether a provider has
// been picked. This is the switch that turns them all off without losing that // been picked. This is the switch that turns them all off without losing that
// choice, which is only useful to somebody who has already made it. // choice, which is only useful to somebody who has already made it.
comments: true, comments: true,
lastEditedBy: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },
@ -457,9 +457,9 @@ class Sites {
backlinks: true, backlinks: true,
browse: true, browse: true,
collaborativeEditing: true, collaborativeEditing: true,
ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: true, comments: true,
lastEditedBy: true,
reasonForChange: 'optional', reasonForChange: 'optional',
search: true search: true
}, },

@ -1216,6 +1216,7 @@ class Tree {
allowComments: page.allowComments, allowComments: page.allowComments,
allowContributions: page.allowContributions, allowContributions: page.allowContributions,
allowRatings: page.allowRatings, allowRatings: page.allowRatings,
showLastEditedBy: page.showLastEditedBy,
showSidebar: page.showSidebar, showSidebar: page.showSidebar,
showTags: page.showTags showTags: page.showTags
}, },

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
278 icons. 281 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -138,6 +138,7 @@ export const BUNDLED_ICONS = {
"la:square-full": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6z\"/>","width":32,"height":32}, "la:square-full": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6z\"/>","width":32,"height":32},
"la:star": {"body":"<path fill=\"currentColor\" d=\"m16 2.125l-.906 2.063l-3.25 7.28l-7.938.845l-2.25.25l1.688 1.5l5.906 5.343l-1.656 7.813l-.469 2.187l1.969-1.125l6.906-4l6.906 4l1.969 1.125l-.469-2.187l-1.656-7.813l5.906-5.343l1.688-1.5l-2.25-.25l-7.938-.844l-3.25-7.281zm0 4.906l2.563 5.782l.25.53l.562.063l6.281.656l-4.687 4.22l-.438.405l.125.563l1.313 6.156l-5.469-3.125l-.5-.312l-.5.312l-5.469 3.125l1.313-6.156l.125-.563l-.438-.406l-4.687-4.218l6.281-.657l.563-.062l.25-.531z\"/>","width":32,"height":32}, "la:star": {"body":"<path fill=\"currentColor\" d=\"m16 2.125l-.906 2.063l-3.25 7.28l-7.938.845l-2.25.25l1.688 1.5l5.906 5.343l-1.656 7.813l-.469 2.187l1.969-1.125l6.906-4l6.906 4l1.969 1.125l-.469-2.187l-1.656-7.813l5.906-5.343l1.688-1.5l-2.25-.25l-7.938-.844l-3.25-7.281zm0 4.906l2.563 5.782l.25.53l.562.063l6.281.656l-4.687 4.22l-.438.405l.125.563l1.313 6.156l-5.469-3.125l-.5-.312l-.5.312l-5.469 3.125l1.313-6.156l.125-.563l-.438-.406l-4.687-4.218l6.281-.657l.563-.062l.25-.531z\"/>","width":32,"height":32},
"la:star-half-alt": {"body":"<path fill=\"currentColor\" d=\"m16 2.375l-.906 2.031l-3.25 7.313l-7.938.812l-2.25.25l1.688 1.5l5.906 5.344l-1.656 7.813l-.469 2.187h.031L9.094 28.5L16 24.531l6.906 3.969l1.969 1.125l-.469-2.188l-1.656-7.812l5.906-5.344l1.688-1.5l-2.25-.25l-7.938-.812l-3.25-7.313zm0 4.906l2.563 5.782l.25.5l.562.062l6.313.656l-4.72 4.25l-.437.375l.125.563l1.313 6.187L16.5 22.5l-.5-.281z\"/>","width":32,"height":32}, "la:star-half-alt": {"body":"<path fill=\"currentColor\" d=\"m16 2.375l-.906 2.031l-3.25 7.313l-7.938.812l-2.25.25l1.688 1.5l5.906 5.344l-1.656 7.813l-.469 2.187h.031L9.094 28.5L16 24.531l6.906 3.969l1.969 1.125l-.469-2.188l-1.656-7.812l5.906-5.344l1.688-1.5l-2.25-.25l-7.938-.812l-3.25-7.313zm0 4.906l2.563 5.782l.25.5l.562.062l6.313.656l-4.72 4.25l-.437.375l.125.563l1.313 6.187L16.5 22.5l-.5-.281z\"/>","width":32,"height":32},
"la:star-solid": {"body":"<path fill=\"currentColor\" d=\"m30.336 12.547l-10.172-1.074L16 2.133l-4.164 9.34l-10.172 1.074l7.598 6.848L7.14 29.398L16 24.29l8.86 5.11l-2.122-10.004z\"/>","width":32,"height":32},
"la:stop": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6zm2 2h16v16H8z\"/>","width":32,"height":32}, "la:stop": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6zm2 2h16v16H8z\"/>","width":32,"height":32},
"la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32}, "la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32},
"la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-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-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32}, "la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-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-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32},
@ -280,6 +281,8 @@ export const BUNDLED_ICONS = {
"mdi:table-row-remove": {"body":"<path fill=\"currentColor\" d=\"M9.41 13L12 15.59L14.59 13L16 14.41L13.41 17L16 19.59L14.59 21L12 18.41L9.41 21L8 19.59L10.59 17L8 14.41zM22 9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2zM4 9h4V6H4zm6 0h4V6h-4zm6 0h4V6h-4z\"/>","width":24,"height":24}, "mdi:table-row-remove": {"body":"<path fill=\"currentColor\" d=\"M9.41 13L12 15.59L14.59 13L16 14.41L13.41 17L16 19.59L14.59 21L12 18.41L9.41 21L8 19.59L10.59 17L8 14.41zM22 9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2zM4 9h4V6H4zm6 0h4V6h-4zm6 0h4V6h-4z\"/>","width":24,"height":24},
"mdi:table-split-cell": {"body":"<path fill=\"currentColor\" d=\"M19 14h2v6H3v-6h2v4h14zM3 4v6h2V6h14v4h2V4zm8 7v2H8v2l-3-3l3-3v2zm5 0V9l3 3l-3 3v-2h-3v-2z\"/>","width":24,"height":24}, "mdi:table-split-cell": {"body":"<path fill=\"currentColor\" d=\"M19 14h2v6H3v-6h2v4h14zM3 4v6h2V6h14v4h2V4zm8 7v2H8v2l-3-3l3-3v2zm5 0V9l3 3l-3 3v-2h-3v-2z\"/>","width":24,"height":24},
"mdi:text-box-outline": {"body":"<path fill=\"currentColor\" d=\"M5 3c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h14c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 2h14v14H5zm2 2v2h10V7zm0 4v2h10v-2zm0 4v2h7v-2z\"/>","width":24,"height":24}, "mdi:text-box-outline": {"body":"<path fill=\"currentColor\" d=\"M5 3c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h14c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 2h14v14H5zm2 2v2h10V7zm0 4v2h10v-2zm0 4v2h7v-2z\"/>","width":24,"height":24},
"mdi:thumb-down": {"body":"<path fill=\"currentColor\" d=\"M19 15h4V3h-4m-4 0H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2a2 2 0 0 0 2 2h6.31l-.95 4.57c-.02.1-.03.2-.03.31c0 .42.17.79.44 1.06L9.83 23l6.58-6.59c.37-.36.59-.86.59-1.41V5a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:thumb-up": {"body":"<path fill=\"currentColor\" d=\"M23 10a2 2 0 0 0-2-2h-6.32l.96-4.57c.02-.1.03-.21.03-.32c0-.41-.17-.79-.44-1.06L14.17 1L7.59 7.58C7.22 7.95 7 8.45 7 9v10a2 2 0 0 0 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73zM1 21h4V9H1z\"/>","width":24,"height":24},
"mdi:tooltip-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M4 2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4l-4 4l-4-4H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m0 2v12h4.83L12 19.17L15.17 16H20V4zm7 2h2v3h3v2h-3v3h-2v-3H8V9h3z\"/>","width":24,"height":24}, "mdi:tooltip-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M4 2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4l-4 4l-4-4H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m0 2v12h4.83L12 19.17L15.17 16H20V4zm7 2h2v3h3v2h-3v3h-2v-3H8V9h3z\"/>","width":24,"height":24},
"mdi:toy-brick-plus": {"body":"<path fill=\"currentColor\" d=\"M19 6V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v1h-2V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v1H3v14h10.09a5.5 5.5 0 0 1-.09-1a6 6 0 0 1 8-5.66V6m-1 9v3h3v2h-3v3h-2v-3h-3v-2h3v-3Z\"/>","width":24,"height":24}, "mdi:toy-brick-plus": {"body":"<path fill=\"currentColor\" d=\"M19 6V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v1h-2V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v1H3v14h10.09a5.5 5.5 0 0 1-.09-1a6 6 0 0 1 8-5.66V6m-1 9v3h3v2h-3v3h-2v-3h-3v-2h3v-3Z\"/>","width":24,"height":24},
"mdi:transfer-down": {"body":"<path fill=\"currentColor\" d=\"M16 3v2H8V3zm0 4v2H8V7zm0 4v2H8v-2zM5 15h14l-7 7z\"/>","width":24,"height":24}, "mdi:transfer-down": {"body":"<path fill=\"currentColor\" d=\"M16 3v2H8V3zm0 4v2H8V7zm0 4v2H8v-2zM5 15h14l-7 7z\"/>","width":24,"height":24},

@ -273,6 +273,16 @@
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" /> unchecked-icon="la:times" />
</div> </div>
<div>
<w-toggle
v-if="pageStore.showSidebar"
v-model="pageStore.showLastEditedBy"
dense
:label="t(`editor.props.showLastEditedBy`)"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times" />
</div>
</w-form> </w-form>
</w-card-section> </w-card-section>
<w-card-section class="alt-card pb-6" id="refCardSocial"> <w-card-section class="alt-card pb-6" id="refCardSocial">
@ -296,7 +306,8 @@
checked-icon="la:check" checked-icon="la:check"
unchecked-icon="la:times" /> unchecked-icon="la:times" />
</div> </div>
<div v-if="flagsStore.experimental"> <!-- -> Nothing to allow while the site rates nothing: General -> Features is the other half -->
<div v-if="siteStore.features.ratingsMode !== `off`">
<w-toggle <w-toggle
v-model="pageStore.allowRatings" v-model="pageStore.allowRatings"
dense dense
@ -399,7 +410,6 @@ import { useI18n } from 'vue-i18n'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -413,7 +423,6 @@ import PageTags from './PageTags.vue'
// STORES // STORES
const editorStore = useEditorStore() const editorStore = useEditorStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()

@ -19,7 +19,7 @@
@click="set(i)" @click="set(i)"
@mouseover="editable && (hovered = i)" @mouseover="editable && (hovered = i)"
@keyup="onKeyup($event, i)"> @keyup="onKeyup($event, i)">
<w-icon :name="icon" class="w-rating__icon" :class="iconClass(i)" /> <w-icon :name="iconFor(i)" class="w-rating__icon" :class="iconClass(i)" />
</div> </div>
</div> </div>
</template> </template>
@ -32,9 +32,9 @@ import WIcon from './WIcon.vue'
* Star rating. * Star rating.
* *
* Simplification: the component this replaces accepted per-star arrays for the icon and colour, a * Simplification: the component this replaces accepted per-star arrays for the icon and colour, a
* separate half-star icon, and a `no-reset` switch. The one caller passes a single icon and a * separate half-star icon, and a `no-reset` switch. The one caller passes a single colour and at
* single colour, so this takes scalars; half ratings are not offered because nothing produced * most two icons -- one for a lit star and one for the rest -- so this takes scalars; half ratings
* them. * are not offered because nothing produced them.
* *
* Behaviour is otherwise preserved, including the two details that are easy to lose: clicking the * Behaviour is otherwise preserved, including the two details that are easy to lose: clicking the
* star you are already on resets the rating to zero, and the arrow keys move FOCUS between stars * star you are already on resets the rating to zero, and the arrow keys move FOCUS between stars
@ -56,6 +56,11 @@ const props = defineProps({
type: String, type: String,
default: 'mdi:star' default: 'mdi:star'
}, },
/** Icon reference for a lit star, e.g. a solid one over an outlined `icon`. Defaults to `icon`. */
activeIcon: {
type: String,
default: null
},
/** Theme colour name. Defaults to the same yellow the original used. */ /** Theme colour name. Defaults to the same yellow the original used. */
color: { color: {
type: String, type: String,
@ -100,13 +105,29 @@ const rootStyle = computed(() => ({
fontSize: props.size ? (NAMED_SIZES[props.size] ?? props.size) : undefined fontSize: props.size ? (NAMED_SIZES[props.size] ?? props.size) : undefined
})) }))
/**
* Whether star `i` is lit: within the hover preview while there is one, within the rating otherwise.
*/
function isActive(i) {
return hovered.value === 0 ? props.modelValue >= i : hovered.value >= i
}
/**
* A lit star takes `activeIcon`. So does one the hover preview is about to give up, which is still
* part of the committed rating -- its lower opacity is what says it would go, not its shape.
*/
function iconFor(i) {
const lit = isActive(i) || (hovered.value > 0 && props.modelValue >= i)
return lit && props.activeIcon ? props.activeIcon : props.icon
}
/** /**
* Three states, matching the original's opacities: a star is lit when it is within the rating (or * Three states, matching the original's opacities: a star is lit when it is within the rating (or
* within the hover preview), half-lit when the hover preview is currently BELOW the committed * within the hover preview), half-lit when the hover preview is currently BELOW the committed
* rating -- so you can see what you are about to give up -- and dim otherwise. * rating -- so you can see what you are about to give up -- and dim otherwise.
*/ */
function iconClass(i) { function iconClass(i) {
const active = hovered.value === 0 ? props.modelValue >= i : hovered.value >= i const active = isActive(i)
const exSelected = hovered.value > 0 && props.modelValue >= i && hovered.value < i const exSelected = hovered.value > 0 && props.modelValue >= i && hovered.value < i
return [ return [
active ? 'opacity-100' : exSelected ? 'opacity-70' : 'opacity-40', active ? 'opacity-100' : exSelected ? 'opacity-70' : 'opacity-40',

@ -185,6 +185,7 @@ export function versionPageProps(version) {
allowComments: config.allowComments, allowComments: config.allowComments,
allowContributions: config.allowContributions, allowContributions: config.allowContributions,
allowRatings: config.allowRatings, allowRatings: config.allowRatings,
showLastEditedBy: config.showLastEditedBy,
showSidebar: config.showSidebar, showSidebar: config.showSidebar,
showTags: config.showTags, showTags: config.showTags,
showToc: config.showToc, showToc: config.showToc,

@ -223,25 +223,36 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<template v-if="flagsStore.experimental"> <w-item>
<w-item> <blueprint-icon icon="star-half-empty" />
<blueprint-icon icon="star-half-empty" /> <w-item-section>
<w-item-section> <w-item-label>{{ t(`admin.general.allowRatings`) }}</w-item-label>
<w-item-label>{{ t(`admin.general.allowRatings`) }}</w-item-label> <w-item-label caption>{{ t(`admin.general.allowRatingsHint`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowRatingsHint`) }}</w-item-label> </w-item-section>
</w-item-section> <w-item-section class="flex-none">
<w-item-section class="flex-none"> <w-btn-toggle
<w-btn-toggle v-model="state.config.features.ratingsMode"
v-model="state.config.features.ratingsMode" push
push glossy
glossy no-caps
no-caps toggle-color="primary"
toggle-color="primary" :options="ratingsModes" />
:options="ratingsModes" /> </w-item-section>
</w-item-section> </w-item>
</w-item> <w-separator class="my-2" inset />
<w-separator class="my-2" inset /> <w-item tag="label">
</template> <blueprint-icon icon="person" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowLastEditedBy`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowLastEditedByHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.lastEditedBy"
:aria-label="t(`admin.general.allowLastEditedBy`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label"> <w-item tag="label">
<blueprint-icon icon="search" /> <blueprint-icon icon="search" />
<w-item-section> <w-item-section>
@ -614,7 +625,6 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import UtilCodeEditor from '@/components/UtilCodeEditor.vue' import UtilCodeEditor from '@/components/UtilCodeEditor.vue'
@ -631,7 +641,6 @@ import { toMerged } from 'es-toolkit/object'
// STORES // STORES
const adminStore = useAdminStore() const adminStore = useAdminStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -665,10 +674,6 @@ function defaultConfig() {
}, },
pageExtensions: '', pageExtensions: '',
logoText: false, logoText: false,
ratings: {
index: false,
follow: false
},
features: { features: {
backlinks: true, backlinks: true,
/* /*
@ -679,9 +684,9 @@ function defaultConfig() {
reason read the other way -- both are `!== false` on the server. reason read the other way -- both are `!== false` on the server.
*/ */
collaborativeEditing: false, collaborativeEditing: false,
ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: true, comments: true,
lastEditedBy: true,
reasonForChange: 'required' reasonForChange: 'required'
}, },
discoverable: false, discoverable: false,
@ -821,6 +826,7 @@ async function save() {
// silent: the toggle moved, the save succeeded, and the next load put it back. // silent: the toggle moved, the save succeeded, and the next load put it back.
collaborativeEditing: state.config.features?.collaborativeEditing ?? false, collaborativeEditing: state.config.features?.collaborativeEditing ?? false,
comments: state.config.features?.comments ?? true, comments: state.config.features?.comments ?? true,
lastEditedBy: state.config.features?.lastEditedBy ?? true,
ratingsMode: state.config.features?.ratingsMode ?? 'off', ratingsMode: state.config.features?.ratingsMode ?? 'off',
reasonForChange: state.config.features?.reasonForChange ?? 'required', reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false search: state.config.features?.search ?? false

@ -327,7 +327,7 @@
thing and belongs where there is room for it --> thing and belongs where there is room for it -->
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
</div> </div>
<div class="px-4 pb-2"> <div class="px-4 pb-4">
<page-toc <page-toc
:nodes="pageStore.toc" :nodes="pageStore.toc"
:min-depth="pageStore.tocDepth.min" :min-depth="pageStore.tocDepth.min"
@ -376,28 +376,77 @@
<page-tags class="mt-2" :edit="state.tagEditMode" /> <page-tags class="mt-2" :edit="state.tagEditMode" />
</div> </div>
</template> </template>
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings"> <template v-if="showRatings">
<w-separator v-if="showToc || showTags" /> <w-separator v-if="showToc || showTags" />
<!-- Rating --> <!-- Rating -->
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:star-half-alt" color="grey" /> <w-icon class="mr-2" name="la:star-half-alt" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div>
</div> </div>
<div class="px-4"> <div class="px-4 pb-4">
<w-rating <template v-if="pageStore.rating.mode === `stars`">
v-if="siteStore.features.ratingsMode === `stars`" <w-rating
v-model="state.currentRating" :model-value="pageStore.viewerRating"
icon="la:star" icon="la:star"
color="secondary" active-icon="la:star-solid"
size="sm" /> color="secondary"
<div size="sm"
class="flex items-center" :readonly="!canRate || state.ratingBusy"
v-else-if="siteStore.features.ratingsMode === `thumbs`"> @update:model-value="rate" />
<w-btn class="acrylic-btn" flat icon="la:thumbs-down" color="secondary" /> <div class="text-caption text-grey-7 mt-1">{{ ratingStarsCaption }}</div>
<w-btn class="acrylic-btn ml-2" flat icon="la:thumbs-up" color="secondary" /> </template>
<div v-else class="flex items-center gap-2">
<w-btn
class="acrylic-btn"
flat
no-caps
size="12px"
:icon="pageStore.viewerRating === 1 ? `mdi:thumb-up` : `la:thumbs-up`"
:color="pageStore.viewerRating === 1 ? `positive` : `secondary`"
:label="String(pageStore.rating.up)"
:aria-label="t('common.page.ratingThumbsUp')"
:aria-pressed="pageStore.viewerRating === 1"
:disable="!canRate || state.ratingBusy"
@click="rateThumb(1)">
<w-tooltip>{{ t('common.page.ratingThumbsUp') }}</w-tooltip>
</w-btn>
<w-btn
class="acrylic-btn"
flat
no-caps
size="12px"
:icon="pageStore.viewerRating === -1 ? `mdi:thumb-down` : `la:thumbs-down`"
:color="pageStore.viewerRating === -1 ? `negative` : `secondary`"
:label="String(pageStore.rating.down)"
:aria-label="t('common.page.ratingThumbsDown')"
:aria-pressed="pageStore.viewerRating === -1"
:disable="!canRate || state.ratingBusy"
@click="rateThumb(-1)">
<w-tooltip>{{ t('common.page.ratingThumbsDown') }}</w-tooltip>
</w-btn>
</div> </div>
</div> </div>
</template> </template>
<!-- Last Edited By -->
<template v-if="showLastEditedBy">
<w-separator v-if="showToc || showTags || showRatings" />
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:user-edit" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.lastEditedBy') }}</div>
</div>
<div class="px-4 pb-4">
<router-link class="page-last-editor" :to="`/_user/${pageStore.authorId}`">
<w-avatar size="24px" color="primary" text-color="white">
<img
v-if="pageStore.authorHasAvatar"
:src="`/_user/${pageStore.authorId}/avatar`"
alt="" />
<span v-else>{{ lastEditorInitial }}</span>
</w-avatar>
<span class="ml-2">{{ pageStore.authorName }}</span>
</router-link>
</div>
</template>
</template> </template>
</div> </div>
<!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete --> <!-- -> Every action on it acts on a page: there is none here to edit, share, rate or delete -->
@ -564,7 +613,7 @@ const route = useRoute()
// I18N // I18N
const { t } = useI18n() const { t, locale } = useI18n()
// COMPOSABLES // COMPOSABLES
@ -603,7 +652,8 @@ const state = reactive({
* panel over the article rather than a column beside it. * panel over the article rather than a column beside it.
*/ */
tocPanelOpen: false, tocPanelOpen: false,
currentRating: 3, /** A rating is on its way to the server, which holds the controls still until it answers. */
ratingBusy: false,
/** /**
* Which view the reader is on: `article`, `talk` or `links`. * Which view the reader is on: `article`, `talk` or `links`.
* *
@ -709,6 +759,46 @@ const showToc = computed(() => {
const showTags = computed(() => { const showTags = computed(() => {
return pageStore.showTags && (pageStore.tags?.length > 0 || state.tagEditMode) return pageStore.showTags && (pageStore.tags?.length > 0 || state.tagEditMode)
}) })
/*
Both switches have to be on: the site's ratings mode under General -> Features, and the page's own
`allowRatings`. The server has already combined them -- `rating` comes back null otherwise -- and
it is also what says which scale is in force, so that is what is read here.
*/
const showRatings = computed(() => {
return Boolean(pageStore.rating) && pageStore.allowRatings
})
/*
A logged in reader who may read the page. Only an account can rate, since one opinion per person
needs a person to hang it on.
*/
const canRate = computed(() => {
return userStore.authenticated && userStore.pagePermissions.includes('read:pages')
})
const ratingStarsCaption = computed(() => {
const { count, average } = pageStore.rating
const total = t('common.page.ratingCount', count)
if (count < 1) {
return total
}
const formatted = new Intl.NumberFormat(locale.value, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format(average)
return `${t('common.page.ratingAverage', { average: formatted })} · ${total}`
})
/*
Both switches have to be on: the site's under General -> Features, and the page's own in its
properties dialog. `authorId` is the account that saved the version that stands -- every save moves
it -- so it is the last editor rather than whoever created the page.
*/
const showLastEditedBy = computed(() => {
return (
siteStore.features.lastEditedBy && pageStore.showLastEditedBy && Boolean(pageStore.authorId)
)
})
const lastEditorInitial = computed(() => {
return (pageStore.authorName || '?').trim().charAt(0).toUpperCase()
})
/* /*
Whether this user may save a change to the page. Editing the tags is a save -- they go up with the Whether this user may save a change to the page. Editing the tags is a save -- they go up with the
rest of the page rather than through an endpoint of their own -- so the test is the pair the PATCH rest of the page rather than through an endpoint of their own -- so the test is the pair the PATCH
@ -1314,6 +1404,26 @@ async function createPage() {
loading.hide() loading.hide()
} }
/**
* Rate the page, or withdraw the rating with 0 -- which is what clicking the star already given does.
* The controls are held while the request is out, so a second click cannot race the first.
*/
async function rate(value) {
state.ratingBusy = true
try {
await pageStore.pageRate(value)
} catch (err) {
notify({ type: 'negative', message: t('common.page.ratingFailed'), caption: err.message })
} finally {
state.ratingBusy = false
}
}
/** A thumb pressed again takes it back, the way a star clicked again does. */
function rateThumb(value) {
return rate(pageStore.viewerRating === value ? 0 : value)
}
/** /**
* Back out of a path that has no page. `router.back()` alone lands on the wiki's own error screen for * Back out of a path that has no page. `router.back()` alone lands on the wiki's own error screen for
* a reader who arrived at this URL directly, having nothing to go back to, so that case goes home. * a reader who arrived at this URL directly, having nothing to go back to, so that case goes home.
@ -1334,6 +1444,22 @@ function goBack() {
BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at
the start when coming back, which is exactly the timing a fade wants. the start when coming back, which is exactly the timing a fade wants.
*/ */
.page-last-editor {
display: inline-flex;
align-items: center;
color: inherit;
text-decoration: none;
/* -> The size of a top-level entry in the contents above it (`.page-toc-item--d0`) */
> span {
font-size: 0.8125rem;
}
&:hover > span {
text-decoration: underline;
}
}
.tags-edit-btn { .tags-edit-btn {
transition: transition:
opacity 0.2s var(--ease-standard), opacity 0.2s var(--ease-standard),

@ -383,11 +383,7 @@ const state = reactive({
orderBy: 'title', orderBy: 'title',
orderByDirection: 'asc', orderByDirection: 'asc',
results: [], results: [],
/** /** How many results have been asked for, which is the offset the next batch starts at. */
* How many ROWS have been asked for, which is not `results.length`: the search API drops rows the
* page rules refuse after it has counted them, so its own offset is the only thing that can be
* paged with.
*/
fetched: 0, fetched: 0,
total: 0 total: 0
}) })

@ -132,12 +132,6 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </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"> <div v-if="lists[tab.name].offset < lists[tab.name].total" class="p-4 text-center">
<w-btn <w-btn
outline outline
@ -404,12 +398,8 @@ async function fetchPages() {
...(resp?.results ?? []).map((r) => ({ ...r, tags: [...(r.tags ?? [])].sort() })) ...(resp?.results ?? []).map((r) => ({ ...r, tags: [...(r.tags ?? [])].sort() }))
) )
/* /*
Advanced by what was ASKED for, not by what came back. The search applies this reader's page rules before it pages, so every batch but the last is
full and `totalHits` counts only pages they may open -- never the ones they were refused.
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.offset += PAGE_SIZE
list.total = resp?.totalHits ?? 0 list.total = resp?.totalHits ?? 0

@ -40,6 +40,7 @@ const DUPLICATED_PAGE_PROPS = [
'scriptCss', 'scriptCss',
'scriptJsLoad', 'scriptJsLoad',
'scriptJsUnload', 'scriptJsUnload',
'showLastEditedBy',
'showSidebar', 'showSidebar',
'showTags', 'showTags',
'showToc', 'showToc',
@ -57,6 +58,7 @@ export const usePageStore = defineStore('page', {
allowRatings: true, allowRatings: true,
authorId: 0, authorId: 0,
authorName: '', authorName: '',
authorHasAvatar: false,
/** /**
* The blog this page is a post of, as `{ path, title }`, or null for a page that is not in one. * The blog this page is a post of, as `{ path, title }`, or null for a page that is not in one.
* *
@ -144,6 +146,7 @@ export const usePageStore = defineStore('page', {
scriptJsLoad: '', scriptJsLoad: '',
scriptJsUnload: '', scriptJsUnload: '',
scriptCss: '', scriptCss: '',
showLastEditedBy: true,
showSidebar: true, showSidebar: true,
showTags: true, showTags: true,
showToc: true, showToc: true,
@ -172,7 +175,17 @@ export const usePageStore = defineStore('page', {
* Whether this reader has asked to be told about changes to this page. Always false for a guest: * Whether this reader has asked to be told about changes to this page. Always false for a guest:
* a watch belongs to an account, which is what a notification would eventually be sent to. * a watch belongs to an account, which is what a notification would eventually be sent to.
*/ */
isWatching: false isWatching: false,
/**
* How readers have rated this page, as `{ mode, count, average, up, down }` on the site's current
* scale, or null when ratings are off for the site or for the page.
*/
rating: null,
/**
* This reader's own rating on that scale: 1 or -1 for thumbs, 1 to 5 for stars, 0 for none.
* Always 0 for a guest, who cannot rate.
*/
viewerRating: 0
}), }),
getters: { getters: {
breadcrumbs: (state) => { breadcrumbs: (state) => {
@ -372,6 +385,31 @@ export const usePageStore = defineStore('page', {
throw err throw err
} }
}, },
/**
* PAGE - RATE
*
* Gives this page a rating, or withdraws it with 0. Moved first and put back on a refusal, the way
* `pageWatch` is; the summary is then taken from the server's answer, since other readers may have
* rated in the meantime.
*
* @throws Whatever the request failed with, for the caller to report.
*/
async pageRate(value) {
const siteStore = useSiteStore()
const previous = this.viewerRating
this.viewerRating = value
try {
const url = `sites/${siteStore.id}/pages/${this.id}/rating`
const resp = await (
value ? API_CLIENT.put(url, { json: { value } }) : API_CLIENT.delete(url)
).json()
this.$patch({ rating: resp.rating, viewerRating: resp.value })
} catch (err) {
this.viewerRating = previous
console.warn(err)
throw err
}
},
/** /**
* PAGE - APPLY VIEWER STATE * PAGE - APPLY VIEWER STATE
* *
@ -397,7 +435,8 @@ export const usePageStore = defineStore('page', {
hasOpenSuggestion: viewer.hasOpenSuggestion === true, hasOpenSuggestion: viewer.hasOpenSuggestion === true,
canReview: viewer.canReview === true, canReview: viewer.canReview === true,
pendingSubmissions: viewer.pendingSubmissions ?? [], pendingSubmissions: viewer.pendingSubmissions ?? [],
isWatching: viewer.isWatching === true isWatching: viewer.isWatching === true,
viewerRating: viewer.rating ?? 0
}) })
}, },
/** /**
@ -445,6 +484,8 @@ export const usePageStore = defineStore('page', {
canReview: false, canReview: false,
pendingSubmissions: [], pendingSubmissions: [],
isWatching: false, isWatching: false,
rating: null,
viewerRating: 0,
blog: null, blog: null,
notFound: true notFound: true
}) })
@ -604,6 +645,7 @@ export const usePageStore = defineStore('page', {
allowComments: props.allowComments ?? false, allowComments: props.allowComments ?? false,
allowContributions: props.allowContributions ?? true, allowContributions: props.allowContributions ?? true,
allowRatings: props.allowRatings ?? true, allowRatings: props.allowRatings ?? true,
showLastEditedBy: props.showLastEditedBy ?? true,
showSidebar: props.showSidebar ?? true, showSidebar: props.showSidebar ?? true,
showTags: props.showTags ?? true, showTags: props.showTags ?? true,
showToc: props.showToc ?? true, showToc: props.showToc ?? true,
@ -952,6 +994,7 @@ export const usePageStore = defineStore('page', {
'scriptJsLoad', 'scriptJsLoad',
'scriptJsUnload', 'scriptJsUnload',
'scriptCss', 'scriptCss',
'showLastEditedBy',
'showSidebar', 'showSidebar',
'showTags', 'showTags',
'showToc', 'showToc',

@ -113,6 +113,7 @@ export const useSiteStore = defineStore('site', {
backlinks: true, backlinks: true,
browse: false, browse: false,
collaborativeEditing: false, collaborativeEditing: false,
lastEditedBy: true,
ratingsMode: 'off', ratingsMode: 'off',
reasonForChange: 'required', reasonForChange: 'required',
search: false search: false

Loading…
Cancel
Save