dh_demo

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

commit 9595ca826f13e3f453039b3513765db6b9af905e
parent f273a8900dd195d0a550468bb8efc3f4279c0420
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Fri, 20 Jan 2023 12:14:59 +0900

feat: '새 위키 만들기' 페이지 추가

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

Diffstat:
Mcomponents/contexts/TokenContext.tsx | 8++------
Mlib/apierror.ts | 21+++++++++++++++++----
Alib/error_codes.ts | 11+++++++++++
Alib/models/wiki_info.ts | 30++++++++++++++++++++++++++++++
Alib/random_name.ts | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/security/token.ts | 35++++++++++++++++++++++++++++++-----
Mpages/api/users/[id].ts | 6++++--
Apages/api/wiki/index.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpages/users/signup/index.tsx | 3++-
Apages/wiki/new.tsx | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstyles/globals.css | 2+-
Mstyles/globals.css.map | 4++--
Mstyles/globals.scss | 12++++++++++++
13 files changed, 456 insertions(+), 21 deletions(-)

diff --git a/components/contexts/TokenContext.tsx b/components/contexts/TokenContext.tsx @@ -1,16 +1,12 @@ import { getAccessTokenCookieName } from '@/lib/env' -import { decode, JwtPayload } from 'jsonwebtoken' +import { AccessTokenPayload } from '@/lib/security/token' +import { decode } from 'jsonwebtoken' import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react' const regexCookie = /^([^=]*)=([^;]*)(?:;\s*|$)/ const TOKEN_REFRESH_BEFORE = 60 * 1000 // 만료 1분 전 갱신 -export interface AccessTokenPayload extends JwtPayload { - tid?: string - uid?: number -} - const TokenContext = createContext<AccessTokenPayload | null>(null) const TokenFlushContext = createContext<() => void>(() => { }) diff --git a/lib/apierror.ts b/lib/apierror.ts @@ -1,3 +1,11 @@ +import { + ERR_CODE_INTERNAL, + ERR_CODE_INVALID_REQUEST, + ERR_CODE_METHOD_NOT_ALLOWED, + ERR_CODE_NOT_FOUND, + ERR_CODE_UNAUTHORIZED, +} from '@/lib/error_codes' + export interface ApiError { code: string message?: string @@ -9,21 +17,26 @@ export function isApiError (obj: any): obj is ApiError { } export const ERR_METHOD_NOT_ALLOWED: ApiError = { - code: 'method_not_allowed', + code: ERR_CODE_METHOD_NOT_ALLOWED, message: 'Method Not Allowed', } export const ERR_INVALID_REQUEST: ApiError = { - code: 'invalid_request', + code: ERR_CODE_INVALID_REQUEST, message: 'Invalid request', } export const ERR_UNAUTHORIZED: ApiError = { - code: 'unauthorized', + code: ERR_CODE_UNAUTHORIZED, message: 'Unauthorized', } export const ERR_NOT_FOUND: ApiError = { - code: 'not_found', + code: ERR_CODE_NOT_FOUND, message: 'Not Found', } + +export const ERR_INTERNAL: ApiError = { + code: ERR_CODE_INTERNAL, + message: 'Internal Error', +} diff --git a/lib/error_codes.ts b/lib/error_codes.ts @@ -0,0 +1,11 @@ +/* eslint sort-vars: "error" */ + +export const + ERR_CODE_DUPLICATED_SLUG = 'duplicated_slug', + ERR_CODE_INTERNAL = 'internal_error', + ERR_CODE_INVALID_REQUEST = 'invalid_request', + ERR_CODE_INVALID_SLUG = 'invalid_slug', + ERR_CODE_INVALID_TITLE = 'invalid_title', + ERR_CODE_METHOD_NOT_ALLOWED = 'method_not_allowed', + ERR_CODE_NOT_FOUND = 'not_found', + ERR_CODE_UNAUTHORIZED = 'unauthorized' diff --git a/lib/models/wiki_info.ts b/lib/models/wiki_info.ts @@ -0,0 +1,30 @@ +import db from '@/lib/db' +import { OkPacket } from 'mysql2' + +export interface WikiInfo { + id: number + ownerId: number + slug: string + title: string + description?: string + createdAt: Date + updatedAt?: Date +} + +const SQL_CREATE_WIKI = ` + insert into wikis (owner_id, slug, title, description) + values (?, ?, ?, ?) +` + +export async function createWiki ( + ownerId: number, + slug: string, + title: string, + description: string | null, +) { + await db.query<OkPacket>({ + sql: SQL_CREATE_WIKI, + }, [ownerId, slug, title, description]) + + return true +} diff --git a/lib/random_name.ts b/lib/random_name.ts @@ -0,0 +1,111 @@ +const adjectives = [ + 'acoustic', + 'unhappy', + 'three', + 'nappy', + 'unequaled', + 'mighty', + 'adventurous', + 'loving', + 'terrible', + 'stupid', + 'workable', + 'tedious', + 'annoyed', + 'scary', + 'nimble', + 'nondescript', + 'plain', + 'square', + 'unlikely', + 'basic', + 'wry', + 'busy', + 'military', + 'greedy', + 'condemned', + 'aspiring', + 'groovy', + 'hypnotic', + 'colorful', + 'bitter', + 'normal', + 'dizzy', + 'low', + 'rapid', + 'uttermost', + 'faded', + 'shut', + 'mixed', + 'desperate', + 'living', + 'suitable', + 'crooked', + 'round', + 'husky', + 'lazy', + 'tidy', + 'aromatic', + 'sufficient', + 'illustrious', + 'garrulous', +] + +const nouns = [ + 'mood', + 'writing', + 'potato', + 'player', + 'safety', + 'context', + 'tennis', + 'menu', + 'guest', + 'thanks', + 'flight', + 'coffee', + 'bonus', + 'oven', + 'skill', + 'food', + 'version', + 'woman', + 'memory', + 'salad', + 'cell', + 'phone', + 'soup', + 'poem', + 'library', + 'surgery', + 'winner', + 'singer', + 'meaning', + 'office', + 'science', + 'dad', + 'story', + 'finding', + 'affair', + 'way', + 'dinner', + 'moment', + 'dealer', + 'physics', + 'insect', + 'disk', + 'speaker', + 'pie', + 'session', + 'bedroom', + 'exam', + 'month', + 'church', + 'article', +] + +export function generateRandomName () { + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] + const noun = nouns[Math.floor(Math.random() * nouns.length)] + return `${adjective}-${noun}` +} diff --git a/lib/security/token.ts b/lib/security/token.ts @@ -1,10 +1,17 @@ -import { sign, verify, decode } from 'jsonwebtoken' +import { getAccessTokenCookieName } from '@/lib/env' +import { sign, verify, JwtPayload } from 'jsonwebtoken' import { nanoid } from 'nanoid' +import { NextApiRequest } from 'next' const TOKEN_ALGORITHM = 'HS256' export const TOKEN_EXPIRES_IN = 5 * 60 // 5분 export const REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60 // 30일 +export interface AccessTokenPayload extends JwtPayload { + tid?: string + uid?: number +} + export function getTokenSecret () { return process.env.TOKEN_SECRET ?? 'dangerously_insecure_s3cr3t' } @@ -65,7 +72,22 @@ export async function verifyToken (token: string) { }) } -export async function getUserIdFromAccessToken (accessToken?: string) { +export async function authenticationFromCookies (cookies: NextApiRequest['cookies']) { + const accessToken = cookies[getAccessTokenCookieName()] + if (accessToken == null) { + return null + } + + try { + const decoded = await verifyToken(accessToken) + return decoded as AccessTokenPayload + } catch (e) { + console.log('token: failed to verify access token', e) + return null + } +} + +export async function getUserIdFromAccessToken (accessToken?: string): Promise<number | null> { if (accessToken == null) { return null } @@ -79,7 +101,10 @@ export async function getUserIdFromAccessToken (accessToken?: string) { } }) }) - return typeof decoded === 'object' && decoded !== null && 'uid' in decoded - ? decoded.uid as string - : null + + if (typeof decoded !== 'object' || decoded == null || !('uid' in decoded)) { + return null + } + + return parseInt(decoded.uid as string, 10) } diff --git a/pages/api/users/[id].ts b/pages/api/users/[id].ts @@ -13,7 +13,7 @@ export default async function handler ( return } - let id = req.query.id + let id: number | typeof req.query.id = req.query.id if (typeof id !== 'string') { res.status(400).json(ERR_INVALID_REQUEST) return @@ -27,10 +27,12 @@ export default async function handler ( return } id = currentUserId + } else { + id = parseInt(id, 10) } // 사용자 정보를 받아옴 - const profile = await getUserProfile(parseInt(id, 10)) + const profile = await getUserProfile(id) if (profile == null) { res.status(404).json(ERR_NOT_FOUND) return diff --git a/pages/api/wiki/index.ts b/pages/api/wiki/index.ts @@ -0,0 +1,72 @@ +import { ERR_INTERNAL, ERR_METHOD_NOT_ALLOWED, ERR_UNAUTHORIZED } from '@/lib/apierror' +import { ERR_CODE_DUPLICATED_SLUG, ERR_CODE_INVALID_SLUG, ERR_CODE_INVALID_TITLE } from '@/lib/error_codes' +import { createWiki } from '@/lib/models/wiki_info' +import { authenticationFromCookies, getUserIdFromAccessToken } from '@/lib/security/token' +import { NextApiRequest, NextApiResponse } from 'next' + +const regexSlug = /^[a-z0-9-]+$/ + +const SLUG_MAX_LENGTH = 48 +const TITLE_MAX_LENGTH = 255 + +export interface CreateWikiRequest { + slug: string + title: string + description?: string +} + +export default async function handler (req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.status(405).json(ERR_METHOD_NOT_ALLOWED) + return + } + + // 토큰 검증 + const tokenPayload = await authenticationFromCookies(req.cookies) + const userId = tokenPayload?.uid + if (userId == null) { + res.status(401).json(ERR_UNAUTHORIZED) + return + } + + const { slug, title, description } = req.body as CreateWikiRequest + + // 파라메터 검증 + if (slug == null || slug === '') { + res.status(400).json({ code: ERR_CODE_INVALID_SLUG, message: 'slug is required' }) + return + } + if (slug.length > SLUG_MAX_LENGTH) { + res.status(400).json({ code: ERR_CODE_INVALID_SLUG, message: 'slug is too long' }) + return + } + if (!regexSlug.test(slug)) { + res.status(400).json({ code: ERR_CODE_INVALID_SLUG, message: 'slug is invalid' }) + return + } + + if (title == null || title === '') { + res.status(400).json({ code: ERR_CODE_INVALID_TITLE, message: 'title is required' }) + return + } + if (title.length > TITLE_MAX_LENGTH) { + res.status(400).json({ code: ERR_CODE_INVALID_TITLE, message: 'title is too long' }) + return + } + + // 새 위키를 생성함 + try { + await createWiki(userId, slug, title, description ?? null) + } catch (e: any) { + if (e.code === 'ER_DUP_ENTRY') { + res.status(400).json({ code: ERR_CODE_DUPLICATED_SLUG, message: 'slug is already used' }) + return + } + + console.error('createWiki: database error:', e) + res.status(500).json(ERR_INTERNAL) + return + } + + return res.status(201).json({ status: 'created' }) +} diff --git a/pages/users/signup/index.tsx b/pages/users/signup/index.tsx @@ -63,8 +63,9 @@ function SignUpForm () { })().catch((err) => { console.error(err) setErrorMessage('알 수 없는 오류가 발생했습니다.') + setLoading(false) }) - }, [email]) + }, [router, email]) return ( <Form onSubmit={handleSubmit}> diff --git a/pages/wiki/new.tsx b/pages/wiki/new.tsx @@ -0,0 +1,162 @@ +import { SubmitButton } from '@/components/elements/Button' +import Title from '@/components/elements/Title' +import Field from '@/components/form/Field' +import Fields from '@/components/form/Fields' +import Form from '@/components/form/Form' +import Container from '@/components/layout/Container' +import Hero from '@/components/layout/Hero' +import Section from '@/components/layout/Section' +import { isApiError } from '@/lib/apierror' +import { ERR_CODE_DUPLICATED_SLUG, ERR_CODE_INVALID_SLUG, ERR_CODE_INVALID_TITLE } from '@/lib/error_codes' +import { generateRandomName } from '@/lib/random_name' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useState } from 'react' + +export default function WikiNewPage () { + return ( + <> + <Hero> + <Title kind="headline">새 위키 만들기</Title> + </Hero> + + <Section> + <Container> + <NewWikiForm/> + </Container> + </Section> + </> + ) +} + +function NewWikiForm () { + const router = useRouter() + + const [title, setTitle] = useState('') + const [slug, setSlug] = useState('') + const [description, setDescription] = useState('') + + const [titleError, setTitleError] = useState<string | null>(null) + const [slugError, setSlugError] = useState<string | null>(null) + const [descriptionError, setDescriptionError] = useState<string | null>(null) + + const [isLoading, setLoading] = useState(false) + + const handleSubmit = useCallback(() => { + const handleError = (status: number, err: any) => { + setLoading(false) + + if (!isApiError(err)) { + setTitleError('알 수 없는 오류가 발생했습니다.') + return + } + + switch (status) { + case 400: + switch (err.code) { + case ERR_CODE_INVALID_SLUG: + setSlugError('경로가 유효하지 않습니다.') + return + + case ERR_CODE_INVALID_TITLE: + setTitleError('제목이 유효하지 않습니다.') + return + + case ERR_CODE_DUPLICATED_SLUG: + setSlugError('이미 사용중인 경로입니다.') + return + } + break + + case 401: + setTitleError('로그인이 필요합니다.') + return + } + + setTitleError('알 수 없는 오류가 발생했습니다.') + } + + setLoading(true) + setTitleError(null) + setSlugError(null) + setDescriptionError(null) + + !(async () => { + const res = await fetch('/api/wiki', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title, slug, description }), + }) + + if (!res.ok) { + const err = await res.json() + handleError(res.status, err) + return + } + + await router.push(`/wiki/${slug}`) + })().catch(err => { + console.error(err) + handleError(err.status, err) + }) + }, [router, title, slug, description]) + + // Random slug generation + + const [randomSlug, setRandomSlug] = useState('') + + const applyRandomSlug = useCallback(() => { + setSlug(randomSlug) + setRandomSlug(generateRandomName()) + }, [randomSlug]) + + useEffect(() => { + setRandomSlug(generateRandomName()) + }, []) + + return ( + <Form onSubmit={handleSubmit}> + <Fields> + <Field + type="text" + placeholder="이름" + value={title} + onValueChange={setTitle} + message={titleError ?? undefined} + color={titleError != null ? 'error' : undefined} + disabled={isLoading} + /> + + <Field + type="text" + placeholder="경로" + value={slug} + onValueChange={setSlug} + message={slugError ?? undefined} + color={slugError != null ? 'error' : undefined} + disabled={isLoading} + /> + + <p>URL의 일부로써 사용되며, 전체 위키에 대해 고유해야 합니다.<br/> + 예를 들어, <a onClick={applyRandomSlug}>&apos;{randomSlug}&apos;</a> 같은 걸 의미합니다.</p> + + <Field + type="text" + placeholder="짧은 설명" + value={description} + onValueChange={setDescription} + message={descriptionError ?? undefined} + color={descriptionError != null ? 'error' : undefined} + disabled={isLoading} + /> + + <SubmitButton + color="primary" + value="만들기" + disabled={isLoading} + /> + </Fields> + </Form> + ) +} diff --git a/styles/globals.css b/styles/globals.css @@ -1 +1 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:rgba(0,0,0,0)}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}*{box-sizing:border-box}body{font-family:-apple-system,sans-serif;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){body{background-color:#191c1b;color:#e1e3e0}}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:1rem}/*# sourceMappingURL=globals.css.map */ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:rgba(0,0,0,0)}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}*{box-sizing:border-box}body{font-family:-apple-system,sans-serif;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){body{background-color:#191c1b;color:#e1e3e0}}a{text-decoration:none;cursor:pointer;color:#7b4998}@media(prefers-color-scheme: dark){a{color:#e6b4ff}}a:hover{text-decoration:underline}h1,h2,h3,h4,h5,h6,p{margin-top:0;margin-bottom:1rem}/*# sourceMappingURL=globals.css.map */ diff --git a/styles/globals.css.map b/styles/globals.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["vendors/_normalize.scss","globals.scss","core/_colors.scss"],"names":[],"mappings":"AAAA,4EAUA,KACI,iBACA,8BAUJ,KACI,SAOJ,KACI,cAQJ,GACI,cACA,eAWJ,GACI,uBACA,SACA,iBAQJ,IACI,gCACA,cAUJ,EACI,+BAQJ,YACI,mBACA,0BACA,iCAOJ,SAEI,mBAQJ,cAGI,gCACA,cAOJ,MACI,cAQJ,QAEI,cACA,cACA,kBACA,wBAGJ,IACI,eAGJ,IACI,WAUJ,IACI,kBAWJ,sCAKI,oBACA,eACA,iBACA,SAQJ,aAEI,iBAQJ,cAEI,oBAOJ,gDAII,0BAOJ,wHAII,kBACA,UAOJ,4GAII,8BAOJ,SACI,2BAUJ,OACI,sBACA,cACA,cACA,eACA,UACA,mBAOJ,SACI,wBAOJ,SACI,cAQJ,6BAEI,sBACA,UAOJ,kFAEI,YAQJ,cACI,6BACA,oBAOJ,yCACI,wBAQJ,6BACI,0BACA,aAUJ,QACI,cAOJ,QACI,kBAUJ,SACI,aAOJ,SACI,aCxVJ,EACE,sBAGF,KACE,qCAEE,yBACA,cCqHF,mCDzHF,KAGI,yBACA,eAIJ,oBAEE,aACA","file":"globals.css"} -\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["vendors/_normalize.scss","globals.scss","core/_colors.scss"],"names":[],"mappings":"AAAA,4EAUA,KACI,iBACA,8BAUJ,KACI,SAOJ,KACI,cAQJ,GACI,cACA,eAWJ,GACI,uBACA,SACA,iBAQJ,IACI,gCACA,cAUJ,EACI,+BAQJ,YACI,mBACA,0BACA,iCAOJ,SAEI,mBAQJ,cAGI,gCACA,cAOJ,MACI,cAQJ,QAEI,cACA,cACA,kBACA,wBAGJ,IACI,eAGJ,IACI,WAUJ,IACI,kBAWJ,sCAKI,oBACA,eACA,iBACA,SAQJ,aAEI,iBAQJ,cAEI,oBAOJ,gDAII,0BAOJ,wHAII,kBACA,UAOJ,4GAII,8BAOJ,SACI,2BAUJ,OACI,sBACA,cACA,cACA,eACA,UACA,mBAOJ,SACI,wBAOJ,SACI,cAQJ,6BAEI,sBACA,UAOJ,kFAEI,YAQJ,cACI,6BACA,oBAOJ,yCACI,wBAQJ,6BACI,0BACA,aAUJ,QACI,cAOJ,QACI,kBAUJ,SACI,aAOJ,SACI,aCxVJ,EACE,sBAGF,KACE,qCAEE,yBACA,cCqHF,mCDzHF,KAGI,yBACA,eAIJ,EACE,qBACA,eAEE,cC6GF,mCDjHF,EAII,eAGF,QACE,0BAIJ,oBAEE,aACA","file":"globals.css"} +\ No newline at end of file diff --git a/styles/globals.scss b/styles/globals.scss @@ -13,6 +13,18 @@ body { } } +a { + text-decoration: none; + cursor: pointer; + @include colors.apply-themes using ($theme) { + color: colors.get($theme, 'secondary'); + } + + &:hover { + text-decoration: underline; + } +} + h1, h2, h3, h4, h5, h6, p { margin-top: 0;