dh_demo

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

commit 3e50e147eeb82a674bc1d0c8e42bee24bcc6e7c4
parent b45f2b094dbd1e2fa8dd30cb3ee7d6b4d24c9f7c
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Fri, 27 Jan 2023 02:50:11 +0900

feat: Thread View 페이지 추가 등

- Thread View 페이지 추가
- Thread View 관련 API 추가
- Pagination 컴포넌트 추가
- Horizontal Fields 추가
- useForm, useRefresh 훅 추가

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

Diffstat:
Acomponents/Pagination.module.css | 1+
Acomponents/Pagination.module.css.map | 2++
Acomponents/Pagination.module.scss | 39+++++++++++++++++++++++++++++++++++++++
Acomponents/Pagination.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcomponents/form/Field.module.css | 2+-
Mcomponents/form/Field.module.css.map | 4++--
Mcomponents/form/Field.module.scss | 26++++++++++++++++++++++++++
Dcomponents/form/Fields.module.css | 1-
Dcomponents/form/Fields.module.css.map | 2--
Dcomponents/form/Fields.module.scss | 12------------
Mcomponents/form/Fields.tsx | 13+++++++++++--
Acomponents/threads/ThreadCommentList.module.css | 1+
Acomponents/threads/ThreadCommentList.module.css.map | 2++
Acomponents/threads/ThreadCommentList.module.scss | 2++
Acomponents/threads/ThreadCommentList.tsx | 18++++++++++++++++++
Acomponents/threads/ThreadCommentView.module.css | 1+
Acomponents/threads/ThreadCommentView.module.css.map | 2++
Acomponents/threads/ThreadCommentView.module.scss | 36++++++++++++++++++++++++++++++++++++
Acomponents/threads/ThreadCommentView.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Acomponents/threads/ThreadCommentWrite.module.css | 1+
Acomponents/threads/ThreadCommentWrite.module.css.map | 2++
Acomponents/threads/ThreadCommentWrite.module.scss | 8++++++++
Acomponents/threads/ThreadCommentWrite.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mlib/apierror.ts | 2++
Mlib/error_codes.ts | 2++
Alib/hooks/use_form.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/hooks/use_refresh.ts | 6++++++
Mlib/models/thread.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Alib/utils/id.ts | 13+++++++++++++
Alib/utils/number.ts | 10++++++++++
Apages/api/threads/[id]/comments.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Apages/threads/[id].tsx | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
32 files changed, 664 insertions(+), 22 deletions(-)

