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:
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}
+ />
+ </>
+ )
+}