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