diff --git a/components/Pagination.module.css b/components/Pagination.module.css @@ -0,0 +1 @@ +.pagination{display:flex;justify-content:center;gap:1rem;margin-bottom:1rem}.item{display:block;width:3rem;height:3rem;line-height:3rem;text-align:center;border-radius:999px;box-shadow:0px 1px 2px 0px rgba(0, 0, 0, 0.2),0px 1px 3px 1px rgba(0, 0, 0, 0.1);background-color:#e8ebe8}@media(prefers-color-scheme: dark){.item{background-color:#292c2b}}span.item{cursor:default}a.item:hover{box-shadow:0px 1px 2px 0px rgba(0, 0, 0, 0.2),0px 2px 6px 2px rgba(0, 0, 0, 0.1);background-color:#d8dbd9}@media(prefers-color-scheme: dark){a.item:hover{background-color:#373a39}}/*# sourceMappingURL=Pagination.module.css.map */ diff --git a/components/Pagination.module.css.map b/components/Pagination.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["Pagination.module.scss","../styles/core/_vars.scss","../styles/core/_elevate.scss","../styles/core/_colors.scss"],"names":[],"mappings":"AAIA,YACE,aACA,uBACA,ICPI,KDQJ,cCRI,KDWN,MACE,cACA,WACA,YACA,iBACA,kBACA,oBE6CA,iFAVA,yBC4EA,mCHrHF,MEyCE,0BF1BF,UACE,eAIA,aE+BA,iFAVA,yBC4EA,mCHjGA,aEqBA","file":"Pagination.module.css"} +\ No newline at end of file diff --git a/components/Pagination.module.scss b/components/Pagination.module.scss @@ -0,0 +1,39 @@ +@use 'core/vars'; +@use 'core/colors'; +@use 'core/elevate'; + +.pagination { + display: flex; + justify-content: center; + gap: vars.$gap; + margin-bottom: vars.$gap; +} + +.item { + display: block; + width: 3rem; + height: 3rem; + line-height: 3rem; + text-align: center; + border-radius: 999px; + + @include elevate.apply-box-shadow(1); + + @include colors.apply-themes() using ($theme) { + @include elevate.apply-background-color($theme, 1, 'on-surface'); + } +} + +span.item { + cursor: default; +} + +a.item { + &:hover { + @include elevate.apply-box-shadow(2); + @include colors.apply-themes() using ($theme) { + @include elevate.apply-background-color($theme, 2, 'on-surface'); + } + } +} + diff --git a/components/Pagination.tsx b/components/Pagination.tsx @@ -0,0 +1,79 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ReactNode, useMemo } from 'react' +import styles from './Pagination.module.css' + +export type PaginationProps = { + page: number +} & ({ + totalCount: number + pageSize: number +} | { + pageCount: number +}) + +export function Pagination (props: PaginationProps) { + const router = useRouter() + + const [items, pageCount] = useMemo(() => { + const pageCount = 'pageCount' in props + ? props.pageCount + : Math.ceil(props.totalCount / props.pageSize) + + const from = Math.max(1, props.page - 2) + const to = Math.min(pageCount, props.page + 2) + + const items: ReactNode[] = [] + for (let i = from; i <= to; i++) { + if (i === props.page) { + items.push( + <span className={styles.item}>{i}</span>, + ) + continue + } + + items.push( + <Link + href={{ + pathname: router.pathname, + query: { + ...router.query, + page: i, + }, + }} + className={styles.item} + >{i}</Link>, + ) + } + + return [items, pageCount] + }, [props, router]) + + return ( + <div className={styles.pagination}> + <Link + href={{ + pathname: router.pathname, + query: { + ...router.query, + page: 1, + }, + }} + className={styles.item} + >&larr;</Link> + + {items} + + <Link + href={{ + pathname: router.pathname, + query: { + ...router.query, + page: pageCount, + }, + }} + className={styles.item} + >&rarr;</Link> + </div> + ) +} diff --git a/components/form/Field.module.css b/components/form/Field.module.css @@ -1 +1 @@ -.field{display:flex;position:relative;border:1px solid rgba(0,0,0,0);margin-bottom:1rem;border-radius:4px;background-color:inherit;box-shadow:0 0 0 1px rgba(0,0,0,0);transition:all 200ms;color:#191c1b;border-color:#6f7975}.field.is-disabled{color:rgba(25,28,27,.5);border-color:rgba(111,121,117,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#006b5a;box-shadow:0 0 0 1px #006b5a}.field.is-primary{color:#006b5a;border-color:#006b5a}.field.is-primary:focus-within{box-shadow:0 0 0 1px #006b5a}.field.is-secondary{color:#7b4998;border-color:#7b4998}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #7b4998}.field.is-tertiary{color:#426278;border-color:#426278}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #426278}.field.is-error{color:red;border-color:red}.field.is-error:focus-within{box-shadow:0 0 0 1px red}@media(prefers-color-scheme: dark){.field{color:#e1e3e0;border-color:#89938f}.field.is-disabled{color:rgba(225,227,224,.5);border-color:rgba(137,147,143,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#2cdebf;box-shadow:0 0 0 1px #2cdebf}.field.is-primary{color:#2cdebf;border-color:#2cdebf}.field.is-primary:focus-within{box-shadow:0 0 0 1px #2cdebf}.field.is-secondary{color:#e6b4ff;border-color:#e6b4ff}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #e6b4ff}.field.is-tertiary{color:#aacbe4;border-color:#aacbe4}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #aacbe4}.field.is-error{color:#ffb4ab;border-color:#ffb4ab}.field.is-error:focus-within{box-shadow:0 0 0 1px #ffb4ab}}.input,.textarea{width:100%;height:3.5rem;border:0;padding:0 1rem;color:inherit;background:rgba(0,0,0,0);outline:none}.field.has-icon-left .input,.field.has-icon-left .textarea{padding-left:3.25rem}.field.has-icon-right .input,.field.has-icon-right .textarea{padding-right:3.25rem}.textarea{resize:none;line-height:1.5rem;padding:1rem 1rem;overflow-y:hidden}.label-background{position:absolute;top:-3px;left:.6875rem;height:4px;padding:0 .3125rem;line-height:0;color:rgba(0,0,0,0);background-color:inherit;transform:translateX(-10%) scaleX(0);transition:200ms transform ease-in-out}.field.is-not-readonly:focus-within .label-background,.field.has-content .label-background{transform:translateX(-10%) scaleX(0.8)}.label{position:absolute;top:50%;left:1rem;pointer-events:none;transform-origin:left top;transform:translateY(-50%);transition:all .2s ease-in-out;z-index:1;color:#3f4946}.field.has-icon-left .label{left:.75rem}.field.is-not-readonly:focus-within .label,.field.has-content .label{top:0;transform:scale(0.8) translateY(-50%)}.field.is-not-readonly.has-no-color:focus-within .label{color:#006b5a}.field.is-disabled .label{color:rgba(63,73,70,.5)}.is-primary .label{color:#006b5a}.is-secondary .label{color:#7b4998}.is-tertiary .label{color:#426278}.is-error .label{color:red}@media(prefers-color-scheme: dark){.label{color:#bfc9c4}.field.is-not-readonly.has-no-color:focus-within .label{color:#2cdebf}.field.is-disabled .label{color:rgba(191,201,196,.5)}.is-primary .label{color:#2cdebf}.is-secondary .label{color:#e6b4ff}.is-tertiary .label{color:#aacbe4}.is-error .label{color:#ffb4ab}}.message{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400;margin-top:-0.75rem;margin-bottom:1rem;color:#3f4946}.is-primary .message{color:#006b5a}.is-secondary .message{color:#7b4998}.is-tertiary .message{color:#426278}.is-error .message{color:red}@media(prefers-color-scheme: dark){.message{color:#bfc9c4}.is-primary .message{color:#2cdebf}.is-secondary .message{color:#e6b4ff}.is-tertiary .message{color:#aacbe4}.is-error .message{color:#ffb4ab}}/*# sourceMappingURL=Field.module.css.map */ +.fields-wrapper{display:flex;flex-direction:column;align-items:flex-start;max-width:25rem;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){.fields-wrapper{background-color:#191c1b;color:#e1e3e0}}.fields-wrapper.is-horizontal{flex-direction:row;align-items:center;gap:1rem;max-width:100%}.field{display:flex;position:relative;border:1px solid rgba(0,0,0,0);margin-bottom:1rem;border-radius:4px;background-color:inherit;box-shadow:0 0 0 1px rgba(0,0,0,0);transition:all 200ms;align-self:stretch;color:#191c1b;border-color:#6f7975}.is-horizontal .field{margin-bottom:0;flex-grow:1}.field.is-disabled{color:rgba(25,28,27,.5);border-color:rgba(111,121,117,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#006b5a;box-shadow:0 0 0 1px #006b5a}.field.is-primary{color:#006b5a;border-color:#006b5a}.field.is-primary:focus-within{box-shadow:0 0 0 1px #006b5a}.field.is-secondary{color:#7b4998;border-color:#7b4998}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #7b4998}.field.is-tertiary{color:#426278;border-color:#426278}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #426278}.field.is-error{color:red;border-color:red}.field.is-error:focus-within{box-shadow:0 0 0 1px red}@media(prefers-color-scheme: dark){.field{color:#e1e3e0;border-color:#89938f}.field.is-disabled{color:rgba(225,227,224,.5);border-color:rgba(137,147,143,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#2cdebf;box-shadow:0 0 0 1px #2cdebf}.field.is-primary{color:#2cdebf;border-color:#2cdebf}.field.is-primary:focus-within{box-shadow:0 0 0 1px #2cdebf}.field.is-secondary{color:#e6b4ff;border-color:#e6b4ff}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #e6b4ff}.field.is-tertiary{color:#aacbe4;border-color:#aacbe4}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #aacbe4}.field.is-error{color:#ffb4ab;border-color:#ffb4ab}.field.is-error:focus-within{box-shadow:0 0 0 1px #ffb4ab}}.input,.textarea{width:100%;height:3.5rem;border:0;padding:0 1rem;color:inherit;background:rgba(0,0,0,0);outline:none}.field.has-icon-left .input,.field.has-icon-left .textarea{padding-left:3.25rem}.field.has-icon-right .input,.field.has-icon-right .textarea{padding-right:3.25rem}.textarea{resize:none;line-height:1.5rem;padding:1rem 1rem;overflow-y:hidden}.label-background{position:absolute;top:-3px;left:.6875rem;height:4px;padding:0 .3125rem;line-height:0;color:rgba(0,0,0,0);background-color:inherit;transform:translateX(-10%) scaleX(0);transition:200ms transform ease-in-out}.field.is-not-readonly:focus-within .label-background,.field.has-content .label-background{transform:translateX(-10%) scaleX(0.8)}.label{position:absolute;top:50%;left:1rem;pointer-events:none;transform-origin:left top;transform:translateY(-50%);transition:all .2s ease-in-out;z-index:1;color:#3f4946}.field.has-icon-left .label{left:.75rem}.field.is-not-readonly:focus-within .label,.field.has-content .label{top:0;transform:scale(0.8) translateY(-50%)}.field.is-not-readonly.has-no-color:focus-within .label{color:#006b5a}.field.is-disabled .label{color:rgba(63,73,70,.5)}.is-primary .label{color:#006b5a}.is-secondary .label{color:#7b4998}.is-tertiary .label{color:#426278}.is-error .label{color:red}@media(prefers-color-scheme: dark){.label{color:#bfc9c4}.field.is-not-readonly.has-no-color:focus-within .label{color:#2cdebf}.field.is-disabled .label{color:rgba(191,201,196,.5)}.is-primary .label{color:#2cdebf}.is-secondary .label{color:#e6b4ff}.is-tertiary .label{color:#aacbe4}.is-error .label{color:#ffb4ab}}.message{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400;margin-top:-0.75rem;margin-bottom:1rem;color:#3f4946}.is-primary .message{color:#006b5a}.is-secondary .message{color:#7b4998}.is-tertiary .message{color:#426278}.is-error .message{color:red}@media(prefers-color-scheme: dark){.message{color:#bfc9c4}.is-primary .message{color:#2cdebf}.is-secondary .message{color:#e6b4ff}.is-tertiary .message{color:#aacbe4}.is-error .message{color:#ffb4ab}}/*# sourceMappingURL=Field.module.css.map */ diff --git a/components/form/Field.module.css.map b/components/form/Field.module.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["Field.module.scss","../../styles/core/_colors.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAYA,OACE,aACA,kBACA,+BACA,mBACA,kBACA,yBACA,mCACA,qBAGE,cACA,qBAEA,mBACE,wBACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MC0GM,QDzGN,aCyGM,QDvGN,+BACE,6BALJ,oBACE,MC0GM,QDzGN,aCyGM,QDvGN,iCACE,6BALJ,mBACE,MC0GM,QDzGN,aCyGM,QDvGN,gCACE,6BALJ,gBACE,MC0GM,IDzGN,aCyGM,IDvGN,6BACE,yBCsFR,mCDpHF,OAWI,cACA,qBAEA,mBACE,2BACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MC0GM,QDzGN,aCyGM,QDvGN,+BACE,6BALJ,oBACE,MC0GM,QDzGN,aCyGM,QDvGN,iCACE,6BALJ,mBACE,MC0GM,QDzGN,aCyGM,QDvGN,gCACE,6BALJ,gBACE,MC0GM,QDzGN,aCyGM,QDvGN,6BACE,8BAOV,iBAEE,WACA,OAhDO,OAiDP,SACA,eACA,cACA,yBACA,aAIA,2DACE,aAHwB,QAM1B,6DACE,cAPwB,QAW5B,UACE,YACA,YA9DqB,OA+DrB,kBACA,kBAGF,kBAGE,kBACA,SACA,cACA,WACA,mBACA,cACA,oBACA,yBACA,qCACA,uCAEA,2FAEE,uCAIJ,OACE,kBACA,QACA,KA7FqB,KA8FrB,oBACA,0BACA,2BACA,+BACA,UAaE,cAXF,4BACE,KApGgB,OAuGlB,qEAEE,MACA,sCAMA,wDACE,cAGF,0BACE,wBAIA,mBACE,MCcM,QDfR,qBACE,MCcM,QDfR,oBACE,MCcM,QDfR,iBACE,MCcM,IAhBZ,mCD/BF,OAqBI,cAEA,wDACE,cAGF,0BACE,2BAIA,mBACE,MCcM,QDfR,qBACE,MCcM,QDfR,oBACE,MCcM,QDfR,iBACE,MCcM,SDRd,SElBI,oBACA,kBACA,8BACA,gBFiBF,oBACA,mBAGE,cAGE,qBACE,MCFM,QDCR,uBACE,MCFM,QDCR,sBACE,MCFM,QDCR,mBACE,MCFM,IAhBZ,mCDQF,SAMI,cAGE,qBACE,MCFM,QDCR,uBACE,MCFM,QDCR,sBACE,MCFM,QDCR,mBACE,MCFM","file":"Field.module.css"} -\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["Field.module.scss","../../styles/core/_colors.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAaA,gBACE,aACA,sBACA,uBACA,UAbU,MAgBR,yBACA,cC2GF,mCDnHF,gBAOI,yBACA,eAGF,8BACE,mBACA,mBACA,SACA,eAIJ,OACE,aACA,kBACA,+BACA,mBACA,kBACA,yBACA,mCACA,qBACA,mBAQE,cACA,qBAPF,sBACE,gBACA,YAOA,mBACE,wBACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MCgFM,QD/EN,aC+EM,QD7EN,+BACE,6BALJ,oBACE,MCgFM,QD/EN,aC+EM,QD7EN,iCACE,6BALJ,mBACE,MCgFM,QD/EN,aC+EM,QD7EN,gCACE,6BALJ,gBACE,MCgFM,ID/EN,aC+EM,ID7EN,6BACE,yBC4DR,mCDhGF,OAiBI,cACA,qBAEA,mBACE,2BACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MCgFM,QD/EN,aC+EM,QD7EN,+BACE,6BALJ,oBACE,MCgFM,QD/EN,aC+EM,QD7EN,iCACE,6BALJ,mBACE,MCgFM,QD/EN,aC+EM,QD7EN,gCACE,6BALJ,gBACE,MCgFM,QD/EN,aC+EM,QD7EN,6BACE,8BAOV,iBAEE,WACA,OAzEO,OA0EP,SACA,eACA,cACA,yBACA,aAIA,2DACE,aAHwB,QAM1B,6DACE,cAPwB,QAW5B,UACE,YACA,YAvFqB,OAwFrB,kBACA,kBAGF,kBAGE,kBACA,SACA,cACA,WACA,mBACA,cACA,oBACA,yBACA,qCACA,uCAEA,2FAEE,uCAIJ,OACE,kBACA,QACA,KAtHqB,KAuHrB,oBACA,0BACA,2BACA,+BACA,UAaE,cAXF,4BACE,KA7HgB,OAgIlB,qEAEE,MACA,sCAMA,wDACE,cAGF,0BACE,wBAIA,mBACE,MCZM,QDWR,qBACE,MCZM,QDWR,oBACE,MCZM,QDWR,iBACE,MCZM,IAhBZ,mCDLF,OAqBI,cAEA,wDACE,cAGF,0BACE,2BAIA,mBACE,MCZM,QDWR,qBACE,MCZM,QDWR,oBACE,MCZM,QDWR,iBACE,MCZM,SDkBd,SE5CI,oBACA,kBACA,8BACA,gBF2CF,oBACA,mBAGE,cAGE,qBACE,MC5BM,QD2BR,uBACE,MC5BM,QD2BR,sBACE,MC5BM,QD2BR,mBACE,MC5BM,IAhBZ,mCDkCF,SAMI,cAGE,qBACE,MC5BM,QD2BR,uBACE,MC5BM,QD2BR,sBACE,MC5BM,QD2BR,mBACE,MC5BM","file":"Field.module.css"} +\ No newline at end of file diff --git a/components/form/Field.module.scss b/components/form/Field.module.scss @@ -2,6 +2,7 @@ @use 'core/colors'; @use 'core/typography'; +$max-width: 25rem; $height: 3.5rem; $icon-size: 1.5rem; $field-gap: 1rem; @@ -10,6 +11,25 @@ $padding-with-icon: 0.75rem; $label-focused-scale: 0.8; $textarea-line-height: 1.5rem; +.fields-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: $max-width; + + @include colors.apply-themes using ($theme) { + background-color: colors.get($theme, 'surface'); + color: colors.get($theme, 'on-surface'); + } + + &.is-horizontal { + flex-direction: row; + align-items: center; + gap: 1rem; + max-width: 100%; + } +} + .field { display: flex; position: relative; @@ -19,6 +39,12 @@ $textarea-line-height: 1.5rem; background-color: inherit; box-shadow: 0 0 0 1px transparent; transition: all 200ms; + align-self: stretch; + + .is-horizontal & { + margin-bottom: 0; + flex-grow: 1; + } @include colors.apply-themes using ($theme) { color: colors.get($theme, 'on-surface'); diff --git a/components/form/Fields.module.css b/components/form/Fields.module.css @@ -1 +0,0 @@ -.fields-wrapper{max-width:25rem;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){.fields-wrapper{background-color:#191c1b;color:#e1e3e0}}/*# sourceMappingURL=Fields.module.css.map */ diff --git a/components/form/Fields.module.css.map b/components/form/Fields.module.css.map @@ -1 +0,0 @@ -{"version":3,"sourceRoot":"","sources":["Fields.module.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAIA,gBACE,UAHU,MAMR,yBACA,cCuHF,mCD5HF,gBAII,yBACA","file":"Fields.module.css"} -\ No newline at end of file diff --git a/components/form/Fields.module.scss b/components/form/Fields.module.scss @@ -1,12 +0,0 @@ -@use 'core/colors'; - -$max-width: 25rem; - -.fields-wrapper { - max-width: $max-width; - - @include colors.apply-themes using ($theme) { - background-color: colors.get($theme, 'surface'); - color: colors.get($theme, 'on-surface'); - } -} diff --git a/components/form/Fields.tsx b/components/form/Fields.tsx @@ -1,13 +1,22 @@ +import classNames from '@/lib/classnames' import { ReactNode } from 'react' -import styles from './Fields.module.css' +import styles from './Field.module.css' export interface FormProps { children: ReactNode + horizontal?: boolean } export default function Fields (props: FormProps) { return ( - <div className={styles['fields-wrapper']}> + <div + {...classNames( + styles['fields-wrapper'], + props.horizontal === true + ? styles['is-horizontal'] + : null, + )} + > {props.children} </div> ) diff --git a/components/threads/ThreadCommentList.module.css b/components/threads/ThreadCommentList.module.css @@ -0,0 +1 @@ +/*# sourceMappingURL=ThreadCommentList.module.css.map */ diff --git a/components/threads/ThreadCommentList.module.css.map b/components/threads/ThreadCommentList.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"ThreadCommentList.module.css"} +\ No newline at end of file diff --git a/components/threads/ThreadCommentList.module.scss b/components/threads/ThreadCommentList.module.scss @@ -0,0 +1,2 @@ +.comment-list { +} diff --git a/components/threads/ThreadCommentList.tsx b/components/threads/ThreadCommentList.tsx @@ -0,0 +1,18 @@ +import ThreadCommentView from '@/components/threads/ThreadCommentView' +import { ThreadComment } from '@/lib/models/thread' +import styles from './ThreadCommentList.module.css' + +export interface ThreadCommentListProps { + comments: ThreadComment[] + startsFrom: number +} + +export default function ThreadCommentList (props: ThreadCommentListProps) { + return ( + <div className={styles['comment-list']}> + {props.comments.map((comment, i) => ( + <ThreadCommentView key={comment.id} index={props.startsFrom - i - 1} comment={comment} /> + ))} + </div> + ) +} diff --git a/components/threads/ThreadCommentView.module.css b/components/threads/ThreadCommentView.module.css @@ -0,0 +1 @@ +.comment{margin-bottom:1rem;padding:1rem;border-radius:.25rem;border:1px solid rgba(0,0,0,0);border-color:#6f7975;background-color:#fafdfa}@media(prefers-color-scheme: dark){.comment{border-color:#89938f;background-color:#191c1b}}.comment .meta{display:flex;flex-direction:row;align-items:center;gap:.5rem;margin-bottom:.5rem;line-height:1rem;font-size:.75rem;letter-spacing:.0333333333rem;font-weight:400}.comment .meta .idx,.comment .meta .author,.comment .meta .date{display:block}.comment .body{line-height:1.5}/*# sourceMappingURL=ThreadCommentView.module.css.map */ diff --git a/components/threads/ThreadCommentView.module.css.map b/components/threads/ThreadCommentView.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["ThreadCommentView.module.scss","../../styles/core/_vars.scss","../../styles/core/_elevate.scss","../../styles/core/_colors.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAKA,SACE,cCNI,KDOJ,aACA,qBACA,+BAGE,qBEwCF,yBC4EA,mCH3HF,SAOI,qBEwCF,0BFpCA,eACE,aACA,mBACA,mBACA,UACA,oBIiGA,iBACA,iBACA,8BACA,gBJhGA,gEAGE,cAIJ,eACE","file":"ThreadCommentView.module.css"} +\ No newline at end of file diff --git a/components/threads/ThreadCommentView.module.scss b/components/threads/ThreadCommentView.module.scss @@ -0,0 +1,36 @@ +@use 'core/vars'; +@use 'core/elevate'; +@use 'core/colors'; +@use 'core/typography'; + +.comment { + margin-bottom: vars.$gap; + padding: 1rem; + border-radius: 0.25rem; + border: 1px solid transparent; + + @include colors.apply-themes() using ($themes) { + border-color: colors.get($themes, 'outline'); + @include elevate.apply-background-color($themes, 1, 'surface'); + } + + .meta { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + @include typography.apply('body-small'); + + .idx, + .author, + .date { + display: block; + } + } + + .body { + line-height: 1.5; + } +} diff --git a/components/threads/ThreadCommentView.tsx b/components/threads/ThreadCommentView.tsx @@ -0,0 +1,44 @@ +import { ThreadComment } from '@/lib/models/thread' +import Link from 'next/link' +import styles from './ThreadCommentView.module.css' + +export interface ThreadCommentViewProps { + index: number + comment: ThreadComment +} + +function pad (n: number) { + return n < 10 ? `0${n}` : n +} + +function formatDate (date: Date) { + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + const hours = date.getHours() + const minutes = date.getMinutes() + const seconds = date.getSeconds() + + return `${year}-${pad(month)}-${pad(day)} ` + + `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` +} + +export default function ThreadCommentView (props: ThreadCommentViewProps) { + return ( + <div className={styles['comment']}> + <div className={styles['meta']}> + <span className={styles['idx']}>{props.index + 1}</span> + + <Link className={styles['author']} href={`/users/${props.comment.authorId}`}> + {props.comment.authorName} + </Link> + + <span className={styles['date']}>{formatDate(props.comment.createdAt)}</span> + </div> + + <div className={styles['body']}> + {props.comment.content} + </div> + </div> + ) +} diff --git a/components/threads/ThreadCommentWrite.module.css b/components/threads/ThreadCommentWrite.module.css @@ -0,0 +1 @@ +.comment-write{margin-bottom:1rem}/*# sourceMappingURL=ThreadCommentWrite.module.css.map */ diff --git a/components/threads/ThreadCommentWrite.module.css.map b/components/threads/ThreadCommentWrite.module.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["ThreadCommentWrite.module.scss","../../styles/core/_vars.scss"],"names":[],"mappings":"AAKA,eACE,cCNI","file":"ThreadCommentWrite.module.css"} +\ No newline at end of file diff --git a/components/threads/ThreadCommentWrite.module.scss b/components/threads/ThreadCommentWrite.module.scss @@ -0,0 +1,8 @@ +@use 'core/vars'; +@use 'core/elevate'; +@use 'core/colors'; +@use 'core/typography'; + +.comment-write { + margin-bottom: vars.$gap; +} diff --git a/components/threads/ThreadCommentWrite.tsx b/components/threads/ThreadCommentWrite.tsx @@ -0,0 +1,47 @@ +import { SubmitButton } from '@/components/elements/Button' +import Field from '@/components/form/Field' +import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' +import { useForm } from '@/lib/hooks/use_form' +import { useMemo } from 'react' +import styles from './ThreadCommentWrite.module.css' + +export interface ThreadCommentWriteProps { + threadId: number + onWritten?: () => void +} + +interface ThreadCommentWriteForm { + content: string +} + +export default function ThreadCommentWrite (props: ThreadCommentWriteProps) { + const [fields, updateFields, submit, isLoading, result, error] = useForm<ThreadCommentWriteForm>( + `/api/threads/${props.threadId}/comments`, + { content: '' }, + props.onWritten, + ) + + return ( + <div className={styles['comment-write']}> + <Form onSubmit={submit}> + <Fields horizontal> + <Field + type="textarea" + placeholder="내용" + value={fields.content} + onValueChange={updateFields.bind(null, 'content')} + disabled={isLoading} + color={error ? 'error' : undefined} + message={error?.message} + /> + + <SubmitButton + value={'댓글 작성'} + disabled={isLoading} + /> + </Fields> + </Form> + </div> + ) +} diff --git a/lib/apierror.ts b/lib/apierror.ts @@ -1,6 +1,7 @@ /* eslint sort-vars: "error" */ import { + ERR_CODE_BAD_ID, ERR_CODE_FORBIDDEN, ERR_CODE_INTERNAL, ERR_CODE_INVALID_REQUEST, @@ -23,6 +24,7 @@ function newErr (code: string, message?: string): ApiError { } export const + ERR_BAD_ID = newErr(ERR_CODE_BAD_ID, 'Bad ID'), ERR_FORBIDDEN = newErr(ERR_CODE_FORBIDDEN, 'Forbidden'), ERR_INTERNAL = newErr(ERR_CODE_INTERNAL, 'Internal Error'), ERR_INVALID_REQUEST = newErr(ERR_CODE_INVALID_REQUEST, 'Invalid request'), diff --git a/lib/error_codes.ts b/lib/error_codes.ts @@ -1,7 +1,9 @@ /* eslint sort-vars: "error" */ export const + ERR_CODE_BAD_ID = 'bad_id', ERR_CODE_DUPLICATED_SLUG = 'duplicated_slug', + ERR_CODE_EMPTY_CONTENT = 'empty_content', ERR_CODE_FORBIDDEN = 'forbidden', ERR_CODE_INTERNAL = 'internal_error', ERR_CODE_INVALID_REQUEST = 'invalid_request', diff --git a/lib/hooks/use_form.ts b/lib/hooks/use_form.ts @@ -0,0 +1,89 @@ +import { ApiError } from '@/lib/apierror' +import { Dispatch, useCallback, useReducer, useState } from 'react' + +export interface FormFields { + [key: string]: any +} + +type FormFieldAction<T extends FormFields> = Partial<{ + [K in keyof T]: T[K] +}> + +interface UpdateFieldOrDispatch<T extends FormFields> { + (fields: T): void + <K extends keyof T> (key: K, value: T[K]): void +} + +/** + * 폼 필드 값을 관리하는 훅 + * + * @param initial 초기 필드 값 + * @returns [fields, update] fields: 현재 폼 필드 값, update: 필드 값을 업데이트하는 함수 + */ +export const useFields = <T extends FormFields> (initial: T): [T, UpdateFieldOrDispatch<T>] => { + const [fields, dispatch] = useReducer((state: T, action: FormFieldAction<T>): T => { + return { ...state, ...action } + }, initial) + + const update = useCallback<UpdateFieldOrDispatch<T>>( + (fieldsOrKey: T | keyof T, value?: unknown) => { + if (typeof fieldsOrKey === 'object') { + dispatch(fieldsOrKey) + } else { + dispatch({ [fieldsOrKey]: value } as FormFieldAction<T>) + } + }, + [] + ) + + return [fields, update] +} + +/** + * 폼을 관리하고 submit 이벤트를 처리하기 위한 훅 + * + * TODO: 훅 대신 컴포넌트로 구현 + */ +export const useForm = <T extends FormFields, Result = unknown> ( + input: RequestInfo | URL, + initial: T, + onSuccess?: (result: Result) => void, +): [fields: T, updateFields: UpdateFieldOrDispatch<T>, submit: () => void, + isLoading: boolean, result: Result | null, error: ApiError | null] => { + const [fields, updateFields] = useFields<T>(initial) + const [isLoading, setLoading] = useState(false) + const [result, setResult] = useState<Result | null>(null) + const [error, setError] = useState<ApiError | null>(null) + + const submit = useCallback(() => { + setLoading(true) + setResult(null) + setError(null) + + !(async () => { + const res = await fetch(input, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(fields), + }) + + const data = await res.json() + setLoading(false) + + if (!res.ok) { + setError(data) + return + } + + setResult(data) + onSuccess?.(data) + })().catch((e) => { + console.error('useForm: unexpected error:', e) + setError({ code: 'internal_error', message: 'Internal error' }) + }) + }, [fields, input]) + + return [fields, updateFields, submit, isLoading, result, error] +} diff --git a/lib/hooks/use_refresh.ts b/lib/hooks/use_refresh.ts @@ -0,0 +1,6 @@ +import { useRouter } from 'next/router' + +export default function useRefresh () { + const router = useRouter() + return (keepScroll?: boolean) => router.replace(router.asPath, undefined, { scroll: !keepScroll }) +} diff --git a/lib/models/thread.ts b/lib/models/thread.ts @@ -6,7 +6,8 @@ export interface Thread { authorId: number authorName: string title: string - preview?: string + preview?: string // available on list + commentCount?: number // available on get createdAt: Date } @@ -61,6 +62,63 @@ export const createThread = modelBehaviour< return result.insertId }) +const SQL_GET_THREAD_AND_FIRST_COMMENT = ` + select t.id, + t.author_id, + u.nickname, + t.title, + t.created_at, + (select count(*) + from thread_comments + where thread_id = t.id) + as comment_count, + + c.id, + c.thread_id, + c.author_id, + u.nickname, + c.content, + c.created_at, + c.updated_at + from threads t + left join user_profiles u on u.login_id = t.author_id + left join thread_comments c on c.thread_id = t.id + where t.id = ? + order by c.id + limit 1 +` + +export const getThreadAndFirstComment = modelBehaviour< + [threadId: number], + [thread: Thread, comment: ThreadComment] | null +>(async (conn, args) => { + const [rows] = await conn.query<RowDataPacket[]>({ + sql: SQL_GET_THREAD_AND_FIRST_COMMENT, + }, args) + + if (rows.length === 0) { + return null + } + + const row = rows[0] + return [{ + id: row[0], + authorId: row[1], + authorName: row[2], + title: row[3], + createdAt: row[4], + commentCount: row[5], + }, { + id: row[6], + threadId: row[7], + authorId: row[8], + authorName: row[9], + content: row[10], + createdAt: row[11], + updatedAt: row[12], + }] +}) + export interface ThreadComment { id: number threadId: number @@ -74,7 +132,7 @@ export interface ThreadComment { const SQL_LIST_THREAD_COMMENTS = ` select c.id, c.thread_id, c.author_id, u.nickname, c.content, c.created_at, c.updated_at from thread_comments c - left join user_profiles u on u.login_id = c.author_id + left join user_profiles u on u.login_id = c.author_id where c.thread_id = ? order by c.created_at desc limit ? offset ? diff --git a/lib/utils/id.ts b/lib/utils/id.ts @@ -0,0 +1,13 @@ +import { NextApiRequest } from 'next' + +export function parseIdFromQuery (query: NextApiRequest['query']) { + const { id } = query + if (typeof id !== 'string') { + return null + } + const idNum = parseInt(id) + if (isNaN(idNum)) { + return null + } + return idNum +} diff --git a/lib/utils/number.ts b/lib/utils/number.ts @@ -0,0 +1,10 @@ +export function parseIntOrDefault (value: any, defaultValue: number): number { + if (typeof value === 'number') { + return value + } + if (typeof value !== 'string') { + return defaultValue + } + const parsed = parseInt(value) + return isNaN(parsed) ? defaultValue : parsed +} diff --git a/pages/api/threads/[id]/comments.ts b/pages/api/threads/[id]/comments.ts @@ -0,0 +1,47 @@ +import { ERR_BAD_ID, ERR_INTERNAL, ERR_UNAUTHORIZED } from '@/lib/apierror' +import { ERR_CODE_EMPTY_CONTENT } from '@/lib/error_codes' +import { createThreadComment } from '@/lib/models/thread' +import { authenticationFromCookies } from '@/lib/security/token' +import { parseIdFromQuery } from '@/lib/utils/id' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler (req: NextApiRequest, res: NextApiResponse) { + switch (req.method) { + case 'POST': + await handlePost(req, res) + break; + } +} + +async function handlePost (req: NextApiRequest, res: NextApiResponse) { + const id = parseIdFromQuery(req.query) + if (id == null) { + res.status(400).json(ERR_BAD_ID) + return + } + + const { content } = req.body + if (typeof content !== 'string' || content.length === 0) { + res.status(400).json({ + code: ERR_CODE_EMPTY_CONTENT, + message: 'Empty content is not allowed' + }) + return + } + + const tokenPayload = await authenticationFromCookies(req.cookies) + if (tokenPayload?.uid == null) { + res.status(401).json(ERR_UNAUTHORIZED) + return + } + + try { + await createThreadComment([id, tokenPayload.uid, content]) + } catch (e) { + console.error('createThreadComment: database error:', e) + res.status(500).json(ERR_INTERNAL) + return + } + + res.status(200).json({ status: 'ok' }) +} diff --git a/pages/threads/[id].tsx b/pages/threads/[id].tsx @@ -0,0 +1,110 @@ +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 ThreadCommentView from '@/components/threads/ThreadCommentView' +import ThreadCommentList from '@/components/threads/ThreadCommentList' +import ThreadCommentWrite from '@/components/threads/ThreadCommentWrite' +import useRefresh from '@/lib/hooks/use_refresh' +import { withConnection } from '@/lib/model_helpers' +import { + getThreadAndFirstComment, + listThreadComments, + Thread, + ThreadComment, +} from '@/lib/models/thread' +import { parseIntOrDefault } from '@/lib/utils/number' +import { GetServerSideProps, GetServerSidePropsResult } from 'next' +import { useRouter } from 'next/router' +import { useMemo } from 'react' + +const PAGE_SIZE = 10 + +export interface ThreadViewPageProps { + page: number + thread: Thread + firstComment: ThreadComment + comments: ThreadComment[] +} + +function getIdFromQuery (query: any) { + if (typeof query.id === 'string') { + return parseInt(query.id) + } + return null +} + +export const getServerSideProps: GetServerSideProps<ThreadViewPageProps> = async (context) => { + const id = getIdFromQuery(context.query) + if (id == null) { + return { + notFound: true, + } + } + + const page = Math.max(1, parseIntOrDefault(context.query.page, 1)) + + return await withConnection(async (conn): Promise<GetServerSidePropsResult<ThreadViewPageProps>> => { + const threadAndFirstComment = await getThreadAndFirstComment(conn, [id]) + if (threadAndFirstComment == null) { + return { + notFound: true, + } + } + + const [thread, firstComment] = threadAndFirstComment + const comments = await listThreadComments(conn, [id, PAGE_SIZE, (page - 1) * PAGE_SIZE]) + + return { + props: { + page, + thread, + firstComment, + comments, + }, + } + }) +} + +export default function ThreadViewPage (props: ThreadViewPageProps) { + const router = useRouter() + + const handleRefresh = async () => { + await router.replace({ + pathname: router.pathname, + query: { + ...router.query, + page: 1, + } + }) + } + + const idStartsFrom = useMemo(() => { + return (props.thread.commentCount ?? 0) - (props.page - 1) * PAGE_SIZE + }, [props.page]) + + return ( + <> + <Hero> + <Title kind="headline">{props.thread.title}</Title> + </Hero> + + <Container> + <Section> + <ThreadCommentView index={0} comment={props.firstComment} /> + </Section> + + <Section> + <ThreadCommentWrite threadId={props.thread.id} onWritten={handleRefresh} /> + <ThreadCommentList startsFrom={idStartsFrom} comments={props.comments} /> + <Pagination + page={props.page} + totalCount={props.thread.commentCount ?? 0} + pageSize={PAGE_SIZE} + /> + </Section> + </Container> + </> + ) +}