commit e97580122b4ca752c9385f3e49958ae83f26265a
parent 858770e4cb65803ef18230dcc5b34d60a3f711cf
Author: Yongbin Kim <iam@yongbin.kim>
Date: Sun, 29 Jan 2023 01:11:33 +0900
feat: 편집 내역(logs) 목록 페이지 추가
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
11 files changed, 249 insertions(+), 4 deletions(-)
diff --git a/.pnp.cjs b/.pnp.cjs
@@ -33,6 +33,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@testing-library/react", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.4.0"],\
["@testing-library/user-event", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:14.4.3"],\
["@types/bcrypt", "npm:5.0.0"],\
+ ["@types/diff", "npm:5.0.2"],\
["@types/jest", "npm:29.2.6"],\
["@types/jsonwebtoken", "npm:9.0.1"],\
["@types/node", "npm:18.11.18"],\
@@ -41,12 +42,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@types/react-dom", "npm:18.0.10"],\
["@types/testing-library__jest-dom", "npm:5.14.5"],\
["bcrypt", "npm:5.1.0"],\
+ ["diff", "npm:5.1.0"],\
["eslint", "npm:8.32.0"],\
["eslint-config-next", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.1.2"],\
["handlebars", "npm:4.7.7"],\
["jest", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jest-environment-jsdom", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jsonwebtoken", "npm:9.0.0"],\
+ ["moment", "npm:2.29.4"],\
["mysql2", "npm:3.0.1"],\
["nanoevents", "npm:7.0.1"],\
["nanoid", "npm:4.0.0"],\
@@ -1826,6 +1829,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD"\
}]\
]],\
+ ["@types/diff", [\
+ ["npm:5.0.2", {\
+ "packageLocation": "./.yarn/cache/@types-diff-npm-5.0.2-cc002907d4-8fbc419b5a.zip/node_modules/@types/diff/",\
+ "packageDependencies": [\
+ ["@types/diff", "npm:5.0.2"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["@types/graceful-fs", [\
["npm:4.1.6", {\
"packageLocation": "./.yarn/cache/@types-graceful-fs-npm-4.1.6-1eadcf742d-c3070ccdc9.zip/node_modules/@types/graceful-fs/",\
@@ -3295,6 +3307,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD"\
}]\
]],\
+ ["diff", [\
+ ["npm:5.1.0", {\
+ "packageLocation": "./.yarn/cache/diff-npm-5.1.0-d24d222280-c7bf0df7c9.zip/node_modules/diff/",\
+ "packageDependencies": [\
+ ["diff", "npm:5.1.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["diff-sequences", [\
["npm:29.3.1", {\
"packageLocation": "./.yarn/cache/diff-sequences-npm-29.3.1-817e98637b-8edab8c383.zip/node_modules/diff-sequences/",\
@@ -3361,6 +3382,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@testing-library/react", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.4.0"],\
["@testing-library/user-event", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:14.4.3"],\
["@types/bcrypt", "npm:5.0.0"],\
+ ["@types/diff", "npm:5.0.2"],\
["@types/jest", "npm:29.2.6"],\
["@types/jsonwebtoken", "npm:9.0.1"],\
["@types/node", "npm:18.11.18"],\
@@ -3369,12 +3391,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@types/react-dom", "npm:18.0.10"],\
["@types/testing-library__jest-dom", "npm:5.14.5"],\
["bcrypt", "npm:5.1.0"],\
+ ["diff", "npm:5.1.0"],\
["eslint", "npm:8.32.0"],\
["eslint-config-next", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.1.2"],\
["handlebars", "npm:4.7.7"],\
["jest", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jest-environment-jsdom", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jsonwebtoken", "npm:9.0.0"],\
+ ["moment", "npm:2.29.4"],\
["mysql2", "npm:3.0.1"],\
["nanoevents", "npm:7.0.1"],\
["nanoid", "npm:4.0.0"],\
@@ -6443,6 +6467,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD"\
}]\
]],\
+ ["moment", [\
+ ["npm:2.29.4", {\
+ "packageLocation": "./.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip/node_modules/moment/",\
+ "packageDependencies": [\
+ ["moment", "npm:2.29.4"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["ms", [\
["npm:2.1.2", {\
"packageLocation": "./.yarn/cache/ms-npm-2.1.2-ec0c1512ff-673cdb2c31.zip/node_modules/ms/",\
diff --git a/.yarn/cache/@types-diff-npm-5.0.2-cc002907d4-8fbc419b5a.zip b/.yarn/cache/@types-diff-npm-5.0.2-cc002907d4-8fbc419b5a.zip
Binary files differ.
diff --git a/.yarn/cache/diff-npm-5.1.0-d24d222280-c7bf0df7c9.zip b/.yarn/cache/diff-npm-5.1.0-d24d222280-c7bf0df7c9.zip
Binary files differ.
diff --git a/.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip b/.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip
Binary files differ.
diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
Binary files differ.
diff --git a/lib/models/wiki_change.ts b/lib/models/wiki_change.ts
@@ -10,6 +10,11 @@ export interface WikiChange {
createdAt: Date
}
+export interface WikiChangeListItem extends WikiChange {
+ nickname?: string
+ size: number
+}
+
const SQL_CREATE_WIKI_CHANGE = `
insert into wiki_changes (page_id, author_id, author_ip, text_id)
values (?, ?, ?, ?)
@@ -26,17 +31,44 @@ export const createWikiChange = modelBehaviour<
return rows.insertId
})
-const SQL_GET_WIKI_CHANGES = `
- select id, page_id, author_id, author_ip, text_id, created_at
+const SQL_COUNT_WIKI_CHANGES = `
+ select count(*)
from wiki_changes
where page_id = ?
+`
+
+export const countWikiChanges = modelBehaviour<
+ [pageId: number],
+ number
+>(async (conn, args) => {
+ const [rows] = await conn.query<RowDataPacket[]>({
+ sql: SQL_COUNT_WIKI_CHANGES,
+ }, args)
+
+ return rows[0][0]
+})
+
+const SQL_GET_WIKI_CHANGES = `
+ select
+ wc.id,
+ page_id,
+ author_id,
+ author_ip,
+ text_id,
+ created_at,
+ up.nickname,
+ length(wt.content)
+ from wiki_changes wc
+ inner join wiki_texts wt on wc.text_id = wt.id
+ left join user_profiles up on wc.author_id = up.login_id
+ where page_id = ?
order by created_at desc
limit ? offset ?
`
export const getWikiChanges = modelBehaviour<
[pageId: number, limit: number, offset: number],
- WikiChange[]
+ WikiChangeListItem[]
>(async (conn, args) => {
const [rows] = await conn.query<RowDataPacket[]>({
sql: SQL_GET_WIKI_CHANGES,
@@ -49,5 +81,8 @@ export const getWikiChanges = modelBehaviour<
authorIp: row[3],
textId: row[4],
createdAt: row[5],
+
+ nickname: row[6],
+ size: row[7],
}))
})
diff --git a/lib/utils/pagination.ts b/lib/utils/pagination.ts
@@ -0,0 +1,11 @@
+import { parseIntOrDefault } from '@/lib/utils/number'
+import { GetServerSidePropsContext } from 'next'
+
+export function getPageQuery (
+ queries: GetServerSidePropsContext['query'],
+ pageSize: number
+) {
+ const page = Math.max(parseIntOrDefault(queries.page, 1), 1)
+ const offset = (page - 1) * pageSize
+ return [page, offset]
+}
diff --git a/lib/utils/wiki.ts b/lib/utils/wiki.ts
@@ -1,7 +1,8 @@
import { GetServerSidePropsContext, NextApiRequest } from 'next'
+import { NextRouter } from 'next/router'
export const getSlugAndPath = (
- context: GetServerSidePropsContext | NextApiRequest
+ context: GetServerSidePropsContext | NextApiRequest | NextRouter
): [slug: string | null, path: string | null] => {
const { slug, path } = context.query
if (typeof slug !== 'string' || path == null || !Array.isArray(path)) {
diff --git a/package.json b/package.json
@@ -13,14 +13,17 @@
"dependencies": {
"@next/font": "13.1.2",
"@types/bcrypt": "^5.0.0",
+ "@types/diff": "^5.0.2",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"bcrypt": "^5.1.0",
+ "diff": "^5.1.0",
"eslint": "8.32.0",
"eslint-config-next": "13.1.2",
"handlebars": "^4.7.7",
"jsonwebtoken": "^9.0.0",
+ "moment": "^2.29.4",
"mysql2": "^3.0.1",
"nanoevents": "^7.0.1",
"nanoid": "^4.0.0",
diff --git a/pages/logs/[slug]/[...path].tsx b/pages/logs/[slug]/[...path].tsx
@@ -0,0 +1,138 @@
+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 { Pagination } from '@/components/Pagination'
+import { withConnection } from '@/lib/model_helpers'
+import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_acl'
+import { countWikiChanges, getWikiChanges, WikiChangeListItem } from '@/lib/models/wiki_change'
+import { getPageQuery } from '@/lib/utils/pagination'
+import { getSlugAndPath } from '@/lib/utils/wiki'
+import moment from 'moment'
+import { GetServerSideProps } from 'next'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useMemo } from 'react'
+
+const PAGE_SIZE = 30
+
+export interface WikiLogsPageProps {
+ page: number
+ count: number
+ changes: WikiChangeListItem[]
+}
+
+export const getServerSideProps: GetServerSideProps<WikiLogsPageProps> = async (context) => {
+ const [slug, path] = getSlugAndPath(context) ?? []
+ if (slug == null || path == null) {
+ console.error('slug or path is null', slug, path)
+ return { notFound: true }
+ }
+
+ const [page, offset] = getPageQuery(context.query, PAGE_SIZE)
+
+ const result = await withConnection(async (conn) => {
+ const aclInfo = await getWikiAndPageACLViaPath(conn, [slug, path])
+ if (aclInfo == null || aclInfo.pageId == null) {
+ return null
+ }
+
+ const count = await countWikiChanges(conn, [aclInfo.pageId])
+ const changes = await getWikiChanges(conn, [aclInfo.pageId, PAGE_SIZE, offset])
+
+ return { count, changes }
+ })
+ if (result == null || result.changes.length === 0) {
+ return { notFound: true }
+ }
+
+ return {
+ props: {
+ page,
+ count: result.count,
+ changes: result.changes
+ },
+ }
+}
+
+export default function WikiLogsPage (props: WikiLogsPageProps) {
+ const router = useRouter()
+ const [slug, path] = useMemo(() => getSlugAndPath(router), [router])
+ return (
+ <>
+ <Hero>
+ <Title kind="headline" size="large">편집 기록: {path}</Title>
+ </Hero>
+
+ <Container>
+ <Section>
+ <ul>
+ {props.changes.map((change, i, changes) => (
+ <li key={change.id}>
+ <Link href={`/wiki/${slug}/${path}?rev=${change.id}`}>
+ {moment(change.createdAt).format('YYYY년 MM월 DD일 hh시 mm분')}
+ </Link>
+
+ <AuthorLink
+ authorId={change.authorId}
+ authorIp={change.authorIp}
+ nickname={change.nickname}
+ />
+
+ <span>{change.size} bytes</span>
+
+ <SizeDiff
+ size={change.size}
+ prev={i + 1 === changes.length ? 0 : changes[i + 1].size}
+ />
+ </li>
+ ))}
+ </ul>
+ </Section>
+
+ <Section>
+ <Pagination page={props.page} totalCount={props.count} pageSize={PAGE_SIZE}/>
+ </Section>
+ </Container>
+ </>
+ )
+}
+
+function AuthorLink (props: { authorId?: number; authorIp?: string; nickname?: string }) {
+ if (props.authorId != null && props.nickname != null) {
+ return (
+ <Link href={`/users/${props.authorId}`}>
+ {props.nickname}
+ </Link>
+ )
+ }
+
+ if (props.authorIp != null) {
+ return (
+ <span>{props.authorIp}</span>
+ )
+ }
+
+ return (
+ <span>unknown</span>
+ )
+}
+
+function SizeDiff (props: { size: number; prev: number }) {
+ const diff = props.size - props.prev
+ if (diff > 0) {
+ return (
+ <span>(+{diff} bytes)</span>
+ )
+ }
+
+ if (diff < 0) {
+ return (
+ <span>(-{diff} bytes)</span>
+ )
+ }
+
+ return (
+ <span>(±0 bytes)</span>
+ )
+}
diff --git a/yarn.lock b/yarn.lock
@@ -1187,6 +1187,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/diff@npm:^5.0.2":
+ version: 5.0.2
+ resolution: "@types/diff@npm:5.0.2"
+ checksum: 8fbc419b5aca33f494026bf5f70e026f76367689677ef114f9c078ac738d7dbe96e6dda3fd8290e4a7c35281e2b60b034e3d7e3c968b850cf06a21279e7ddcbe
+ languageName: node
+ linkType: hard
+
"@types/graceful-fs@npm:^4.1.3":
version: 4.1.6
resolution: "@types/graceful-fs@npm:4.1.6"
@@ -2390,6 +2397,13 @@ __metadata:
languageName: node
linkType: hard
+"diff@npm:^5.1.0":
+ version: 5.1.0
+ resolution: "diff@npm:5.1.0"
+ checksum: c7bf0df7c9bfbe1cf8a678fd1b2137c4fb11be117a67bc18a0e03ae75105e8533dbfb1cda6b46beb3586ef5aed22143ef9d70713977d5fb1f9114e21455fba90
+ languageName: node
+ linkType: hard
+
"dir-glob@npm:^3.0.1":
version: 3.0.1
resolution: "dir-glob@npm:3.0.1"
@@ -2442,6 +2456,7 @@ __metadata:
"@testing-library/react": ^13.4.0
"@testing-library/user-event": ^14.4.3
"@types/bcrypt": ^5.0.0
+ "@types/diff": ^5.0.2
"@types/jest": ^29.2.6
"@types/jsonwebtoken": ^9.0.1
"@types/node": 18.11.18
@@ -2450,12 +2465,14 @@ __metadata:
"@types/react-dom": 18.0.10
"@types/testing-library__jest-dom": ^5.14.5
bcrypt: ^5.1.0
+ diff: ^5.1.0
eslint: 8.32.0
eslint-config-next: 13.1.2
handlebars: ^4.7.7
jest: ^29.3.1
jest-environment-jsdom: ^29.3.1
jsonwebtoken: ^9.0.0
+ moment: ^2.29.4
mysql2: ^3.0.1
nanoevents: ^7.0.1
nanoid: ^4.0.0
@@ -5078,6 +5095,13 @@ __metadata:
languageName: node
linkType: hard
+"moment@npm:^2.29.4":
+ version: 2.29.4
+ resolution: "moment@npm:2.29.4"
+ checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
+ languageName: node
+ linkType: hard
+
"ms@npm:2.1.2":
version: 2.1.2
resolution: "ms@npm:2.1.2"