dh_demo

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

commit 2f68d43f66f3724618f831bcd5758150fdcfbb58
parent 25b7706df4d6ea97ea5dd1606cde40bd40bff77b
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Wed, 25 Jan 2023 10:40:13 +0900

feat: Wiki View (/wiki/[slug]/[...path]) 페이지 추가

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

Diffstat:
M.yarn/install-state.gz | 0
Acomponents/wiki/WikiArticle.tsx | 16++++++++++++++++
Acomponents/wiki/WikiBase.tsx | 30++++++++++++++++++++++++++++++
Acomponents/wiki/WikiToolbar.module.css | 1+
Acomponents/wiki/WikiToolbar.module.css.map | 2++
Acomponents/wiki/WikiToolbar.module.scss | 46++++++++++++++++++++++++++++++++++++++++++++++
Acomponents/wiki/WikiToolbar.tsx | 38++++++++++++++++++++++++++++++++++++++
Mlib/models/wiki_info.ts | 32++++++++++++++++++++++++++++++++
Mpages/api/wiki/[slug]/[...path].tsx | 70++++++++++++++++++++++++++++++++++++++++++----------------------------
Apages/wiki/[slug]/[...path].tsx | 48++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 255 insertions(+), 28 deletions(-)

diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz Binary files differ. diff --git a/components/wiki/WikiArticle.tsx b/components/wiki/WikiArticle.tsx @@ -0,0 +1,16 @@ +import WikiBase from '@/components/wiki/WikiBase' + +export interface WikiViewProps { + slug: string + path: string + title: string + html: string +} + +export default function WikiArticle (props: WikiViewProps) { + return ( + <WikiBase pageKind={'wiki'} title={props.title}> + <article dangerouslySetInnerHTML={{ __html: props.html }}/> + </WikiBase> + ) +} diff --git a/components/wiki/WikiBase.tsx b/components/wiki/WikiBase.tsx @@ -0,0 +1,30 @@ +import Title from '@/components/elements/Title' +import Container from '@/components/layout/Container' +import Hero from '@/components/layout/Hero' +import Section from '@/components/layout/Section' +import WikiToolbar, { WikiToolbarProps } from '@/components/wiki/WikiToolbar' +import { ReactNode } from 'react' + +export interface WikiBaseProps { + pageKind: WikiToolbarProps['pageKind'] + title: string + children: ReactNode +} + +export default function WikiBase (props: WikiBaseProps) { + return ( + <> + <Hero> + <Title kind="headline">{props.title}</Title> + </Hero> + + <Container> + <WikiToolbar pageKind={props.pageKind} /> + + <Section> + {props.children} + </Section> + </Container> + </> + ) +} diff --git a/components/wiki/WikiToolbar.module.css b/components/wiki/WikiToolbar.module.css @@ -0,0 +1 @@ +.toolbar{display:flex;padding:0 2rem;justify-content:flex-end;margin-bottom:1rem}.item{min-width:5rem;border:0;line-height:1.5rem;padding:.5rem 1rem;background:rgba(0,0,0,0);text-align:center;cursor:pointer;user-select:none;line-height:1rem;font-size:.75rem;letter-spacing:.0416666667rem;font-weight:500;color:#191c1b;background:#fafdfa;transition:background 150ms;border-radius:999px}.item:hover{background-color:#e6f1ed}@media(prefers-color-scheme: dark){.item{color:#e1e3e0;background:#191c1b;transition:background 150ms;border-radius:999px}.item:hover{background-color:#1b2c28}}.item:hover{text-decoration:none}/*# sourceMappingURL=WikiToolbar.module.css.map */ diff --git a/components/wiki/WikiToolbar.module.css.map b/components/wiki/WikiToolbar.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["WikiToolbar.module.scss","../../styles/core/_vars.scss","../../styles/core/_typography.scss","../../styles/core/_elevate.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAYA,SACE,aACA,QARQ,OASR,yBACA,cChBI,KDmBN,MACE,UAVe,KAWf,SACA,YAbiB,OAcjB,QAfa,WAgBb,yBACA,kBACA,eACA,iBE2FE,iBACA,iBACA,8BACA,gBFzFA,cACA,mBACA,4BACA,oBAEA,YGeF,yBC4EA,mCJ7GF,MAaI,cACA,mBACA,4BACA,oBAEA,YGeF,0BHVA,YACE","file":"WikiToolbar.module.css"} +\ No newline at end of file diff --git a/components/wiki/WikiToolbar.module.scss b/components/wiki/WikiToolbar.module.scss @@ -0,0 +1,46 @@ +@use 'core/vars'; +@use 'core/colors'; +@use 'core/elevate'; +@use 'core/typography'; +@use 'sass:math'; + +$padding: 0 (vars.$gap * 2); +$margin-bottom: vars.$gap; +$item-padding: math.div(vars.$gap, 2) vars.$gap; +$item-line-height: 1.5rem; +$item-min-width: 5rem; + +.toolbar { + display: flex; + padding: $padding; + justify-content: flex-end; + margin-bottom: $margin-bottom; +} + +.item { + min-width: $item-min-width; + border: 0; + line-height: $item-line-height; + padding: $item-padding; + background: transparent; + text-align: center; + cursor: pointer; + user-select: none; + + @include typography.apply('label-medium'); + + @include colors.apply-themes() using ($theme) { + color: colors.get($theme, 'on-surface'); + background: colors.get($theme, 'surface'); + transition: background 150ms; + border-radius: 999px; + + &:hover { + @include elevate.apply-background-color($theme, 1, 'primary'); + } + } + + &:hover { + text-decoration: none; + } +} diff --git a/components/wiki/WikiToolbar.tsx b/components/wiki/WikiToolbar.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link' +import styles from './WikiToolbar.module.css'; + +export interface WikiToolbarProps { + pageKind: 'wiki' | 'edit' | 'talk' | 'xref' | 'logs' +} + +export default function WikiToolbar (props: WikiToolbarProps) { + return ( + <div className={styles['toolbar']}> + {props.pageKind !== 'wiki' && ( + <Link href={'/wiki/test/test'} className={styles['item']}> + View + </Link> + )} + {props.pageKind !== 'edit' && ( + <Link href={'/edit/test/test'} className={styles['item']}> + Edit + </Link> + )} + {props.pageKind !== 'talk' && ( + <Link href={`/talk/test/test`} className={styles['item']}> + Comments + </Link> + )} + {props.pageKind !== 'xref' && ( + <Link href={`/xref/test/test`} className={styles['item']}> + Backlinks + </Link> + )} + {props.pageKind !== 'logs' && ( + <Link href={'/logs/test/test'} className={styles['item']}> + History + </Link> + )} + </div> + ) +} diff --git a/lib/models/wiki_info.ts b/lib/models/wiki_info.ts @@ -74,6 +74,38 @@ export const listWikiByOwnerId = modelBehaviour< })) }) +const SQL_GET_WIKI = ` + select id, owner_id, slug, title, description, acl_data, created_at, updated_at + from wikis + where id = ? +` + +export const getWiki = modelBehaviour< + [ id: number ], + WikiInfo | null +>(async (conn, args) => { + const [rows] = await db.query<RowDataPacket[]>({ + sql: SQL_GET_WIKI, + }, [args]) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + + return { + id: row[0], + ownerId: row[1], + slug: row[2], + title: row[3], + description: row[4], + acl: row[5], + createdAt: row[6], + updatedAt: row[7], + } +}) + const SQL_GET_WIKI_VIA_SLUG = ` select id, owner_id, slug, title, description, acl_data, created_at, updated_at from wikis diff --git a/pages/api/wiki/[slug]/[...path].tsx b/pages/api/wiki/[slug]/[...path].tsx @@ -1,9 +1,10 @@ -import { ERR_FORBIDDEN, ERR_INTERNAL } from '@/lib/apierror' +import { ERR_INTERNAL } from '@/lib/apierror' import { withConnection } from '@/lib/model_helpers' -import { getWikiViaSlug } from '@/lib/models/wiki_info' -import { getWikiPage } from '@/lib/models/wiki_page' +import { getWiki, getWikiViaSlug } from '@/lib/models/wiki_info' +import { getWikiPage as modelGetWikiPage } from '@/lib/models/wiki_page' import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl' -import { authenticationFromCookies } from '@/lib/security/token' +import { AccessTokenPayload, authenticationFromCookies } from '@/lib/security/token' +import { Connection } from 'mysql2/promise' import { NextApiRequest, NextApiResponse } from 'next' export default async function handler (req: NextApiRequest, res: NextApiResponse) { @@ -25,6 +26,35 @@ function getParams (req: NextApiRequest, res: NextApiResponse) { return { slug, path: path.join('/') } } +export async function getWikiPage ( + conn: Connection, + token: AccessTokenPayload | null, + wikiIdOrSlug: number | string, + path: string +) { + const wiki = typeof wikiIdOrSlug === 'string' + ? await getWikiViaSlug(conn, [wikiIdOrSlug]) + : await getWiki(conn, [wikiIdOrSlug]) + if (wiki == null) { + return null + } + + if (!resolveACL(token, wiki.acl, ACL_ACTION_READ)) { + return null + } + + const page = await modelGetWikiPage(conn, [wiki.id, path]) + if (page == null) { + return null + } + + if (!resolveACL(token, page.acl, ACL_ACTION_READ)) { + return null + } + + return page +} + async function handleGet (req: NextApiRequest, res: NextApiResponse) { const params = getParams(req, res) if (params == null) { @@ -34,29 +64,13 @@ async function handleGet (req: NextApiRequest, res: NextApiResponse) { const { slug, path } = params const token = await authenticationFromCookies(req.cookies) - await withConnection(async (conn) => { - const wiki = await getWikiViaSlug(conn, [slug]) - if (wiki == null) { - res.status(404).json({ status: 'not found' }) - return - } - - if (!resolveACL(token, wiki.acl, ACL_ACTION_READ)) { - res.status(403).json(ERR_FORBIDDEN) - return - } - - const page = await getWikiPage(conn, [wiki.id, path]) - if (page == null) { - res.status(404).json({ status: 'not found' }) - return - } - - if (!resolveACL(token, page.acl, ACL_ACTION_READ)) { - res.status(403).json(ERR_FORBIDDEN) - return - } - - res.status(200).json(page) + const wikiPage = await withConnection(async (conn) => { + return await getWikiPage(conn, token, slug, path) }) + if (wikiPage == null) { + res.status(404).json({ status: 'not found' }) + return + } + + res.status(200).json(wikiPage) } diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx @@ -0,0 +1,48 @@ +import WikiArticle from '@/components/wiki/WikiArticle' +import WikiToolbar from '@/components/wiki/WikiToolbar' +import { withConnection } from '@/lib/model_helpers' +import { getWikiPage } from '@/pages/api/wiki/[slug]/[...path]' +import { GetServerSideProps } from 'next' + +export interface WikiViewPageProps { + wikiSlug: string + path: string + 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 path = paths.join('/') + + const wikiPage = await withConnection(async (conn) => { + return await getWikiPage(conn, null, slug, path) + }) + if (wikiPage == null) { + return { notFound: true } + } + + return { + props: { + wikiSlug: slug, + path: path, + html: wikiPage.html, + }, + } +} + +export default function WikiViewPage (props: WikiViewPageProps) { + return ( + <> + <WikiArticle + slug={props.wikiSlug} + path={props.path} + title={props.path} + html={props.html} + /> + </> + ) +}