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:
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}>'{randomSlug}'</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;