vue.js로 블로그 UI 만들기 (3)

양성연·2023년 7월 24일
1

참고

게시글을 써내려 가면서, 여러 컴포넌트의 부분부분을 수정하고 있습니다. 또한 블로그 글에 올리는 코드에 분량 상 생략한 부분이 생길 수 있습니다. 제 깃헙 레포지토리에 작업 중인 최신 버전을 지속적으로 업로드 하고 있으니 참고하시면서 글을 읽어주시면 감사하겠습니다.

https://github.com/Larshavin/vue-blog

게시글 디테일 뷰

이제는 저 역시도 모르는 것을 찾아가면서 진행해야 할 때가 왔습니다.

Javascript 라이브러리 중에 마크다운 기반의 파일을 html로 변환 후 스타일을 예쁘게 올려주는 괜찮은 것이 있을까 생각해봐야 합니다. (Vue3 예제는 Marked 사용)

대강 검색해보았을 때는, Markdown parser + Code highlight 의 느낌인 것 같습니다. marked에서는 marked-highlight 사용을 권장하고 있었습니다.

우선 구관이 명물이라고, Marked 라는 라이브러리를 다운 받아서 markdown 파일을 html로 변환해 봅시다.

$ npm install -g marked
$ npm install marked-highlight
$ npm install highlight.js
<script setup>
import { ref, computed } from 'vue'
import { marked } from 'marked';

const markdown = ref("# hello");
const markdownToHtml = computed(() => {
  return marked(markdown.value);
});
</script>

<template>
  <div v-html="markdownToHtml"></div>
</template>

예시로 만들어본 파일 입니다. "# hello"를 받아서 markdownToHtml으로 변환한 후 <div v-html="markdownToHtml"></div>로 표현하면 hello가 잘 표시됩니다.

그럼 이제, 임의의 markdown 파일을 프로젝트 폴더 안에 집어 넣어놓고 javascript를 이용하여 그 md 파일을 변수로 호출해 봅시다. 저도 조사하면서 처음 알게 된 사실인데, “import is only for javascript code” md 파일을 호출 하기 위해서는 import가 아닌, axios, fetch 등의 도구를 이용하여 파일을 불러와야 하는 것 같습니다.

Axios는 REST API 통신에 자주 쓰는 라이브러리 입니다. 이것도 설치해 봅시다.

$ npm install axios

그리고 /src/assets/posts 라는 폴더를 생성하고, 그 안에 /vue.js/vue1.md 를 생성해 줍시다. 저희는 code highlight 기능을 테스트 볼 것이기 때문에 vue1.md 파일에 들어갈 내용으로 간단한 코드(혹은 터미널 명령어)와 설명을 작성해 주면 좋을 것 같습니다. (저는 제 블로그 게시물을 아예 통채로 복붙 하였습니다.)

이제 PostDetailView.vue 파일을 열고, 다음과 같이 코드를 수정 해봅시다.

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios';
import { marked } from 'marked';
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

