mirror of https://github.com/requarks/wiki
parent
957efebecb
commit
c36eab6729
@ -1,5 +0,0 @@
|
||||
-- Create PG Extensions --
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS ltree;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"id": "061e8c84-e05e-40b0-a074-7a56bd794fc7",
|
||||
"prevIds": [
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
],
|
||||
"version": "8",
|
||||
"dialect": "postgres",
|
||||
"ddl": [],
|
||||
"renames": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
ALTER TABLE "apiKeys" DROP COLUMN "key";
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "apiKeys" ADD COLUMN "keyShort" varchar(8) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "apiKeys" ADD COLUMN "groups" jsonb DEFAULT '[]' NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@
|
||||
CREATE TYPE "hookState" AS ENUM('pending', 'success', 'error');--> statement-breakpoint
|
||||
CREATE TABLE "hooks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"name" varchar(255) NOT NULL,
|
||||
"events" text[] DEFAULT ARRAY[]::text[] NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"includeMetadata" boolean DEFAULT true NOT NULL,
|
||||
"includeContent" boolean DEFAULT false NOT NULL,
|
||||
"acceptUntrusted" boolean DEFAULT false NOT NULL,
|
||||
"authHeader" text,
|
||||
"state" "hookState" DEFAULT 'pending'::"hookState" NOT NULL,
|
||||
"lastErrorMessage" text,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,14 +0,0 @@
|
||||
CREATE TABLE "storage" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"module" varchar(255) NOT NULL,
|
||||
"isEnabled" boolean DEFAULT false NOT NULL,
|
||||
"contentTypes" jsonb DEFAULT '{}' NOT NULL,
|
||||
"assetDelivery" jsonb DEFAULT '{}' NOT NULL,
|
||||
"versioning" jsonb DEFAULT '{}' NOT NULL,
|
||||
"config" jsonb DEFAULT '{}' NOT NULL,
|
||||
"state" jsonb DEFAULT '{}' NOT NULL,
|
||||
"siteId" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint
|
||||
ALTER TABLE "storage" ADD CONSTRAINT "storage_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
||||
CREATE TABLE "iconSets" (
|
||||
"prefix" varchar(64) PRIMARY KEY,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"isEnabled" boolean DEFAULT true NOT NULL,
|
||||
"info" jsonb DEFAULT '{}' NOT NULL,
|
||||
"refreshedAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "icons" (
|
||||
"prefix" varchar(64),
|
||||
"name" varchar(255),
|
||||
"body" text NOT NULL,
|
||||
"width" integer DEFAULT 16 NOT NULL,
|
||||
"height" integer DEFAULT 16 NOT NULL,
|
||||
"left" integer DEFAULT 0 NOT NULL,
|
||||
"top" integer DEFAULT 0 NOT NULL,
|
||||
"rotate" integer DEFAULT 0 NOT NULL,
|
||||
"hFlip" boolean DEFAULT false NOT NULL,
|
||||
"vFlip" boolean DEFAULT false NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "icons_pkey" PRIMARY KEY("prefix","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "icons" ADD CONSTRAINT "icons_prefix_iconSets_prefix_fkey" FOREIGN KEY ("prefix") REFERENCES "iconSets"("prefix");
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
CREATE TABLE "approvalRules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match" varchar(16) DEFAULT 'START' NOT NULL,
|
||||
"path" varchar(2048) DEFAULT '' NOT NULL,
|
||||
"submitterGroups" jsonb DEFAULT '[]' NOT NULL,
|
||||
"reviewerGroups" jsonb DEFAULT '[]' NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"siteId" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "approvalRules_siteId_idx" ON "approvalRules" ("siteId");--> statement-breakpoint
|
||||
ALTER TABLE "approvalRules" ADD CONSTRAINT "approvalRules_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "approvalRules" ADD COLUMN "name" varchar(255) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "approvalRules" ADD COLUMN "isEnabled" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@
|
||||
CREATE TABLE "pageEditSubmissions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"content" text NOT NULL,
|
||||
"patch" text NOT NULL,
|
||||
"baseHash" varchar(64) NOT NULL,
|
||||
"guestName" varchar(255),
|
||||
"guestEmail" varchar(255),
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"pageId" uuid NOT NULL,
|
||||
"siteId" uuid NOT NULL,
|
||||
"authorId" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_pageId_idx" ON "pageEditSubmissions" ("pageId");--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_authorId_idx" ON "pageEditSubmissions" ("authorId");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "pageEditSubmissions_page_author_idx" ON "pageEditSubmissions" ("pageId","authorId") WHERE "authorId" IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id");
|
||||
@ -0,0 +1,171 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { pageHistory as pageHistoryTable, pages as pagesTable } from '../db/schema.ts'
|
||||
|
||||
/**
|
||||
* The kinds of change a history row records.
|
||||
*
|
||||
* `created` and `deleted` are the two ends of a page's life; `moved` is a change of path or title,
|
||||
* which is worth telling apart from an ordinary edit because it is what breaks links; `updated` is
|
||||
* everything else, content and metadata alike.
|
||||
*/
|
||||
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as const
|
||||
|
||||
export type PageHistoryAction = (typeof pageHistoryActions)[number]
|
||||
|
||||
/**
|
||||
* The page fields a version carries beyond the ones with columns of their own.
|
||||
*
|
||||
* 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`, `toc`, `searchContent`,
|
||||
* `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`,
|
||||
* `historyData`, `isSearchableComputed`).
|
||||
*/
|
||||
const EXCLUDED_FROM_META = new Set([
|
||||
'id',
|
||||
'siteId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'authorId',
|
||||
'hash',
|
||||
'render',
|
||||
'toc',
|
||||
'searchContent',
|
||||
'ts',
|
||||
'ratingScore',
|
||||
'ratingCount',
|
||||
'historyData',
|
||||
'isSearchableComputed',
|
||||
// -> Held in columns of their own
|
||||
'locale',
|
||||
'path',
|
||||
'title',
|
||||
'content'
|
||||
])
|
||||
|
||||
/**
|
||||
* Fields a change is never reported as having touched.
|
||||
*
|
||||
* Either derived from the content (a render moves whenever the source does, and saying so twice tells
|
||||
* a reader nothing) or bookkeeping that moves on every save regardless.
|
||||
*/
|
||||
const NOT_REPORTED_AS_CHANGED = new Set([
|
||||
'render',
|
||||
'toc',
|
||||
'searchContent',
|
||||
'ts',
|
||||
'hash',
|
||||
'authorId',
|
||||
'updatedAt',
|
||||
'ratingScore',
|
||||
'ratingCount',
|
||||
'historyData',
|
||||
'isSearchableComputed'
|
||||
])
|
||||
|
||||
/**
|
||||
* Page history model
|
||||
*
|
||||
* Records a version of a page every time one changes. Nothing reads it back yet — displaying the
|
||||
* history, comparing versions and restoring one are the next step — so this is deliberately only the
|
||||
* recording side.
|
||||
*/
|
||||
class PageHistory {
|
||||
/**
|
||||
* Record what a page looks like now, as a new version.
|
||||
*
|
||||
* The snapshot is read from the stored row rather than taken from the caller, so that what is
|
||||
* recorded is what was actually saved — not what the caller believed it was saving. For a deletion
|
||||
* that means this has to be called BEFORE the row goes.
|
||||
*
|
||||
* A failure here is logged and swallowed: history is a record of what happened, and losing an entry
|
||||
* is not a reason to fail the edit that was the point of the request.
|
||||
*
|
||||
* @param authorId Who made the change. Kept on the row until that account is deleted, at which
|
||||
* point the version survives with no author rather than blocking the deletion.
|
||||
* @param changedFields Which fields the change touched. Empty for a creation or a deletion, where
|
||||
* the whole page is the change.
|
||||
* @returns The version's ID, or null when nothing was recorded
|
||||
*/
|
||||
async record({
|
||||
siteId,
|
||||
pageId,
|
||||
action,
|
||||
authorId,
|
||||
changedFields = []
|
||||
}: {
|
||||
siteId: string
|
||||
pageId: string
|
||||
action: PageHistoryAction
|
||||
authorId: string
|
||||
changedFields?: string[]
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const rows = await WIKI.db.select().from(pagesTable).where(eq(pagesTable.id, pageId)).limit(1)
|
||||
const page = rows[0]
|
||||
if (!page) {
|
||||
WIKI.logger.warn(`Cannot record page history for ${pageId}: the page is not there.`)
|
||||
return null
|
||||
}
|
||||
|
||||
const meta: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(page)) {
|
||||
if (!EXCLUDED_FROM_META.has(key)) {
|
||||
meta[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await WIKI.db
|
||||
.insert(pageHistoryTable)
|
||||
.values({
|
||||
pageId,
|
||||
siteId,
|
||||
authorId,
|
||||
action,
|
||||
changedFields,
|
||||
locale: page.locale,
|
||||
path: page.path,
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
meta
|
||||
})
|
||||
.returning({ id: pageHistoryTable.id })
|
||||
|
||||
return inserted[0]?.id ?? null
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(`Failed to record page history for ${pageId}: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a page's fields a patch actually changes.
|
||||
*
|
||||
* Compared against the stored row rather than taken from the patch keys: a client that sends every
|
||||
* field on every save — which is what the editor does — would otherwise record every field as
|
||||
* changed on every version, and the point of this is to say what was touched.
|
||||
*
|
||||
* Fields derived from the content, and the bookkeeping that moves on every save, are left out: a
|
||||
* render changing alongside its source is not a second thing that happened.
|
||||
*
|
||||
* @param existing The page row as it stands
|
||||
* @param patch The fields being written, keyed as the page stores them
|
||||
*/
|
||||
changedFields(existing: Record<string, any>, patch: Record<string, any>): string[] {
|
||||
const changed: string[] = []
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined || !(key in existing) || NOT_REPORTED_AS_CHANGED.has(key)) {
|
||||
continue
|
||||
}
|
||||
// -> JSON rather than `===`: tags, relations and the config blobs are arrays and objects, and
|
||||
// comparing those by reference reports every save as a change to all of them
|
||||
if (JSON.stringify(existing[key]) !== JSON.stringify(value)) {
|
||||
changed.push(key)
|
||||
}
|
||||
}
|
||||
return changed.sort()
|
||||
}
|
||||
}
|
||||
|
||||
export const pageHistory = new PageHistory()
|
||||
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<w-layout>
|
||||
<w-header>
|
||||
<header-nav />
|
||||
</w-header>
|
||||
<w-page-container class="layout-inbox">
|
||||
<div class="layout-inbox-card">
|
||||
<div class="layout-inbox-sd">
|
||||
<w-list>
|
||||
<w-item
|
||||
v-for="navItem of sidenav"
|
||||
:key="navItem.key"
|
||||
clickable
|
||||
:to="`/_inbox/` + navItem.key"
|
||||
active-class="is-active">
|
||||
<w-item-section side>
|
||||
<w-icon :name="navItem.icon" />
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>{{ navItem.label }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</w-page-container>
|
||||
<main-overlay-dialog />
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import HeaderNav from '@/components/HeaderNav.vue'
|
||||
import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
|
||||
|
||||
/**
|
||||
* The inbox: what has come in for this user, what they are following, and what is waiting on them.
|
||||
*
|
||||
* Same shape as the profile layout -- dark backdrop, one card, a rail down its left -- but the card
|
||||
* fills the viewport rather than sitting in a column, since these sections are lists to work through
|
||||
* rather than a form to read.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ROUTER
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
titleTemplate: (title) => `${title} - ${t('inbox.title')} - Wiki.js`
|
||||
})
|
||||
|
||||
// DATA
|
||||
|
||||
const sidenav = [
|
||||
{
|
||||
key: 'messages',
|
||||
label: t('inbox.inbox'),
|
||||
icon: 'mdi:inbox-full'
|
||||
},
|
||||
{
|
||||
key: 'watching',
|
||||
label: t('inbox.watching'),
|
||||
icon: 'la:bell'
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: t('inbox.pendingReview'),
|
||||
icon: 'la:clipboard-check'
|
||||
}
|
||||
]
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> There is nothing in here for somebody with no account, and every section is about them
|
||||
watch(
|
||||
() => route.path,
|
||||
(newValue) => {
|
||||
if (newValue.startsWith('/_inbox') && !userStore.authenticated) {
|
||||
router.replace('/login')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
The backdrop and the rail are the profile layout's, deliberately: these are the two places in the
|
||||
app a signed in person manages their own things, and they should read as the same place.
|
||||
|
||||
What differs is the card. The profile card is a centred column of forms; this one is a workspace of
|
||||
lists, so it takes the whole viewport less a margin.
|
||||
*/
|
||||
.layout-inbox {
|
||||
// -> Dark in both themes, unlike the profile layout: there is no light half here for a light theme
|
||||
// to own, so the surface is the same either way
|
||||
background-color: $dark-6;
|
||||
|
||||
/*
|
||||
The profile layout's gradient, stretched over the whole viewport instead of a band across the top.
|
||||
There it fades into a light page below it, which is what the 350px height and the border were for;
|
||||
with nothing to fade into, both go.
|
||||
*/
|
||||
&:before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at bottom, $dark-3, $dark-6);
|
||||
}
|
||||
|
||||
&: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%
|
||||
);
|
||||
}
|
||||
|
||||
&-card {
|
||||
position: relative;
|
||||
margin: 16px;
|
||||
box-shadow: $shadow-2;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
// -> The margin above, top and bottom, is what this subtracts: the card fills what is left
|
||||
min-height: calc(100% - 32px);
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: #fff;
|
||||
color: var(--color-black);
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&-sd {
|
||||
flex: 0 0 300px;
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-1;
|
||||
border-right: 1px solid rgba($dark-3, 0.1);
|
||||
box-shadow: inset -1px 0 0 #fff;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
border-right: 1px solid rgba(#fff, 0.12);
|
||||
box-shadow: inset -1px 0 0 rgba($dark-6, 0.5);
|
||||
}
|
||||
|
||||
.w-list .w-item {
|
||||
font-weight: 500;
|
||||
color: $grey-9;
|
||||
|
||||
@at-root .body--dark & {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: linear-gradient(to bottom, rgba($primary, 0.25), rgba($primary, 0.1));
|
||||
color: $primary;
|
||||
|
||||
// -> WIcon draws an Iconify reference as <iconify-icon> and anything else via q-icon
|
||||
.w-icon,
|
||||
iconify-icon {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
@at-root .body--dark & {
|
||||
color: var(--color-primary-light);
|
||||
|
||||
.w-icon,
|
||||
iconify-icon {
|
||||
color: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-page {
|
||||
flex: 1 1;
|
||||
|
||||
@at-root .body--light & {
|
||||
border-left: 1px solid #fff;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
border-left: 1px solid rgba($dark-6, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.body--dark {
|
||||
background-color: $dark-6;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<w-page class="py-4">
|
||||
<div class="w-section-header">{{ t('inbox.inbox') }}</div>
|
||||
<div class="p-4">
|
||||
<div class="text-body2">{{ t('inbox.inboxInfo') }}</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
title: t('inbox.inbox')
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<w-page class="inbox-review flex flex-col">
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<!-- QUEUE -->
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<template v-if="!state.selected">
|
||||
<!--
|
||||
`pt-4` on the heading rather than `py-4` on the page, which is where the other sections get it
|
||||
from: this page is a flex column whose diff view fills the rest of the card, and padding on the
|
||||
container would sit under that too.
|
||||
-->
|
||||
<div class="w-section-header pt-4">{{ t('inbox.pendingReview') }}</div>
|
||||
<div class="p-4">
|
||||
<div class="text-body2">{{ t('inbox.pendingReviewInfo') }}</div>
|
||||
<w-banner
|
||||
v-if="state.submissions.length < 1 && state.loading < 1"
|
||||
class="mt-6"
|
||||
rounded
|
||||
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
|
||||
{{ t('inbox.reviewNone') }}
|
||||
</w-banner>
|
||||
<w-list v-else class="mt-6" bordered separator>
|
||||
<w-item
|
||||
v-for="submission of state.submissions"
|
||||
:key="submission.id"
|
||||
clickable
|
||||
@click="openSubmission(submission)">
|
||||
<w-item-section avatar>
|
||||
<w-avatar color="secondary" text-color="white" rounded>
|
||||
<w-icon name="la:file-alt" />
|
||||
</w-avatar>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>
|
||||
<strong>{{ submission.page.title }}</strong>
|
||||
</w-item-label>
|
||||
<w-item-label caption>/{{ submission.page.path }}</w-item-label>
|
||||
<w-item-label caption>
|
||||
<i18n-t keypath="inbox.reviewSubmittedBy" scope="global">
|
||||
<template #author>
|
||||
<strong>{{ submission.author.name || t('inbox.reviewUnknownAuthor') }}</strong>
|
||||
</template>
|
||||
<template #date>{{ humanizeDate(submission.createdAt) }}</template>
|
||||
</i18n-t>
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<div class="flex items-center gap-3">
|
||||
<w-badge v-if="submission.author.isGuest" color="grey-7" rounded>
|
||||
{{ t('inbox.reviewGuest') }}
|
||||
</w-badge>
|
||||
<!-- The page moved on after this was written; see `isStale` on the API side. -->
|
||||
<w-badge v-if="submission.isStale" color="warning" rounded>
|
||||
{{ t('inbox.reviewStale') }}
|
||||
</w-badge>
|
||||
<w-icon name="la:angle-right" color="grey" />
|
||||
</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<!-- ONE SUBMISSION -->
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<template v-else>
|
||||
<div class="flex flex-none flex-wrap items-center gap-2 p-4">
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="la:arrow-left"
|
||||
color="grey"
|
||||
:aria-label="t(`inbox.reviewBack`)"
|
||||
@click="closeSubmission">
|
||||
<w-tooltip>{{ t(`inbox.reviewBack`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-subtitle1">
|
||||
<strong>{{ state.selected.page.title }}</strong>
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
<i18n-t keypath="inbox.reviewSubmittedBy" scope="global">
|
||||
<template #author>
|
||||
<strong>{{ state.selected.author.name || t('inbox.reviewUnknownAuthor') }}</strong>
|
||||
</template>
|
||||
<template #date>{{ humanizeDate(state.selected.createdAt) }}</template>
|
||||
</i18n-t>
|
||||
<template v-if="state.selected.author.email">
|
||||
· {{ state.selected.author.email }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
icon="la:external-link-alt"
|
||||
color="grey"
|
||||
:label="t(`inbox.reviewViewPage`)"
|
||||
no-caps
|
||||
:href="`/` + state.selected.page.path"
|
||||
target="_blank" />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
icon="la:times"
|
||||
color="negative"
|
||||
:label="t(`inbox.reviewDecline`)"
|
||||
no-caps
|
||||
@click="rejectSubmission" />
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="la:check"
|
||||
color="positive"
|
||||
:label="t(`inbox.reviewApprove`)"
|
||||
no-caps
|
||||
@click="approveSubmission" />
|
||||
</div>
|
||||
<!--
|
||||
A warning rather than a block: the reviewer can see both sides in the diff below and edit the
|
||||
result before accepting, which is exactly what a stale suggestion needs.
|
||||
-->
|
||||
<!-- Literal colour classes: WBanner has no `color` prop, so one would be silently dropped. -->
|
||||
<w-banner
|
||||
v-if="state.selected.isStale"
|
||||
class="mx-4 mb-2 flex-none bg-warning text-black"
|
||||
rounded>
|
||||
{{ t('inbox.reviewStaleHint') }}
|
||||
</w-banner>
|
||||
<div class="flex-none px-4 pb-2 text-caption text-grey">
|
||||
{{ t('inbox.reviewDiffHint') }}
|
||||
</div>
|
||||
<!-- The diff itself: current page on the left, the suggestion on the right and editable. -->
|
||||
<div ref="diffEl" class="inbox-review-diff" />
|
||||
</template>
|
||||
|
||||
<w-inner-loading :showing="state.loading > 0" />
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { confirm } from '@/composables/dialog'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// STORES
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
title: t('inbox.pendingReview')
|
||||
})
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
submissions: [],
|
||||
/** The submission being reviewed, with both sides of the diff. Null while the queue is showing. */
|
||||
selected: null
|
||||
})
|
||||
|
||||
// REFS
|
||||
|
||||
const diffEl = ref(null)
|
||||
|
||||
/*
|
||||
The Monaco instances, deliberately outside `state`: they are large objects with their own internals,
|
||||
and making them reactive buys nothing and costs a lot.
|
||||
*/
|
||||
let diffEditor = null
|
||||
let originalModel = null
|
||||
let modifiedModel = null
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> The container only exists once a submission is open, so the editor is built after that render
|
||||
watch(
|
||||
() => state.selected?.id,
|
||||
async (id) => {
|
||||
if (!id) {
|
||||
disposeEditor()
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
mountEditor()
|
||||
}
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
|
||||
async function apiMessage(err) {
|
||||
return (
|
||||
(await err.response
|
||||
?.json()
|
||||
.then((b) => b?.message)
|
||||
.catch(() => null)) ?? err.message
|
||||
)
|
||||
}
|
||||
|
||||
function humanizeDate(val) {
|
||||
return Temporal.Instant.from(val).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
try {
|
||||
// -> The markdown renderer is configured per site (line breaks, typographer, and so on), and that
|
||||
// configuration comes with the editor configs rather than on its own
|
||||
if (!editorStore.configIsLoaded) {
|
||||
await editorStore.fetchConfigs()
|
||||
}
|
||||
state.submissions =
|
||||
(await API_CLIENT.get(`sites/${siteStore.id}/approvals/submissions`).json()) ?? []
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('inbox.reviewLoadFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function openSubmission(submission) {
|
||||
state.loading++
|
||||
try {
|
||||
state.selected = await API_CLIENT.get(
|
||||
`sites/${siteStore.id}/approvals/submissions/${submission.id}`
|
||||
).json()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('inbox.reviewLoadFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
function closeSubmission() {
|
||||
state.selected = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff, as the reviewer works on it.
|
||||
*
|
||||
* Left is the page as it stands, read-only. Right is the suggestion, and is not: the reviewer can
|
||||
* adjust it before accepting, which is what makes a stale or nearly-right suggestion usable. What
|
||||
* ends up on the page is whatever the right-hand model says at that moment, which is why approving
|
||||
* reads the model rather than the value that was loaded.
|
||||
*/
|
||||
function mountEditor() {
|
||||
if (!diffEl.value || !state.selected) {
|
||||
return
|
||||
}
|
||||
disposeEditor()
|
||||
|
||||
// -> The markdown editor's theme, defined again here because that component may never have mounted
|
||||
monaco.editor.defineTheme('wikijs', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#070a0d',
|
||||
'editor.lineHighlightBackground': '#0d1117',
|
||||
'editorLineNumber.foreground': '#546e7a',
|
||||
'editorGutter.background': '#0d1117'
|
||||
}
|
||||
})
|
||||
|
||||
originalModel = monaco.editor.createModel(state.selected.pageContent ?? '', 'markdown')
|
||||
modifiedModel = monaco.editor.createModel(state.selected.content ?? '', 'markdown')
|
||||
|
||||
diffEditor = monaco.editor.createDiffEditor(diffEl.value, {
|
||||
automaticLayout: true,
|
||||
fontSize: 14,
|
||||
// -> Side by side: this screen exists to compare the two, and an inline diff of prose reads as a
|
||||
// jumble of half-lines
|
||||
renderSideBySide: true,
|
||||
originalEditable: false,
|
||||
readOnly: false,
|
||||
scrollBeyondLastLine: false,
|
||||
theme: 'wikijs',
|
||||
wordWrap: 'on'
|
||||
})
|
||||
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
|
||||
}
|
||||
|
||||
function disposeEditor() {
|
||||
diffEditor?.dispose()
|
||||
originalModel?.dispose()
|
||||
modifiedModel?.dispose()
|
||||
diffEditor = null
|
||||
originalModel = null
|
||||
modifiedModel = null
|
||||
}
|
||||
|
||||
/** What the reviewer settled on: the right-hand side of the diff as it stands now. */
|
||||
function reviewedContent() {
|
||||
return modifiedModel ? modifiedModel.getValue() : (state.selected?.content ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML for what is being approved, produced here for the same reason the editor produces it on
|
||||
* every save: the markdown pipeline is a frontend one. Without it the server would have to drive a
|
||||
* headless browser, which is an extension most instances do not install.
|
||||
*
|
||||
* @throws When the source will not render, which is worth stopping for -- approving would otherwise
|
||||
* publish a page whose HTML does not match its source.
|
||||
*/
|
||||
function renderReviewed(content) {
|
||||
const md = new MarkdownRenderer(editorStore.editors.markdown ?? {})
|
||||
return md.render(content)
|
||||
}
|
||||
|
||||
function approveSubmission() {
|
||||
confirm({
|
||||
title: t('inbox.reviewApprove'),
|
||||
message: t('inbox.reviewApproveConfirm', { page: state.selected.page.title }),
|
||||
cancel: true,
|
||||
okLabel: t('inbox.reviewApprove')
|
||||
}).onOk(async () => {
|
||||
state.loading++
|
||||
try {
|
||||
const content = reviewedContent()
|
||||
const resp = await API_CLIENT.post(
|
||||
`sites/${siteStore.id}/approvals/submissions/${state.selected.id}/approve`,
|
||||
{ json: { content, render: renderReviewed(content) } }
|
||||
).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('inbox.reviewApproveSuccess')
|
||||
})
|
||||
closeSubmission()
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('inbox.reviewApproveFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
})
|
||||
}
|
||||
|
||||
function rejectSubmission() {
|
||||
confirm({
|
||||
title: t('inbox.reviewDecline'),
|
||||
message: t('inbox.reviewDeclineConfirm'),
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('inbox.reviewDecline')
|
||||
}).onOk(async () => {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.post(
|
||||
`sites/${siteStore.id}/approvals/submissions/${state.selected.id}/reject`
|
||||
).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('inbox.reviewDeclineSuccess')
|
||||
})
|
||||
closeSubmission()
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('inbox.reviewDeclineFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
})
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
|
||||
onBeforeUnmount(disposeEditor)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.inbox-review {
|
||||
/*
|
||||
The diff takes whatever is left under the header rather than a fixed height: this page sits in a
|
||||
card that already fills the viewport, so a height in pixels would either overflow it or leave a
|
||||
gap under it.
|
||||
*/
|
||||
&-diff {
|
||||
flex: 1 1 auto;
|
||||
min-height: 400px;
|
||||
border-top: 1px solid rgba(#fff, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<w-page class="py-4">
|
||||
<div class="w-section-header">{{ t('inbox.watching') }}</div>
|
||||
<div class="p-4">
|
||||
<div class="text-body2">{{ t('inbox.watchingInfo') }}</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
title: t('inbox.watching')
|
||||
})
|
||||
</script>
|
||||
Loading…
Reference in new issue