dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

commit 84bc71eb9620d9b642c07c2dec070a337c5ec85d
parent e97580122b4ca752c9385f3e49958ae83f26265a
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Sun, 29 Jan 2023 02:54:34 +0900

feat: Revision 기능 추가 및 WikiArticle 스타일 추가

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
Acomponents/wiki/RevisionAlert.module.css | 1+
Acomponents/wiki/RevisionAlert.module.css.map | 2++
Acomponents/wiki/RevisionAlert.module.scss | 9+++++++++
Acomponents/wiki/RevisionAlert.tsx | 23+++++++++++++++++++++++
Acomponents/wiki/WikiArticle.module.css | 1+
Acomponents/wiki/WikiArticle.module.css.map | 2++
Acomponents/wiki/WikiArticle.module.scss | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcomponents/wiki/WikiArticle.tsx | 18++++++------------
Alib/htmlcache.ts | 35+++++++++++++++++++++++++++++++++++
Mlib/models/wiki_page.ts | 31+++++++++++++++++++++++++++++++
Mlib/utils/wiki.ts | 18+++++++++++++++++-
Mpages/wiki/[slug]/[...path].tsx | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
12 files changed, 257 insertions(+), 32 deletions(-)

diff --git a/components/wiki/RevisionAlert.module.css b/components/wiki/RevisionAlert.module.css @@ -0,0 +1 @@ +.revision-alert{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400}.revision-alert p{margin-bottom:1em}/*# sourceMappingURL=RevisionAlert.module.css.map */ diff --git a/components/wiki/RevisionAlert.module.css.map b/components/wiki/RevisionAlert.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["RevisionAlert.module.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAEA,gBCoHI,oBACA,kBACA,8BACA,gBDpHF,kBACE","file":"RevisionAlert.module.css"} +\ No newline at end of file diff --git a/components/wiki/RevisionAlert.module.scss b/components/wiki/RevisionAlert.module.scss @@ -0,0 +1,9 @@ +@use 'core/typography'; + +.revision-alert { + @include typography.apply('body-medium'); + + p { + margin-bottom: 1em; + } +} diff --git a/components/wiki/RevisionAlert.tsx b/components/wiki/RevisionAlert.tsx @@ -0,0 +1,23 @@ +import { WikiPage } from '@/lib/models/wiki_page' +import moment from 'moment' +import Link from 'next/link' +import styles from './RevisionAlert.module.css' + +export interface RevisionAlertProps { + page: WikiPage + recentURL: string +} + +export default function RevisionAlert (props: RevisionAlertProps) { + return ( + <blockquote className={styles['revision-alert']}> + <p> + 현재 {moment(props.page.updatedAt).format('YYYY년 MM월 DD일 HH시 mm분 ss초')}에 + 수정된 이전 버전을 보고 있습니다. + </p> + <p> + <Link href={props.recentURL}>최신 버전 보기</Link> + </p> + </blockquote> + ) +} diff --git a/components/wiki/WikiArticle.module.css b/components/wiki/WikiArticle.module.css @@ -0,0 +1 @@ +.article{line-height:1.5}.article h1{line-height:2.5rem;font-size:2rem;letter-spacing:0rem;font-weight:400}.article h2{line-height:2.25rem;font-size:1.75rem;letter-spacing:0rem;font-weight:400}.article h3{line-height:2rem;font-size:1.5rem;letter-spacing:0rem;font-weight:400}.article h4{line-height:1.75rem;font-size:1.375rem;letter-spacing:0rem;font-weight:400}.article h5{line-height:1.5rem;font-size:1rem;letter-spacing:.009375rem;font-weight:500}.article h6{line-height:1.25rem;font-size:.875rem;letter-spacing:.0071428571rem;font-weight:500}.article ul,.article ol{margin:1rem 0;padding-left:2rem}.article ul li ul,.article ul li ol,.article ol li ul,.article ol li ol{margin:0 1rem;padding-left:0}.article blockquote{border-left:1px solid rgba(0,0,0,0);padding-left:1rem;margin:1rem 0 2rem;border-color:#006b5a}@media(prefers-color-scheme: dark){.article blockquote{border-color:#2cdebf}}.article small{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400}.article hr{height:1px;border:none;background-color:#006b5a}@media(prefers-color-scheme: dark){.article hr{background-color:#2cdebf}}/*# sourceMappingURL=WikiArticle.module.css.map */ diff --git a/components/wiki/WikiArticle.module.css.map b/components/wiki/WikiArticle.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["WikiArticle.module.scss","../../styles/core/_typography.scss","../../styles/core/_vars.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAIA,SACE,gBAEA,YC+GE,mBACA,eACA,oBACA,gBD/GF,YC4GE,oBACA,kBACA,oBACA,gBD5GF,YCyGE,iBACA,iBACA,oBACA,gBDzGF,YCsGE,oBACA,mBACA,oBACA,gBDtGF,YCmGE,mBACA,eACA,0BACA,gBDnGF,YCgGE,oBACA,kBACA,8BACA,gBD/FF,wBACE,cACA,kBAGE,wEACE,cACA,eAKN,oBACE,oCACA,aExCE,KFyCF,mBAGE,qBGoFJ,mCH1FA,oBAMI,sBAIJ,eCsEE,oBACA,kBACA,8BACA,gBDrEF,YACE,WACA,YAGE,yBGuEJ,mCH5EA,YAKI","file":"WikiArticle.module.css"} +\ No newline at end of file diff --git a/components/wiki/WikiArticle.module.scss b/components/wiki/WikiArticle.module.scss @@ -0,0 +1,61 @@ +@use 'core/vars'; +@use 'core/colors'; +@use 'core/typography'; + +.article { + line-height: 1.5; + + h1 { + @include typography.apply('headline-large'); + } + h2 { + @include typography.apply('headline-medium'); + } + h3 { + @include typography.apply('headline-small'); + } + h4 { + @include typography.apply('title-large'); + } + h5 { + @include typography.apply('title-medium'); + } + h6 { + @include typography.apply('title-small'); + } + + ul, ol { + margin: vars.$gap 0; + padding-left: (2 * vars.$gap); + + li { + ul, ol { + margin: 0 1rem; + padding-left: 0; + } + } + } + + blockquote { + border-left: 1px solid transparent; + padding-left: vars.$gap; + margin: vars.$gap 0 (2 * vars.$gap); + + @include colors.apply-themes() using ($theme) { + border-color: colors.get($theme, 'primary'); + } + } + + small { + @include typography.apply('body-medium'); + } + + hr { + height: 1px; + border: none; + + @include colors.apply-themes() using ($theme) { + background-color: colors.get($theme, 'primary'); + } + } +} diff --git a/components/wiki/WikiArticle.tsx b/components/wiki/WikiArticle.tsx @@ -1,16 +1,10 @@ -import WikiBase from '@/components/wiki/WikiBase' +import { ReactNode } from 'react' +import styles from './WikiArticle.module.css' -export interface WikiViewProps { - slug: string - path: string - title: string - html: string -} - -export default function WikiArticle (props: WikiViewProps) { +export default function WikiArticle (props: { children: ReactNode }) { return ( - <WikiBase pageKind={'wiki'} title={props.title}> - <article dangerouslySetInnerHTML={{ __html: props.html }}/> - </WikiBase> + <article className={styles['article']}> + {props.children} + </article> ) } diff --git a/lib/htmlcache.ts b/lib/htmlcache.ts @@ -0,0 +1,35 @@ +import { getRedis } from '@/lib/redis' +import { parseIntOrDefault } from './utils/number' + +const DEFAULT_CACHE_TTL = 60 * 60 // 1 hour + +export function htmlCacheKey (textId: number) { + return `htmlcache:text/${textId}` +} + +export async function hasHtmlCache (textId: number) { + const redis = await getRedis() + return (await redis.exists(htmlCacheKey(textId))) === 1 +} + +export async function getHtmlCache (textId: number) { + const redis = await getRedis() + return await redis.get(htmlCacheKey(textId)) +} + +export async function putHtmlCache (textId: number, html: string) { + const redis = await getRedis() + const key = htmlCacheKey(textId) + await redis.set(key, html) + await refreshCacheImpl(redis, key) +} + +export async function refreshHtmlCache (textId: number) { + const redis = await getRedis() + await refreshCacheImpl(redis, htmlCacheKey(textId)) +} + +function refreshCacheImpl (redis: Awaited<ReturnType<typeof getRedis>>, key: string) { + return redis.expire(key, parseIntOrDefault(process.env.WIKI_PAGE_CACHE_TTL, DEFAULT_CACHE_TTL)) +} + diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts @@ -131,3 +131,34 @@ export const getWikiRawPage = modelBehaviour< } }) +const SQL_GET_WIKI_PAGE_REVISION = ` + select wp.wiki_id, wp.path, wc.text_id, wp.acl_data, wp.created_at, wc.created_at + from wiki_changes wc + inner join wiki_pages wp on wc.page_id = wp.id + where wp.wiki_id = ? + and wp.path = ? + and wc.id = ? +` + +export const getWikiPageRevision = modelBehaviour< + [wikiId: number, path: string, changeId: number], + WikiPage | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI_PAGE_REVISION, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return { + wikiId: row[0], + path: row[1], + textId: row[2], + acl: row[3], + createdAt: row[4], + updatedAt: row[5], + } +}) diff --git a/lib/utils/wiki.ts b/lib/utils/wiki.ts @@ -1,8 +1,10 @@ +import { WikiText } from '@/lib/models/wiki_text' import { GetServerSidePropsContext, NextApiRequest } from 'next' import { NextRouter } from 'next/router' +import { unzip as decompressGzip } from 'node:zlib' export const getSlugAndPath = ( - context: GetServerSidePropsContext | NextApiRequest | NextRouter + context: GetServerSidePropsContext | NextApiRequest | NextRouter, ): [slug: string | null, path: string | null] => { const { slug, path } = context.query if (typeof slug !== 'string' || path == null || !Array.isArray(path)) { @@ -10,3 +12,17 @@ export const getSlugAndPath = ( } return [slug, path.join('')] } + +export const getStringFromWikiText = async (wikiText: WikiText): Promise<string> => { + switch (wikiText.encoding) { + case 'gzip': + // TODO: implement gzipped wiki text + return 'TODO' + + case 'utf-8': + return wikiText.content + + default: + throw new Error(`Unknown wiki text encoding: ${wikiText.encoding}`) + } +} diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx @@ -1,28 +1,42 @@ +import RevisionAlert from '@/components/wiki/RevisionAlert' import WikiArticle from '@/components/wiki/WikiArticle' +import WikiBase from '@/components/wiki/WikiBase' +import { getHtmlCache, hasHtmlCache, putHtmlCache, refreshHtmlCache } from '@/lib/htmlcache' +import { render } from '@/lib/markup' import { withConnection } from '@/lib/model_helpers' import { getWikiViaSlug } from '@/lib/models/wiki_info' -import { getWikiHtmlPage } from '@/lib/models/wiki_page' +import { getWikiPage, getWikiPageRevision, WikiPage } from '@/lib/models/wiki_page' +import { getWikiText } from '@/lib/models/wiki_text' import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' import { authenticationFromCookies } from '@/lib/security/token' +import { getSlugAndPath, getStringFromWikiText } from '@/lib/utils/wiki' +import moment from 'moment' import { GetServerSideProps } from 'next' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { Fragment, useMemo } from 'react' export interface WikiViewPageProps { - wikiSlug: string - path: string + page: WikiPage html: string } export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (context) => { - const { slug, path: paths } = context.query - if (typeof slug !== 'string' || !Array.isArray(paths)) { - throw new Error('invalid query') + const { rev } = context.query + const [slug, path] = getSlugAndPath(context) + if (slug == null || path == null) { + return { notFound: true } } - const path = paths.join('/') + const revId = rev == null ? null : parseInt(rev as string) + if (revId != null && isNaN(revId)) { + return { notFound: true } + } const token = await authenticationFromCookies(context.req.cookies) - const wikiPage = await withConnection(async (conn) => { + const result = await withConnection(async (conn): + Promise<[page: WikiPage, html: string] | null> => { const wiki = await getWikiViaSlug(conn, [slug]) if (wiki == null) { return null @@ -32,30 +46,66 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async ( return null } - return await getWikiHtmlPage(conn, [wiki.id, path]) + const page = revId == null + ? await getWikiPage(conn, [wiki.id, path]) + : await getWikiPageRevision(conn, [wiki.id, path, revId]) + if (page == null) { + return null + } + + if (await hasHtmlCache(page.textId)) { + const html = await getHtmlCache(page.textId) + + if (html != null) { + await refreshHtmlCache(page.textId) + return [page, html] + } + } + + // Cache miss; do render + const wikiText = await getWikiText(conn, [page.textId]) + if (wikiText == null) { + return null + } + + const source = await getStringFromWikiText(wikiText) + const html = render(source) + + await putHtmlCache(page.textId, html) + return [page, html] }) - if (wikiPage == null) { + if (result == null) { return { notFound: true } } + const [page, html] = result + return { props: { - wikiSlug: slug, - path: path, - html: wikiPage.html, + page: page, + html: html, }, } } export default function WikiViewPage (props: WikiViewPageProps) { + const router = useRouter() + const [slug, path] = getSlugAndPath(router) + const { rev } = router.query + return ( <> - <WikiArticle - slug={props.wikiSlug} - path={props.path} - title={props.path} - html={props.html} - /> + <WikiBase pageKind={'wiki'} title={path ?? ''}> + <WikiArticle> + {rev != null && ( + <RevisionAlert + page={props.page} + recentURL={`/wiki/${slug}/${path}`} + /> + )} + <div dangerouslySetInnerHTML={{ __html: props.html }}/> + </WikiArticle> + </WikiBase> </> ) }