const markdown = ref('');
const markdownHtml = ref('');
const markdownToHtml = (() => {

    marked.use(markedHighlight({
        langPrefix: 'hljs language-',
        highlight(code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    }));

    const output = marked(markdown.value, {
        async: true,
        pedantic: false,
        gfm: true,
        mangle: false,
        headerIds: false,
    });
    return output
});

onMounted(async () => {
    const path = '../src/assets/posts/vue.js/vue1.md'
    await getMarkdown(path);
    markdownHtml.value = await markdownToHtml();
    console.log(markdownHtml.value)
});

const getMarkdown = async (path) => {
    try {
        const response = await axios.get(path);
        markdown.value = response.data;
    } catch (error) {
        console.error(error);
    }
};
</script>

<template>
    <div>
        page detail
    </div>
    <div v-html="markdownHtml"></div>
</template>

저도 기능을 테스트 하다보면서 코드를 작성한 터라, 조금 뒤죽박죽의 과정으로 코드를 작성했습니다. 그래도 살펴보자면, 가장 먼저 위에서 테스트 해본 라이브러리 들이 똑같이 import 되어 있고, highlight.js 에서 제공하는 테마 중 github-dark 테마를 불러왔습니다.

그리고 marked-highlight 문서에서 시키는 대로 얌전하게 아래 부분을 적어 주었습니다. 보통의 라이브러리들의 장점은 바로 이것 입니다. 추상화 된 레이어의 기능을 사용하면, 그 안에서 어떤 치열한 작업이 이뤄지는 지 살펴보지 않아도 원하는 결과를 얻어낼 수 있습니다.

    marked.use(markedHighlight({
        langPrefix: 'hljs language-',
        highlight(code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    }));

다만, 대충은 짐작이 되는데요. marked가 md 파일의 내용을 html로 변환하는 과정에서 <code> 로 변환 된 요소에 언어에 따라 테마에 맞는 클래스 등을 추가하는 작업을 하는 것으로 추측됩니다.

그리고 marked 문서에 나온 세부 설정에 따라 다음과 같은 설정을 해주었습니다. 저 역시도 각각의 설정을 아직 완벽하게 이해하지는 못했습니다.

    const output = marked(markdown.value, {
        async: true,
        pedantic: false,
        gfm: true,
        mangle: false,
        headerIds: false,
    });

이 중, async: true 설정을 해주신다면, 매 결과물을 얻는 과정 속에서 asyncawait를 적절하게 사용 해주시는 것이 좋겠습니다. asyncawait 는 자바스크립트에서 비동기 프로세스, Promise 객체와 깊게 연관된 문법입니다. 이곳을 참고해 보시면 좋을 듯 합니다.

const getMarkdown = async (path) => {
    try {
        const response = await axios.get(path);
        markdown.value = response.data;
    } catch (error) {
        console.error(error);
    }
};

따라서 async로 선언한 onMounted 에서 위 getMarkdown 함수로 markdown의 컨텐츠를 가져오고, 아래 markdownToHtml를 통해 변환된 html을 output 변수로 할당 받았습니다.

const markdownToHtml = (() => {

    marked.use(markedHighlight({
        langPrefix: 'hljs language-',
        highlight(code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    }));

    const output = marked(markdown.value, {
        async: true,
        pedantic: false,
        gfm: true,
        mangle: false,
        headerIds: false,
    });
    return output
});

그리고 이것을 아래처럼 화면에 표현해 주었습니다.

<div v-html="markdownHtml"></div>

결과는 어떻게 나왔을까요? 아래와 같은 느낌으로 표출 되었습니다.

저는 이 html에 글 간격이나, 글자 크기들을 조절할 수 있을지 궁금해졌습니다. 가장 첫 접근 방식으로 div 태크에 class들을 부여 해보겠습니다.

<template>
    <div>
        page detail
    </div>
    <div class="line-height-4 text-xl custom" v-html="markdownHtml"></div>
</template>

<style scoped>
.custom ::v-deep a {
    color: #42b983;
    text-decoration: none;
    border-bottom: 1px solid #42b983;
    transition: all 0.3s ease-in-out;
}

.custom ::v-deep code:not([class]) {
    padding: 2px 4px;
    font-size: 90%;
    color: #c7254e;
    background-color: #f9f2f4;
    border-radius: 4px;
}
</style >

우와, 가독성이 훨씬 좋아졌습니다. 이 블로그를 참고하면, marked 자체로 극한의 커스텀을 이끌어 낼 수 있는 듯 하지만, 저는 아직 그 정도의 영역까진 바라보고 있진 않습니다. (제 깃허브 레포지토리에 있는 코드에는 약간의 커스텀을 추가해보았습니다. 코드 상단에 어떤 언어인지 표시하고, 코드를 한 번에 복사할 수 있는 기능을 넣어보았습니다.)

이제 고려해야 할 것은, hugo blog 상에서 다음과 같은 영역을 어떻게 만들어 낼 것인가? 이겠습니다.

머리가 조금 복잡해집니다. UI적으로 트리 구조의 Table of Contents를 만들어 내는 것은 PrimeVue 컴포넌트를 사용하거나, 커스텀으로 컴포넌트를 만들어 낼 수는 있을 것 같습니다. 하지만 문제는 md 파일에서 데이터를 어떻게 뽑아내는가 입니다.

컨텐츠 테이블을 구성한 예시들은 종종 많이 발견할 수 있습니다. Tistory, Velog 등등 에서 기본으로 제공해주는 기능입니다.

---
title: "Vue.js로 블로그 UI 만들기 (1)"
date: 2023-07-17T21:29:25+09:00
ShowToc: true
TocOpen: false
Tags: ['vue.js', 'blog']
---

또한 hugo에서는 markdown 최상단에 이런 요소를 집어 넣습니다. 저 역시도 이 요소들을 추출해 변수화 시키고 싶습니다. 그런데 어떻게 해야할 까요? marked 라이브러리에서 부분 부분 parsing 할 수 있는 걸까요?

한 번 검색을 통해 이런 기능이 어떻게 구현되는 지 알아보겠습니다.

저 혼자서만 이런 고민을 한 게 아닌 듯 합니다. 테스트로 다음과 같은 파일을 만들어 봅시다. (chatGPT의 답변입니다)

<template>
    <div>
        <div v-html="renderedContent"></div>
        <br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
        <br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
        <div v-html="tableOfContents"></div>
    </div>
</template>
  
<script setup>
import { marked } from 'marked';

// The Markdown content with headings
const markdown = `
  # Heading 1
  ## Subheading 1.1
  ## Subheading 1.2
  ### Sub-subheading 1.2.1
  # Heading 2
  ## Subheading 2.1
  \`<template>\` 쪽을 살펴봅시다. 
`

// Function to generate the Table of Contents from the Markdown content
const generateTableOfContents = (markdownContent) => {

    const tocItems = [];

    const tokens = marked.lexer(markdownContent);
    console.log(tokens);
    marked.walkTokens(tokens, (token) => {
        if (token.type === 'heading') {
            tocItems.push({
                text: token.text,
                level: token.depth,
            });
        }
    });

    const tableOfContentsHTML = tocItems
        .map((item) => `<li><a href="#${item.text.toLowerCase().replace(/[^\w]+/g, '-')}">${item.text}</a></li>`)
        .join('');

    return tableOfContentsHTML;
};

// Call the function with the Markdown content
const tableOfContents = generateTableOfContents(markdown);

// Render the Markdown content
const renderer = new marked.Renderer();
const renderedContent = marked(markdown, { renderer });
</script>

<style>
/* Your component's styles here */
</style>

이를 실행 시켜보면, 다음과 같이 리스트가 만들어집니다. 마크 다운을 파싱하며 링크를 만들 수 있다는 것을 확인 하였습니다.

코드를 분석해보면, Marked의 lexer 기능을 사용하고 각 부분들의 타입을 추출해 html로 뽑아내고 있습니다. 여기서 추출되는 Token이라는 요소가 편리하게 ToC를 다룰 수 있게 만들어 줍니다.

중간에 <br />이 한 가득한 부분은 제가 임의로 넣어둔 것입니다. 정말 클릭시에 해당하는 Header로 데이터가 날아가는 지 확인용 입니다. 지금 상태로는 가장 큰 해더로만 링크를 타고 가고 있습니다. 아하 그 이유는 여기 때문이네요.

v-html을 사용하지 않은 renderedContent와 tableOfContentsHTML을 직접 비교해보면 다음과 같습니다.

<h1 id="heading-1">Heading 1</h1>
        <h2 id="subheading-11">Subheading 1.1</h2>
        <h2 id="subheading-12">Subheading 1.2</h2>
        <h3 id="sub-subheading-121">Sub-subheading 1.2.1</h3>
        <h1 id="heading-2">Heading 2</h1>
        <h2 id="subheading-21">Subheading 2.1</h2>
        <p> <code>&lt;template&gt;</code> 쪽을 살펴봅시다. </p>

<li><a href="#heading-1">Heading 1</a></li>
        <li><a href="#subheading-1-1">Subheading 1.1</a></li>
        <li><a href="#subheading-1-2">Subheading 1.2</a></li>
        <li><a href="#sub-subheading-1-2-1">Sub-subheading 1.2.1</a></li>
        <li><a href="#heading-2">Heading 2</a></li>
        <li><a href="#subheading-2-1">Subheading 2.1</a></li>

id의 이름과 href에서 사용된 이름이 다르기 때문에 서브 헤딩에서 링크가 작동하지 않고 있었네요. 그렇다면 파싱을 할 때 해더에 id를 부여하고 있는데, 이 단계에서 네이밍 규칙을 아래와 동일하게 해야하지 않을까 싶습니다. 문서를 찾아보니, Renderer가 토큰을 정의하는 규칙이라고 합니다.
그리고 walkTokens는 모든 토큰을 획득하는 요청이라 합니다. Lexer 는 마크 다운 string에 tokenizer functions를 적용시키는 기능인 듯 합니다.

Marked에 다양한 기능이 있다는 것을 확인 하니 자신감도 차오르고 슬슬 감이 오는 것 같습니다. 또한 문서를 읽기 잘했다는 생각이 듭니다. 앞서 markdown 최상단 요소에서 정보를 어떻게 분리해야 하나 생각했는데, front-matter라는 라이브러리와 hooks 기능을 사용하면 됩니다.

import { ref, onMounted } from 'vue';
import { marked } from 'marked';
import fm from 'front-matter';
...
onMounted(() => {
    marked.use({ hooks });
		...
});
const options = ref()
// Override function
const hooks = {
    preprocess(markdown) {
        const data = fm(markdown);
        options.value = data.attributes;
        return data.body;
    }
};

// Run marked
console.log(marked.parse(`
---
headerIds: false
---

## test
`.trim()));
...

이 정도 읽었을 때, 이 참고자료가 온전히 이해 될 수 있었습니다. 결국 종합하면, 아래와 같은 코드 구조를 가질 때, ‘UI를 구축하기 위한 필수 데이터들이 구비가 되었다.’ 라고 볼 수 있겠습니다.

<template>
    <div>
        {{ options }}
    </div>
    <div>
        {{ tocItems }}
    </div>
    <div class="line-height-4 text-xl custom" v-html="markdownHtml"></div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios';
import { marked } from 'marked';
import { markedHighlight } from "marked-highlight";
import fm from 'front-matter';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

const markdown = ref('');
const markdownHtml = ref('');
const markdownToHtml = (() => {
    marked.use({ hooks });
    marked.use({ renderer });
    marked.use(markedHighlight({
        langPrefix: 'hljs language-',
        highlight(code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    }));
    const output = marked(markdown.value, {
        async: true,
        pedantic: false,
        headerIds: false,
        gfm: true,
        mangle: false,
    });
    return output
});

onMounted(async () => {
    const path = '../src/assets/posts/vue.js/vue1.md'
    await getMarkdownFile(path);
    markdownHtml.value = await markdownToHtml();
});

const getMarkdownFile = async (path) => {
    try {
        const response = await axios.get(path);
        markdown.value = response.data;
    } catch (error) {
        console.error(error);
    }
};

const tocItems = ref([]);
const renderer = (() => {
    const renderer = new marked.Renderer();
    renderer.heading = function (text, level, raw) {
        console.log(text, raw)
        const anchor = raw.replace(/[^\wㄱ-ㅎㅏ-ㅣ가-힣]+/g, '-');
        console.log(anchor);
        tocItems.value.push({
            anchor: anchor,
            level: level,
            text: text
        });
        return '<h'
            + level
            + ' id="'
            + anchor
            + '">'
            + text
            + '</h'
            + level
            + '>'
    };
    return renderer;
})();
const options = ref()
const hooks = {
    preprocess(markdown) {
        const data = fm(markdown);
        options.value = data.attributes;
        return data.body;
    }
};
</script>

<style scoped>
.custom ::v-deep a {
    color: #42b983;
    text-decoration: none;
    border-bottom: 1px solid #42b983;
    transition: all 0.3s ease-in-out;
}

.custom ::v-deep code:not([class]) {
    padding: 2px 4px;
    font-size: 90%;
    color: #c7254e;
    background-color: #f9f2f4;
    border-radius: 4px;
}
</style >

이제 적절한 UI 작업을 거치면, 꽤나 그럴싸한 결과가 나올 것 입니다. 바로 이렇게 말이죠.

스타일링을 추가한 PostDetailView의 현재 상태는 다음과 같습니다.

<template>
    <div class="mx-3">
        <div>
            <div class="mt-8">
                <RouterLink to="/" style="text-decoration: none; color: inherit;">
                    Home
                </RouterLink> »
                <RouterLink to="/posts" style="text-decoration: none; color: inherit;">
                    Posts
                </RouterLink>
            </div>
            <h1 v-if="options.title">
                {{ options.title }}
            </h1>
            <div v-if="options.date" class="text-600">
                {{ timeformatChange(options.date) }} · {{ timeToRead }} min read
            </div>
        </div>

        <div
            class="surface-600 text-300 text-lg p-3 mt-3 font-bold border-round flex flex-column align-items-start justify-content-center">
            <div class="">
                <i v-if="!tocOpen" class="pi pi-caret-right" @click="tocClick()"></i>
                <i v-else class="pi pi-caret-down" @click="tocClick()"></i>
                &nbsp; Table of Contents
            </div>
            <div v-if="tocOpen" class="mt-2">
                <div v-for="item in tocItems" :key="item.text" class="m-2 px-3">
                    <div v-html="linkForTitle(item)" class="toc"></div>
                </div>
            </div>

        </div>

        <div class="line-height-4 text-xl custom" v-html="markdownHtml"></div>

        <div v-if="options.Tags">
            {{ options.Tags }}
        </div>
        <Comment />
    </div>
</template>

<script setup>
...

import Comment from '@/components/Comment.vue';

...

onMounted(async () => {
    const path = '../src/assets/posts/vue.js/vue1.md'
    await getMarkdownFile(path);
    markdownHtml.value = await markdownToHtml();
    readingTime(markdown.value);
});

const timeToRead = ref(0);
const readingTime = (text) => {
    const wordsPerMinute = 200;
    const noOfWords = text.split(/\s/g).length;
    const minutes = noOfWords / wordsPerMinute;
    timeToRead.value = Math.ceil(minutes);
};

...

const linkForTitle = (item) => {
    // console.log(item)
    if (item.level == 1) {
        return `<li class="list-none"><a href="#${item.anchor}" class="toc">${item.text}</a></li>`
    }
    else if (item.level == 2) {
        return `<li style="margin-left: 1rem;" class="list-none"><a href="#${item.anchor}" class="toc">${item.text}</a></li>`
    }

}

const timeformatChange = (time) => {
    const date = new Date(time);
    const year = date.getFullYear();
    const month = date.getMonth() + 1;
    const day = date.getDate();

    return `${year}${month}${day}`;
}

const tocOpen = ref(false);
const tocClick = () => {
    tocOpen.value = !tocOpen.value;
}
</script>

<style scoped>
.custom {
    /* Other styles */
    word-wrap: break-word;
    max-width: 100%;
    /* Set the desired maximum width for the content */
}

.custom :deep(a:not(.anchor)) {
    color: #42b983;
    text-decoration: none;
    border-bottom: 1px solid #42b983;
    transition: all 0.3s ease-in-out;
}

.custom :deep(code:not([class])) {
    padding: 2px 4px;
    font-size: 90%;
    color: #c7254e;
    background-color: #f9f2f4;
    border-radius: 4px;
}

.toc :deep(a) {
    color: inherit;
    text-decoration: none;
}

.toc:hover :deep(a) {
    color: #42b983;
    text-decoration: underline;
}

:deep(h1:hover .anchor) {
    display: inline-flex;
    color: var(--surface-700);
    margin-inline-start: 8px;
    font-weight: 100;
    user-select: none;
    text-decoration: underline;
    text-decoration-thickness: 1px;

}
</style >

이제 Table of Contents 부분이 linkForTitle 이라는 함수와 더불어 tocItems 리스트를 활용했다는 점이 가장 먼저 살펴볼 만 합니다. h2 까지의 목차를 리스트에 나오게 만들었습니다. v-if를 활용해 테이블을 접었다 펼치는 기능도 넣어 두었습니다.

두 번째 볼만한 부분은 시간 + 읽는 속도 계산 부분입니다. timeFormatChange라는 함수에서 적절한 시간 표현을 만들었고, 읽는 시간 계산은 이 문서를 참고해 readingTime이라는 함수로 구현하였습니다.

이제 또 건드려볼 것은 댓글 창입니다. 댓글 기능은 Utterances를 이용했습니다. 위의 문서에서 configuration 파트 이후를 작성 하시고 Enable Utterances 쪽에서의 <script> 를 복사해 오면 github 와 연동된 댓글 기능을 쉽게 사용할 수 있습니다. 다만 이제, vue js에서는 <script>태그를 <template> 안에 집어넣어서 사용할 수 없기 때문에, 저희는 다음과 같이 /src/components/Comment.vue 라는 파일을 만들고 다음과 같이 내용을 채울 것입니다.

<template>
    <div ref="commentContainer"></div>
</template>
  
<script setup>
import { ref, onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useToggleStore } from '@/stores/toggle';

const toggleStore = useToggleStore();
const { toggleDarKMode } = storeToRefs(toggleStore);

// Define a ref for the "commentContainer" element
const commentContainer = ref(null);

// Function to add Utterances script with the specified theme
function addUtterancesScript(theme) {
    // Remove the existing Utterances script container (if any)
    const existingContainer = document.querySelector('#utterances-container');
    if (existingContainer) {
        existingContainer.remove();
    }

    // Create a new container div for the script
    const utterancesContainer = document.createElement('div');
    utterancesContainer.id = 'utterances-container';

    // Create the new script element
    const utterances = document.createElement('script');
    utterances.type = 'text/javascript';
    utterances.async = true;
    utterances.crossorigin = 'anonymous';
    utterances.src = 'https://utteranc.es/client.js';
    utterances.setAttribute('issue-term', 'pathname');
    utterances.setAttribute('theme', theme);
    utterances.setAttribute('repo', 'Larshavin/Larshavin.github.io');

    // Append the script to the container
    utterancesContainer.appendChild(utterances);

    // Append the container to the "commentContainer" element
    commentContainer.value.appendChild(utterancesContainer);
}

// Call the function when the component is mounted
onMounted(() => {
    // Add the Utterances script with the initial theme
    addUtterancesScript(toggleDarKMode.value ? 'github-dark' : 'github-light');
});

// Watch for changes in the toggleDarKMode ref and update the theme accordingly
watch(toggleDarKMode, (newValue) => {
    const theme = newValue ? 'github-dark' : 'github-light';
    addUtterancesScript(theme);
});
</script>

위의 컴포넌트의 핵심은 결국, <div ref="commentContainer"></div> 쪽에 child로 utterances의 엘리먼트 document.createElement('script') 를 추가하는 것 입니다. 추가적으로 다크 모드의 적용을 위해 onMounted + watch의 기능을 종합적으로 사용하였습니다. watch는 이전에 살펴본 computed 와 그 쓰임새가 비슷한데요. (사실 Computed나 단순한 함수를 사용하여 비슷하게 구현할 수 있을 듯 합니다. 그래도 여러가지 기능을 소개해 드리고 싶었습니다) 데이터의 변경을 감지한다는 것이 큰 특징입니다.

onMounted의 특징으로는 내부의 데이터가 변경되면, 라이프사이클 특성 상 리로딩시 오류가 생기게 됩니다. 따라서, 어느 데이터 값의 변경, 여기선 toggleDarKMode 값이 변할 때 마다, watch에서 자동으로 새로운 환경을 제시하게 됩니다. 구체적으로 보면 정의한 addUtterancesScript 함수를 실행하여 기존 테마의 댓글 element를 삭제하고, 테마를 바꾼 댓글 창을 다시 만들게 됩니다.

지금 상태에서 아직 부족한 기능은 세 가지 입니다. 하나는 스크롤이 아래에 있을 때, 페이지 최상단으로 향할 수 있게 하는 버튼과 태그 버튼 꾸미기 및 기능 구현 그리고 앞 뒤 게시글 이동 버튼 입니다. 이제 자잘한 UI는 다음과 같이 쉽게 구현 할 수 있겠습니다.

최상단으로 이동하는 버튼의 기능은 다음과 같이 구현할 수 있습니다.

<template>
  <div>
    <!-- Your main content here -->

    <!-- The div that will be shown only when scrolling downward -->
    <div v-if="showTopButton" class="scroll-btn" @click="scrollToTop">TOP</div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const showTopButton = ref(false);

// Function to scroll to the top when the TOP div is clicked
const scrollToTop = () => {
  window.scrollTo(0, 0);
};

// Function to update the scroll direction based on the current scroll position
const handleScroll = () => {
  // console.log(window.scrollY, showTopButton.value)
  if (window.scrollY > 0) {
    showTopButton.value = true;
  } else {
    showTopButton.value = false;
  }
};

// Add the scroll event listener when the component is mounted
onMounted(() => {
  window.addEventListener('scroll', handleScroll);
});

// Remove the scroll event listener when the component is unmounted
onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll);
});
</script>

<style>
.scroll-btn {
  cursor: pointer;
  position: fixed;
  bottom: 50px;
  right: 50px;
  width: 50px;
  height: 50px;
  border-radius: 50%;
  background-color: var(--surface-700);
  color: aliceblue;
}
</style>

위의 코드를 저는 App.vue에 적용하여 모든 페이지에서 스크롤에 따라 사용할 수 있게 만들었습니다. window.addEventListener 를 이용해 스크롤의 y 값에 따라 버튼을 on/off 시키는 로직입니다. 윈도우 객체가 가지는 기능에 대한 문서는 여기를 참고하시면 됩니다. Mounted가 될 때 이벤트를 수신하고, Unmounted 가 될 때는 이벤트 수신을 제거합니다. 버튼의 위치는 fixed와 absolute한 좌표로 위치를 할당해주었습니다.

남은 문제는 게시글 목록 관리를 어떻게 할 것인가 입니다.

백엔드를 구성해야 하는 압박감이 점점 코 앞으로 다가오고 있습니다. 물론 hugo가 작동하는 방식처럼, 정적인 데이터들을 같은 vue의 public 폴더에 넣어두어 관리할 수 있겠습니다. 저는 게시글이 쌓이면 쌓일 수록 github 원격 폴더에 데이터가 동시에 쌓이는 것을 원치 않습니다. 또한 데이터 보호용의 측면에서도 백엔드를 구성하는 것이 더 나을지도 모르겠습니다. 프론트엔드에서 드래그 방지 기능을 넣고, 데이터들을 뒷단 서버에 고이 모셔두는 방향으로요.

그래도 이렇게, 블로그의 게시글 보기 페이지의 UI를 어느 정도 만들어 낸 것 같습니다! vue.js를 활용한 UI 만들기 시리즈는 여기서 멈춰도 될 것 같습니다. 저희가 같이 해보지 않은 것이 Search, Archive, Tag 경로에 나와있는 UI 입니다. 허나, 저 부분들도 어려운 것은 검색과 같은 기능 구현이지, 화면 자체는 그렇게 어려움이 없어 보입니다.

블로그 만들기 시리즈는 끝나지 않았습니다! 다음 게시글에서는 golang과 postgresql를 이용해 markdown과 static file 들을 어떻게 관리할 지 고민 해보도록 합시다.

profile
In the realm of astronomy once, but now becoming a dream-chasing gopher

2개의 댓글

comment-user-thumbnail
2023년 7월 24일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

1개의 답글