[TOC]
[ ←전편보기 / 1편, 2편, 3편, 4편, 5편 ]
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403
1. 자! 드가자~!
아마 이번이 3월에 진행했던 스터디 편의 마지막 포스팅이 될 거 같다.
(딸랑 4일치 스터디 내용을 무려 6개월동안 레포팅 하고 있다....)
중요한건 이제껏 만든게 고작 게시판 이라는거다!
(고작 게시판 이지만 구축이 만만하지만은 않다는걸 깨달았다)
이번편은 남은 내용 전부를 리뷰할 거기 때문에 꽤 길어질 것이 예상되지만
....
뭐..
...
어쨌든 마무리 해 보자!
이번 편에서 작성 및 수정 할 파일들은 아래와 같다.
└── src
├── components
│ ├── footer.tsx (꼬리말 컴포넌트)
│ └── header.tsx (머리말 컴포넌트)
├── libs
│ └── app-context.ts (통합기능 라이브러리)
└── pages
├── atc
│ ├── atc01001s01.jsx (게시물 작성 페이지)
│ ├── atc01001s02
│ │ ├── [articleid].jsx (게시물 조회 페이지)
│ ├── atc01001s03
│ │ ├── [articleid].jsx (게시물 수정 페이지)
│ └── atc01001s04
│ ├── [pagenum].jsx (게시물 목록 페이지)
│ └── index.jsx (게시물 목록 페이지)
├── lgn
│ └── lgn01001s01.jsx (로그인 페이지)
├── mai
│ └── mai01001s01.jsx (메인 페이지)
├── usr
│ ├── usr01001s01.jsx (회원가입 페이지)
│ ├── usr01001s02.jsx (가입완료 페이지)
│ └── usr01001s03.jsx (마이페이지)
├── index.jsx (메인 페이지)
└── global.scss (전역 스타일시트)
2. 추가 모듈 (컴포넌트 / 라이브러리)
2-1. global.scss
(전역 스타일시트)
우선, 이번 포스팅에 추가할 컴포넌트 및 페이지에 대한 스타일을 추가 및 수정해 준다
html {
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0;
header {
position: fixed;
top: 0;
width: calc(100%);
max-width: 100vw;
height: 1rem;
z-index: 999;
> div {
background-color: #fff;
text-align: center;
margin-bottom: 1rem;
padding-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ccc;
align-items: center;
> div:nth-child(1) {
width: 64px;
}
> div:nth-child(2) {
width: calc(100% - 4rem)
}
> div:nth-child(3) {
width: 64px;
}
}
}
.header-aside > .MuiPaper-root {
width: 30%;
max-width: 10rem;
padding-top: 1rem;
> div {
> .MuiButtonBase-root {
width: 100%;
}
}
}
main {
margin-top: calc(3rem + 1.2rem + 2px + 1rem);
margin-bottom: calc(6rem + 1.2rem + 2px);
> div > section {
margin-left: auto;
margin-right: auto;
}
}
footer {
transition: opacity 0.3s, transform 0.2s;
position: fixed;
opacity: 1;
top: calc(100dvh - 3rem - 1.2rem - 2px);
width: calc(100%);
max-width: 100vw;
z-index: 999;
> div {
background-color: #fff;
text-align: center;
margin-top: 1rem;
padding-top: 1rem;
padding-bottom: 1rem;
border-top: 1px solid #ccc;
}
}
/** sticky 기능을 위해 css변수를 사용 */
footer :root {
--screen-height: 100dvh;
--scroll-top: 0;
--footer-height: calc(3rem + 1.2rem + 2px)
}
footer.sticky {
position: absolute;
top: calc(var(--screen-height) + var(--scroll-top) - var(--footer-height))
}
footer.hide {
opacity: 0;
transform: translateY(0.5rem)
}
}
}
section.title {
display: flex;
justify-content: space-between;
align-items: center;
}
section.flex-form {
display: flex;
justify-content: center;
form {
width: calc(60vw + 1rem + 4rem + 32px);
article {
hr {
margin-top: 2rem;
margin-bottom: 2rem;
}
div.form-block {
margin-bottom: 0.5rem;
> label[for] {
display: flex;
align-items: center;
justify-content: left;
width: 7rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
> label[for] + div.form-element {
// width: calc(60vw);
width: 100%;
margin-right: 0.5rem;
}
> label[for] + div.form-element.user-id {
> div:nth-child(1) {
width: calc(100% - 5rem - 0.5rem - 10px);
margin-right: 0.5rem;
}
> div:nth-child(2) {
width: calc(5rem);
}
}
> label[for] + div.form-element.email {
> div:nth-child(1) {
width: calc(50% - 1rem);
}
> span:nth-child(2) {
display: inline-block;
width: 2rem;
padding-top: 0.5rem;
text-align: center;
}
> div:nth-child(3) {
width: calc(50% - 1rem);
}
}
> label[for] + div.form-element.editor {
> div > div[contenteditable] {
border: 1px solid #ccc;
border-radius: 0.2rem;
padding: 0.5rem;
height: 10rem;
overflow-y: auto;
}
}
> label[for] + div.form-element + button {
width: calc(4rem + 32px);
}
}
> .buttons {
display: flex;
justify-content: center;
}
}
}
}
.tiptap > p:first-child {
margin-top: 0;
}
.MuiInputBase-root > input + fieldset > legend {
display: none;
}
hr {
border-top: 1px solid #ccc;
border-bottom: 0;
}
table.articles {
> thead {
> tr {
> th {
border-left: 1px solid #ccc;
}
> th:first-child {
border-left: none;
}
}
}
> tbody {
> tr {
> td {
padding: 0.5rem;
}
> td.nopad {
padding: 0;
}
}
}
}
/** 좌우 폭이 너무 넓어지는것을 방지 */
@media (min-width:800px) {
section, hr {
max-width: 800px;
}
}
/** 이하 tailwind 에서 일부 내용을 차용해서 util css 로 사용 */
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.my-1 {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.flex {
display: -webkit-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
}
.justify-center {
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
.w-screen {
width: 100vw;
}
.w-full {
width: 100%;
}
.max-w-1\/2 {
max-width: calc(50%);
}
.max-w-2\/3 {
max-width: calc(100% * 2 / 3);
}
2-2. app-context.ts
(공통컨텍스트 모듈)
app-context.ts
파일 중 onload
메소드를 찾아 아래와 같이 수정한다
기존 주석 내용 (TODO: AES 암호화 정보 초기화
, TODO: 사용자 정보 초기화
) 에 대한 내용 추가
암호 교환 및 검증 절차, 공통서비스 내용 참고
... 중략 ...
/** APP 최초 구동시 수행되는 프로세스 */
async onload(props: AppProps) {
const $body = $(document.body)
appvars.router = props.router
if (appvars.astate == C.APPSTATE_INIT) {
appvars.astate = C.APPSTATE_START
try {
const api = (await import('@/libs/api')).default
const crypto = (await import('@/libs/crypto')).default
const userContext = (await import('@/libs/user-context')).default
const conf = decryptAES(encrypted(), C.CRYPTO_KEY)
app.putAll(appvars.config, conf)
log.setLevel(conf.log.level)
log.debug('CONF:', conf)
const cres = await api.get(`cmn01001`, {})
await crypto.rsa.init(app.getConfig().security.key.rsa, C.PRIVATE_KEY)
const aeskey = crypto.rsa.decrypt(cres?.check || '')
await crypto.aes.init(aeskey)
appvars.astate = C.APPSTATE_ENV
const userInfo = userContext.getUserInfo()
if (userInfo?.userId && (userInfo.accessToken?.expireTime || 0) > new Date().getTime()) { userContext.checkExpire() }
appvars.astate = C.APPSTATE_USER
} catch (e) {
appvars.astate = C.APPSTATE_ERROR
log.debug('E:', e)
}
const fnunload = async () => {
window.removeEventListener('beforeunload', fnunload)
$body.addClass('hide-onload')
}
const fnload = async () => {
window.addEventListener('beforeunload', fnunload)
document.removeEventListener('DOMContentLoaded', fnload)
$body.removeClass('hide-onload')
/** 트랜지션시간 300ms */
setTimeout(() => appvars.astate = C.APPSTATE_READY, 300)
}
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', fnload)
} else {
fnload()
}
}
},
... 중략 ...
2-3. header.tsx
(머리말 컴포넌트)
헤더 컴포넌트를 다음과 같이 수정해 준다. (Aside 형태 메뉴 기능 추가)
import app from '@/libs/app-context'
import userContext from '@/libs/user-context'
import * as C from '@/libs/constants'
import { Container, Block, Button, Link } from '@/components'
import { Drawer } from '@mui/material'
import { Menu as MenuIcon, ArrowBackIos as ArrowBackIcon } from '@mui/icons-material';
const { defineComponent, useSetup, goPage } = app
export default defineComponent(() => {
const self = useSetup({
vars: {
aside: false
},
async mounted({ releaser }) {
/** 사용자 로그인 만료시간 모니터링 */
releaser(userContext.subscribe(() => update(C.UPDATE_SELF)))
}
})
const { vars, update, ready } = self()
const userInfo = userContext.getUserInfo()
/** aside 메뉴 오픈 */
const openAside = (visible: boolean) => {
vars.aside = visible
update(C.UPDATE_FULL)
}
const logout = async () => {
await userContext.logout()
alert('로그아웃 되었어요')
goPage('/')
}
return (
<header>
<Container className='flex'>
<Block>
<Button
size='small'
onClick={() => goPage(-1)}
>
<ArrowBackIcon />
</Button>
</Block>
<Block>
<Link
href={ '/' }
>
HEADER
</Link>
</Block>
<Block>
<Button
size='small'
onClick={ () => openAside(true) }
>
<MenuIcon />
</Button>
</Block>
</Container>
<Drawer
className='header-aside'
anchor='right'
open={ vars.aside }
onClick={ () => openAside(false) }
>
{ ready() && (userInfo?.userId) && (
<>
<Block className='text-center my-1'>
{ userInfo.userNm }
</Block>
<Block className='text-center my-1'>
{ userInfo.timelabel }
</Block>
</>
) }
<Block>
<Button
href={'/'}
>
홈
</Button>
{ ready() && !(userInfo?.userId) ? (
<>
<Button
href={'/lgn/lgn01001s01'}
>
로그인
</Button>
<Button
href={'/usr/usr01001s01'}
>
회원가입
</Button>
</>
) : (
<>
<Button
onClick={ userContext.tokenRefresh }
>
로그인연장
</Button>
<Button
onClick={ logout }
>
로그아웃
</Button>
<Button
href='/usr/usr01001s03'
>
마이페이지
</Button>
</>
) }
<Button
href='/atc/atc01001s04/1'
>
게시판
</Button>
</Block>
</Drawer>
</header>
)
})
2-4. footer.tsx
(꼬리말 컴포넌트)
꼬리말 컴포넌트를 다음과 같이 수정해 준다. (꼬리말 따라다니기 기능)
import $ from 'jquery'
import lodash from 'lodash'
import app from '@/libs/app-context'
import { Container } from '@/components'
const { defineComponent, useSetup, useRef } = app
const { debounce } = lodash
const evtlst = ['scroll', 'resize']
export default defineComponent(() => {
const eref = useRef({} as HTMLDivElement)
useSetup({
async mounted({ releaser }) {
evtlst.map(v => window.addEventListener(v, fncResize))
fncResize()
},
async unmount() {
evtlst.map(v => window.removeEventListener(v, fncResize))
}
})
const fncResizePost = debounce(() => {
const page = $('html,body')
/** footer sticky 기능을 위해 css 변수를 수정한다 */
const footer = eref.current || {}
{[
'--screen-height', `${window.innerHeight || 0}px`,
'--scroll-top', `${page.scrollTop()}px`,
'--footer-height', `${footer?.offsetHeight || 0}px`
].map((v, i, l) => (i % 2) &&
footer?.style?.setProperty(l[i - 1], v))}
footer?.classList?.remove('hide')
}, 10)
const fncResize = () => {
eref?.current?.classList?.add('sticky', 'hide')
fncResizePost()
}
return (
<footer ref={ eref }>
<Container>
FOOTER
</Container>
</footer>
)
})
3. 페이지 구축
3-1. /mai/mai01001s01.jsx
(메인 페이지)
먼저 몸풀기용으로 간단한 링크 페이지부터 만들어 보자
import app from '@/libs/app-context'
import userContext from '@/libs/user-context'
import { Button, Block, Container } from '@/components'
const { definePage, useSetup } = app
export default definePage(() => {
const { ready } = useSetup()()
const userInfo = userContext.getUserInfo()
return (
<Container>
<section className='title'>
<h2>메인페이지</h2>
</section>
<hr/>
<section>
<p> 샘플 게시판 어플리케이션 입니다. </p>
<p> 현재 페이지는 메인페이지 입니다. </p>
<article>
{ ready() && !(userInfo?.userId) && (
<Block>
<Button
href='/lgn/lgn01001s01'
>
로그인
</Button>
<Button
href='/usr/usr01001s01'
>
회원가입
</Button>
</Block>
) }
<Block>
<Button
href='/atc/atc01001s04/1'
>
게시판으로 이동
</Button>
</Block>
<Block>
<Button
href='/smp/smp01001s01'
>
샘플1
</Button>
</Block>
<Block>
<Button
href='/smp/smp01001s02'
>
샘플2
</Button>
</Block>
</article>
</section>
</Container>
)
})
3-2. /index.jsx
(메인 페이지)
/
URL로 접근시 단순히 만들어진 페이지(mai01001s01
)를 호출해 주도록 수정한다.
import app from '@/libs/app-context'
import Mai01001s01 from '@/pages/mai/mai01001s01'
export default app.definePage(() => {
return ( <Mai01001s01 />)
})
3-3. /usr/usr01001s01.jsx
(회원가입 페이지)
import app from '@/libs/app-context'
import api from '@/libs/api'
import values from '@/libs/values'
import crypto from '@/libs/crypto'
import * as C from '@/libs/constants'
import { Block, Form, Button, Input, Select, Container } from '@/components'
const { definePage, useSetup, log, goPage, clone, sleep } = app
const { matcher } = values
export default definePage(() => {
const PTN_EMAIL = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
const self = useSetup({
vars: {
formdata: {
userNm: '',
userId: '',
passwd: '',
passwd2: '',
emailId: '',
emailHost: '',
email: ''
},
iddupchk: false,
emailSelector: 'select',
emailHosts: [
{ name: '선택해 주세요', value: '' },
'gmail.com',
'naver.com',
'daum.net',
'kakao.com',
'hotmail.com',
'icloud.com',
{ name: '직접입력', value: '_' },
]
},
async mounted() {
log.debug('USR01001S01 - MOUNTED!')
}
})
const { update, vars } = self()
const emailHostChanged = async () => {
/** 직접입력인 경우 입력컴포넌트 전환 */
if (vars.formdata.emailHost == '_') {
vars.emailSelector = 'input'
vars.formdata.emailHost = ''
update(C.UPDATE_SELF)
}
}
const checkUserId = async () => {
const model = vars.formdata
vars.iddupchk = false
if (model.userId) {
model.userId = String(model.userId)
.toLowerCase().replace(/[^a-zA-Z0-9]+/, '')
const res = await api.get(`usr01001/${model.userId}`)
if (res?.rescd !== C.RESCD_OK) {
alert(`"${model.userId}" 는 이미 사용중이거나 사용할수 없어요.`)
} else {
vars.iddupchk = true
alert(`"${model.userId}" 는 사용 가능해요.`)
}
update(C.UPDATE_FULL)
} else {
alert('아이디를 입력해 주세요')
}
}
/** 회원가입, was 에 전달하기 전에 validation 부터 수행한다. */
const submit = async () => {
let msg = ''
let model = clone(vars.formdata)
model.email = `${model.emailId}@${model.emailHost}`
if (!msg && !model.userNm) { msg = '이름을 입력해 주세요' }
if (!msg && model.userNm.length < 2) { msg = '이름을 2글자 이상 입력해 주세요' }
if (!msg && !model.userId) { msg = '아이디를 입력해 주세요' }
if (!msg && model.userId.length < 4) { msg = '아이디를 4글자 이상 입력해 주세요' }
if (!msg && !vars.iddupchk) { msg = '아이디 중복확인을 수행해 주세요' }
if (!msg && !model.passwd) { msg = '비밀번호를 입력해 주세요' }
if (!msg && model.passwd.length < 4) { msg = '비밀번호를 4글자 이상 입력해 주세요' }
if (!msg && !model.passwd2) { msg = '비밀번호 확인을 입력해 주세요' }
if (!msg && model.passwd !== model.passwd2) { msg = '비밀번호 확인이 맞지 않아요' }
if (!msg && !model.emailId) { msg = '이메일을 입력해 주세요' }
if (!msg && !PTN_EMAIL.test(model.email)) { msg = `"${model.email}" 는 올바른 이메일 형식이 아니예요` }
if (msg) {
alert(msg)
} else {
let result = false
// dialog.progress(true)
try {
/** 필요한 파라메터만 복사한다. */
Object.keys(model).map((k) => ['userNm', 'userId', 'passwd', 'email'].indexOf(k) == -1 && delete model[k])
model.passwd = crypto.aes.encrypt(model.passwd)
let res = await api.put(`usr01001`, model)
log.debug('RES:', res)
if (res.rescd === C.RESCD_OK) {
result = true
await goPage(-1)
await goPage(`/usr/usr01001s02`)
}
} catch (e) {
log.debug('E:', e)
}
// thread(() => { dialog.progress(false) }, 500)
if (!result) {
alert('회원 등록에 실패했어요 잠시후 다시 시도해 주세요')
}
}
}
return (
<Container>
<section className='title'>
<h2>회원가입</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article>
<Block className='form-block'>
<label htmlFor='frm-user-nm'> 이름 </label>
<Block className='form-element'>
<Input
id='frm-user-nm'
model={ vars.formdata }
name='userNm'
placeholder='이름'
maxLength={ 20 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-user-id'>아이디</label>
<Block className='form-element user-id'>
<Input
id='frm-user-id'
model={ vars.formdata }
name='userId'
placeholder='영문자로 시작, 12자 이내'
maxLength={ 12 }
className='w-full'
size='small'
/>
<Button
variant='contained'
color='inherit'
onClick={ checkUserId }
>
중복확인
</Button>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-passwd'>비밀번호</label>
<Block className='form-element'>
<Input
type='password'
id='frm-passwd'
model={ vars.formdata }
name='passwd'
placeholder='영문자, 숫자, 특수기호 각 1개이상'
maxLength={ 30 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-passwd2'>비밀번호확인</label>
<Block className='form-element'>
<Input
type='password'
id='frm-passwd2'
model={ vars.formdata }
name='passwd2'
placeholder='비밀번호확인'
maxLength={ 30 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-email'>이메일</label>
<Block className='form-element email'>
<Input
id='frm-email'
model={ vars.formdata }
name='emailId'
placeholder='이메일 아이디'
maxLength={ 30 }
size='small'
/>
<span>@</span>
{ matcher(vars?.emailSelector, 'select',
'select', (
<Select
model={ vars.formdata }
name='emailHost'
options={ vars.emailHosts }
onChange={ emailHostChanged }
size='small'
/>
),
'input', (
<Input
model={ vars.formdata }
name='emailHost'
maxLength={ 30 }
size='small'
/>
)
) }
</Block>
</Block>
<hr/>
<Block className='buttons'>
<Button
className='mx-1'
variant='contained'
size='large'
onClick={ submit }
>
완료
</Button>
<Button
className='mx-1'
variant='outlined'
size='large'
>
취소
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-4. /usr/usr01001s02.jsx
(가입완료 페이지)
가입완료 환영페이지, lottie
를 사용한 애니메이션을 추가한다.
import app from '@/libs/app-context'
import { Block, Form, Button, Container, Lottie } from '@/components'
const { definePage, goPage } = app
export default definePage(() => {
return (
<Container>
<section className='title'>
<h2>회원가입 완료</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article className='text-center'>
<Block>
<p>
가입이 완료되었어요
</p>
<Lottie
src='/assets/lottie/hello.json'
/>
</Block>
<Block>
<Button
variant='contained'
onClick={() => goPage(-1) }
>
이전 페이지로 이동
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-5. /usr/usr01001s03.jsx
(마이페이지)
회원정보 수정페이지, 회원가입 페이지에서 많은 부분 복사 작성했다.
import app from '@/libs/app-context'
import api from '@/libs/api'
import userContext from '@/libs/user-context'
import values from '@/libs/values'
import crypto from '@/libs/crypto'
import * as C from '@/libs/constants'
import { Block, Form, Button, Input, Select, Container } from '@/components'
const { definePage, useSetup, log, goPage, clone } = app
const { matcher } = values
const userInfo = userContext.getUserInfo()
export default definePage(() => {
const PTN_EMAIL = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
const self = useSetup({
vars: {
formdata: {
id: '',
userNm: '',
userId: '',
passwd: '',
passwd2: '',
emailId: '',
emailHost: '',
email: '',
},
iddupchk: false,
emailSelector: 'select',
emailHosts: [
{ name: '선택해 주세요', value: '' },
'gmail.com',
'naver.com',
'daum.net',
'kakao.com',
'hotmail.com',
'icloud.com',
{ name: '직접입력', value: '_' },
]
},
/** email 등 저장하고 있지 않은 개인정보를 표시하기 위해 불러들임 */
async mounted() {
const res = await api.get(`usr01002/${userInfo.userId}`)
vars.formdata = res
const email = PTN_EMAIL.exec(vars.formdata.email)
if (email) {
vars.formdata.emailId = email[1]
vars.formdata.emailHost = email[5]
}
update(C.UPDATE_ENTIRE)
}
})
const { update, vars, ready } = self()
const emailHostChanged = async () => {
if (vars.formdata.emailHost == '_') {
vars.emailSelector = 'input'
vars.formdata.emailHost = ''
update(C.UPDATE_SELF)
}
}
/** validation 진행 후 submit */
const submit = async () => {
let msg = ''
let model = clone(vars.formdata)
model.email = `${model.emailId}@${model.emailHost}`
if (!msg && model.passwd && model.passwd.length < 4) { msg = '비밀번호를 4글자 이상 입력해 주세요' }
if (!msg && model.passwd && !model.passwd2) { msg = '비밀번호 확인을 입력해 주세요' }
if (!msg && model.passwd && model.passwd !== model.passwd2) { msg = '비밀번호 확인이 맞지 않아요' }
if (!msg && !model.emailId) { msg = '이메일을 입력해 주세요' }
if (!msg && !PTN_EMAIL.test(model.email)) { msg = `"${model.email}" 는 올바른 이메일 형식이 아니예요` }
if (msg) {
alert(msg)
} else {
let result = false
try {
/** 필요한 파라메터만 복사한다. */
Object.keys(model).map((k) => ['id', 'userId', 'passwd', 'email'].indexOf(k) == -1 && delete model[k])
if (model.passwd) { model.passwd = crypto.aes.encrypt(model.passwd) }
let res = await api.put(`usr01002`, model)
log.debug('RES:', res)
if (res.rescd === C.RESCD_OK) {
result = true
goPage(-1)
}
} catch (e) {
log.debug('E:', e)
}
if (!result) {
alert('회원 정보 수정에 실패했어요 잠시후 다시 시도해 주세요')
}
}
}
return (
<Container>
<section className='title'>
<h2>회원정보 수정</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article>
{ ready() && (
<>
<Block className='form-block'>
<p> 이름 : { userInfo.userNm } </p>
</Block>
<Block className='form-block'>
<p> 아이디 : { userInfo.userId } </p>
</Block>
</>
) }
<Block className='form-block'>
<label htmlFor='frm-passwd'>비밀번호</label>
<Block className='form-element'>
<Input
type='password'
id='frm-passwd'
model={ vars.formdata }
name='passwd'
placeholder='변경시에만 입력해 주세요'
maxLength={ 30 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-passwd2'>비밀번호확인</label>
<Block className='form-element'>
<Input
type='password'
id='frm-passwd2'
model={ vars.formdata }
name='passwd2'
placeholder='비밀번호확인'
maxLength={ 30 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-email'>이메일</label>
<Block className='form-element email'>
<Input
id='frm-email'
model={ vars.formdata }
name='emailId'
placeholder='이메일 아이디'
maxLength={ 30 }
size='small'
/>
<span>@</span>
{ matcher(vars?.emailSelector, 'select',
'select', (
<Select
model={ vars.formdata }
name='emailHost'
options={ vars.emailHosts }
onChange={ emailHostChanged }
size='small'
/>
),
'input', (
<Input
model={ vars.formdata }
name='emailHost'
maxLength={ 30 }
size='small'
/>
)
) }
</Block>
</Block>
<hr/>
<Block className='buttons'>
<Button
className='mx-1'
variant='contained'
size='large'
onClick={ submit }
>
완료
</Button>
<Button
className='mx-1'
variant='outlined'
size='large'
onClick={ () => goPage(-1) }
>
취소
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-5. /lgn/lgn01001s01.jsx
(로그인 페이지)
자! 회원가입 까지 되었으니 이제 로그인 페이지를 작성해 보자
실제 토큰을 받아와서 영속저장 하는 부분은 api.ts
와 user-context.ts
에 구현되어 있다
로그인, 로그인서비스, 통신모듈, 사용자정보 모듈 내용참고
import app from '@/libs/app-context'
import api from '@/libs/api'
import crypto from '@/libs/crypto'
import * as C from '@/libs/constants'
import { Form, Block, Button, Container, Input } from '@/components'
const { definePage, useSetup, clone, log, goPage } = app
export default definePage(() => {
const self = useSetup({
vars: {
formdata: {
userId: '',
passwd: '',
}
}
})
const { vars } = self()
const submit = async () => {
const formdata = clone (vars.formdata)
formdata.passwd = crypto.aes.encrypt(formdata.passwd)
try {
const res = await api.post(`lgn01001`, formdata)
log.debug('RES:', res)
if (res.rescd === C.RESCD_OK) {
goPage(-1)
} else {
alert('로그인이 실패했습니다')
}
} catch (e) {
log.debug('E:', e)
if (e?.msgcode == 'USER_NOT_FOUND') {
alert('사용자 아이디 혹은 비밀번호가 잘못되었어요')
} else {
alert(e?.message || '오류가 발생했어요')
}
}
}
return (
<Container>
<section className='title'>
<h2>로그인</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article className='text-center'>
<Block className='form-block'>
<label htmlFor='frm-user-id'> 아이디 </label>
<Block className='form-element'>
<Input
id='frm-user-id'
model={ vars.formdata }
name='userId'
placeholder='로그인 아이디'
maxLength={ 20 }
className='w-full'
size='small'
onEnter={ submit }
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-passwd'> 비밀번호 </label>
<Block className='form-element'>
<Input
id='frm-passwd'
type='password'
model={ vars.formdata }
name='passwd'
placeholder='비밀번호'
maxLength={ 20 }
className='w-full'
size='small'
onEnter={ submit }
/>
</Block>
</Block>
<hr/>
<Block className='buttons'>
<Button
className='mx-1'
variant='contained'
size='large'
onClick={ submit }
>
로그인
</Button>
<Button
className='mx-1'
variant='outlined'
size='large'
>
취소
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-6. /atc/atc01001s01.jsx
(게시물 작성 페이지)
import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import { Block, Button, Container, Form, Editor } from '@/components'
import Input from '@/components/input'
import $ from 'jquery'
const { definePage, useSetup, log, clone, goPage } = app
export default definePage(() => {
const self = useSetup({
vars: {
formdata: {
title: '',
contents: '',
}
},
async mounted() {
}
})
const { vars, update } = self()
const submit = async () => {
let msg = ''
let model = clone(vars.formdata)
const contents = String($(model.contents).text()).trim()
if (!msg && !model.title) { msg = '제목을 입력해 주세요' }
if (!msg && model.title.length < 2) { msg = '제목을 2글자 이상 입력해 주세요' }
if (!msg && !contents) { msg = '내용을 입력해 주세요' }
if (!msg && contents.length < 2) { msg = '내용을 2글자 이상 입력해 주세요' }
if (msg) {
alert(msg)
} else {
let result = false
try {
/** 필요한 파라메터만 복사한다. */
Object.keys(model).map((k) => ['title', 'contents'].indexOf(k) == -1 && delete model[k])
log.debug('CHECK:', model)
const res = await api.put(`atc01001`, model)
log.debug('RES:', res)
if (res.rescd === C.RESCD_OK) {
result = true
goPage(-1)
}
} catch (e) {
msg = e?.message
log.debug('E:', e)
}
if (!result) {
alert(msg || '새글 쓰기에 실패했어요 잠시후 다시 시도해 주세요')
}
}
}
return (
<Container>
<section className='title'>
<h2>새글 작성</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article>
<Block className='form-block'>
<label htmlFor='frm-title'> 제목 </label>
<Block className='form-element'>
<Input
id='frm-title'
model={ vars.formdata }
name='title'
placeholder='제목'
maxLength={ 20 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-contents'> 내용 </label>
<Block className='form-element editor'>
<Editor
id='frm-contents'
model={ vars.formdata }
name='contents'
className='w-full'
size='small'
/>
</Block>
</Block>
<hr/>
<Block className='buttons'>
<Button
className='mx-1'
variant='contained'
size='large'
onClick={ submit }
>
완료
</Button>
<Button
className='mx-1'
variant='outlined'
size='large'
onClick={ () => goPage(-1) }
>
취소
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-7. /atc/atc01001s02/[articleid].jsx
(게시물 수정 페이지)
파일명이 '[ ]' 로 둘러쌓인 이유는 Java 의 path-variable 과 같이 사용하기 위함이다.
가령 브라우저에서 /atc/atc01001s02/132
로 호출한 경우, 페이지 내부에서 getParameter('articleid')
를 실행하면 132
값을 얻을 수 있다.
import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import userContext from '@/libs/user-context'
import { Container, Block, Input, Button, Link, Content, Form, Editor } from '@/components'
import moment from 'moment'
import $ from 'jquery'
const { definePage, useSetup, log, putAll, clone, getParameter, goPage } = app
export default definePage(() => {
const self = useSetup({
vars: {
formdata: {
id: '',
title: '',
contents: '',
userId: '',
userNm: '',
ctime: '',
utime: '',
boardId: '',
num: ''
},
},
async mounted() {
/** getParameter 로 path-variable (articleid)을 읽어온다 */
loadData(getParameter('articleid'))
}
})
const userInfo = userContext.getUserInfo()
const { vars, update, ready } = self()
const loadData = async (articleId) => {
const res = await api.get(`atc01001/${articleId}`)
vars.formdata = clone(res)
log.debug('RES:', res)
setTimeout(() => update(C.UPDATE_ENTIRE), 200)
}
const submit = async () => {
let msg = ''
let model = clone(vars.formdata)
const contents = String($(model.contents).text()).trim()
if (!msg && !model.title) { msg = '제목을 입력해 주세요' }
if (!msg && model.title.length < 2) { msg = '제목을 2글자 이상 입력해 주세요' }
if (!msg && !contents) { msg = '내용을 입력해 주세요' }
if (!msg && contents.length < 2) { msg = '내용을 2글자 이상 입력해 주세요' }
if (msg) {
alert(msg)
} else {
let result = false
try {
/** 필요한 파라메터만 복사한다. */
Object.keys(model).map((k) => ['id', 'title', 'contents'].indexOf(k) == -1 && delete model[k])
log.debug('CHECK:', model)
const res = await api.put(`atc01001`, model)
log.debug('RES:', res)
if (res.rescd === C.RESCD_OK) {
result = true
goPage(-1)
}
} catch (e) {
msg = e?.message
log.debug('E:', e)
}
if (!result) {
alert(msg || '글 수정에 실패했어요 잠시후 다시 시도해 주세요')
}
}
}
const print = {
cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
}
return (
<Container>
<section className='title'>
<h2>글수정</h2>
</section>
<hr/>
<section className='flex-form'>
<Form>
<article>
<Block className='form-block'>
<label htmlFor='frm-title'> 제목 </label>
<Block className='form-element'>
<Input
id='frm-title'
model={ vars.formdata }
name='title'
placeholder='제목'
maxLength={ 20 }
className='w-full'
size='small'
/>
</Block>
</Block>
<Block className='form-block'>
<label htmlFor='frm-contents'> 내용 </label>
<Block className='form-element editor'>
<Editor
id='frm-contents'
model={ vars.formdata }
name='contents'
className='w-full'
size='small'
/>
</Block>
</Block>
<hr/>
<Block className='buttons'>
<Button
className='mx-1'
variant='contained'
size='large'
onClick={ submit }
>
완료
</Button>
<Button
className='mx-1'
variant='outlined'
size='large'
onClick={ () => goPage(-1) }
>
취소
</Button>
</Block>
</article>
</Form>
</section>
</Container>
)
})
3-8. /atc/atc01001s03/[articleid].jsx
(게시물 조회 페이지)
게시물 수정 페이지와 같이 path-variable 을 사용한다.
import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import userContext from '@/libs/user-context'
import { Container, Block, Button, Link, Content } from '@/components'
import moment from 'moment'
const { definePage, useSetup, log, putAll, clone, getParameter, goPage } = app
export default definePage(() => {
const self = useSetup({
vars: {
data: {
id: '',
title: '',
contents: '',
userId: '',
userNm: '',
ctime: ''
},
},
async mounted() {
loadData(getParameter('articleid'))
}
})
const userInfo = userContext.getUserInfo()
const { vars, update, ready } = self()
const loadData = async (articleId) => {
const res = await api.get(`atc01001/${articleId}`)
vars.data = clone(res)
log.debug('RES:', res)
update(C.UPDATE_FULL)
}
const print = {
cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
}
return (
<Container>
<section className='title'>
<h2>{ vars.data?.title || '게시물 조회' }</h2>
{ ready() && (userInfo?.userId || '') == vars.data?.userId && (
<Button
variant='contained'
href={`/atc/atc01001s02/${vars.data?.id}`}
>
글수정
</Button>
) }
</section>
<hr/>
<section>
<article>
<Block>
<p> 작성자 : { vars.data.userNm } </p>
<p> 작성일시 : { print.cdate(vars.data.ctime) } </p>
</Block>
<hr/>
<Block className='w-full overflow-x-auto'>
<Content html={ vars.data?.contents || '' } />
</Block>
</article>
</section>
</Container>
)
})
3-9. /atc/atc01001s04/[pagenum].jsx
(게시물 목록 페이지)
import app from '@/libs/app-context'
import api from '@/libs/api'
import userContext from '@/libs/user-context'
import * as C from '@/libs/constants'
import { Container, Block, Button, Pagination, Link } from '@/components'
import moment from 'moment'
const { definePage, useSetup, log, clone, getParameter, goPage } = app
export default definePage(() => {
const self = useSetup({
vars: {
data: {
list: [],
},
/** pagination 을위한 데이터 */
pdata: {
currentPage: 1,
rowCount: 10,
rowStart: 0,
rowTotal: 0,
keyword: '',
searchType: '',
orderType: ''
},
state: 0,
},
async mounted() {
loadData(getParameter('pagenum') || 1)
},
})
const { vars, update, ready } = self()
const userInfo = userContext.getUserInfo()
const print = {
num: (inx) => (vars.pdata?.rowTotal || 0) - (vars.pdata?.rowStart || 0) - inx,
cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
/** 상태에 따라 다른 메시지가 출력된다 */
state(state) {
switch (state) {
case 0: return '잠시만 기다려 주세요.'
case 2: return '검색된 게시물이 없습니다.'
default: return state
}
}
}
const loadData = async (pagenum = C.UNDEFINED) => {
vars.state = 0
update(C.UPDATE_FULL)
try {
if (pagenum !== C.UNDEFINED) {
vars.pdata.rowStart = ((pagenum - 1) || 0) * vars.pdata.rowCount
}
const pdata = clone(vars.pdata)
const res = await api.post(`atc01001`, pdata)
log.debug('RES:', res)
vars.data = res
if (vars.data.list.length != 0) {
vars.state = 1
vars.pdata.currentPage = pagenum
} else {
vars.state = 2
}
for (const k in vars.pdata) { Object.hasOwn(res, k) && (vars.pdata[k] = res[k]) }
} catch (e) {
vars.data.list = []
vars.state = e?.message || '오류가 발생했습니다.'
log.debug('E:', e)
}
update(C.UPDATE_FULL)
}
const pageChanged = async () => {
goPage(`/atc/atc01001s04/${vars.pdata.currentPage}`)
}
return (
<Container>
<section className='title'>
<h2>게시물 목록</h2>
{ ready() && userInfo.userId && (
<Button
variant='contained'
href='/atc/atc01001s01'
>
새글작성
</Button>
) }
</section>
<hr/>
<section>
<article>
<Block>
<table className='w-full articles'>
<colgroup>
<col width={'10%'} />
<col width={'50%'} />
<col width={'20%'} />
<col width={'20%'} />
</colgroup>
<thead>
<tr>
<th> 순번 </th>
<th> 제목 </th>
<th> 작성자 </th>
<th> 작성일 </th>
</tr>
</thead>
<tbody>
<tr><td colSpan={4} className='nopad'><hr/></td></tr>
{ (vars.data?.list?.length || 0) === 0 && (
<tr><td colSpan={4} className='text-center'>{
print.state(vars.state)
}</td></tr>
) }
{ vars.data?.list?.map((itm, inx) => (
<tr key={ inx }>
<td className='text-right'> { print.num(inx) } </td>
<td>
<Link
href={ `/atc/atc01001s03/${itm.id}` }
>
{ itm?.title }
</Link>
</td>
<td> { itm?.userNm } </td>
<td> { print.cdate(itm?.ctime) } </td>
</tr>
)) }
</tbody>
</table>
</Block>
<Block className='flex justify-center'>
<Pagination
model={ vars.pdata }
onChange={ pageChanged }
/>
</Block>
</article>
</section>
</Container>
)
})
3-10. /atc/atc01001s04/index.jsx
(게시물 목록 페이지)
실제 기능은 위 페이지에서 수행하므로 단순히 import-export 처리한다.
import Atc01001s04 from './[pagenum]'
export default Atc01001s04
4. 결과물
뭐.... 일단은 완성했다.
최종 결과물의 전체 파일 목록은 아래와 같다. (필수 파일들)
/public/assets/lottie/hello.json
파일은 lottie 애니메이션을 다운로드 한 것이다
├── public
│ ├── assets
│ │ └── lottie
│ │ └── hello.json
│ └── favicon.ico
├── env
│ ├── env-local.yml
│ └── replace-loader.js
├── src
│ ├── components
│ │ ├── block.tsx
│ │ ├── button.tsx
│ │ ├── checkbox.tsx
│ │ ├── container.tsx
│ │ ├── content.tsx
│ │ ├── editor.tsx
│ │ ├── footer.tsx
│ │ ├── form.tsx
│ │ ├── header.tsx
│ │ ├── index.ts
│ │ ├── input.tsx
│ │ ├── layout.tsx
│ │ ├── link.tsx
│ │ ├── lottie.tsx
│ │ ├── pagination.tsx
│ │ └── select.tsx
│ ├── libs
│ │ ├── api.ts
│ │ ├── app-context.ts
│ │ ├── constants.ts
│ │ ├── crypto.ts
│ │ ├── log.ts
│ │ ├── user-context.ts
│ │ └── values.ts
│ └── pages
│ ├── atc
│ │ ├── atc01001s01.jsx
│ │ ├── atc01001s02
│ │ │ └── [articleid].jsx
│ │ ├── atc01001s03
│ │ │ └── [articleid].jsx
│ │ └── atc01001s04
│ │ ├── [pagenum].jsx
│ │ └── index.jsx
│ ├── lgn
│ │ └── lgn01001s01.jsx
│ ├── mai
│ │ └── mai01001s01.jsx
│ ├── smp
│ │ ├── smp01001s01.jsx
│ │ └── smp01001s02.jsx
│ ├── usr
│ │ ├── usr01001s01.jsx
│ │ ├── usr01001s02.jsx
│ │ └── usr01001s03.jsx
│ ├── _app.jsx
│ ├── _document.jsx
│ ├── global.scss
│ └── index.jsx
├── .eslintrc.json
├── next.config.mjs
├── package.json
├── package-lock.json
└── tsconfig.json
실제 실행되는 모습이다.
5. 후~~~우기
처음에는 정말 혼자서 할수있는 간단하고 쉬운 코딩 교육용 게시판 프로젝트 하나 작성하려고 했는데
중간중간 살붙이기도 많이 했고 하는김에 제대로 하자 라는 욕심도 붙어서
초보자가 이해하기에는 살짝 어렵고, 경력자에게는 불필요한 좀 어중간한 프로젝트가 되고 말았다.
뭐 개인적으로는 이거 통째로 가져다가 실제 프로젝트에 써먹긴 했으니 손해본거 같지는 않지만..
실제 스터디 할 때 까지만 해도 대충 만든거라 좀 불안하게 작동했는데
블로그로 정리하면서 꽤 많이 안정화 시킨거 같다 (내가 보기에는......).
일단 (내가 사용할 목적으로) 본 프로젝트를 바탕으로 많은 기능들을 추가시켜 볼 생각이다.
(진행 사항은 gitlab
또는 github
를 통해 지속적으로 배포할 계획이다)
급하게 달려오다 보니 설명되지 않았던 핵심 기술들에 대한 상세 설명들은
하나씩 기술팁 등의 짧은글로 남기도록 하겠다.
끝!!
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403