Nuxt.jsでポートフォリオサイトを作成してみた

Nuxt.jsはSPAやSSRで使われるフレームワークですが、静的サイトの生成も可能ということで、Nuxt.jsを使ってポートフォリオサイトを作ってみました。
その過程をまとめたので良かったらご覧ください。

![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/bc89221f-c27d-57a4-334f-a5221d5d6c04.gif)



## Nuxt.jsのプロジェクトを作成

まずは、Nuxt.jsのプロジェクトを作成します。
下記のコマンドで作成可能です。

```
npx create-nuxt-app <プロジェクト名>
```
インストールが終わると下記のディレクトリ構成が作られます。
![キャプチャ.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/96d1d88d-97fc-0796-9714-4d9ec7d7b3b6.jpeg)
## サーバーの起動
下記コマンドでサーバーを起動できます。

```
npm run dev
```

## Sassのインストール
Sassを使いたいので、Sassもインストールしておきます。

```
npm install --save-dev node-sass sass-loader
```
こちらでもまとめています。
【[Nuxt.jsでSass記述ができるようにする方法](https://qiita.com/To-K/items/cf880eb05e1cc34acadc)】

## headタグの設定
Nuxt.jsではheadタグの設定は、nuxt.config.jsでおこなうことができます。

```js:nuxt.config.js
export default {
  mode: 'spa',
  head: {
    htmlAttrs:{
      lang: 'ja'
    },
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: process.env.npm_package_description || '' }
    ],
    link: [
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Noto+Sans+JP:400,500,700,900&display=swap' },
      { rel: 'stylesheet', href: 'https://use.fontawesome.com/releases/v5.6.1/css/all.css' }
    ]
  },
  loading: { color: '#fff' },
  css: [
    '~/assets/style/main.scss'
  ],
}

```
グローバルCSSやFont Awesome、Google Fontsの読み込みをおこないました。
【[Nuxt.jsでグローバル（共通の）CSSを設定する方法](https://qiita.com/To-K/items/632e26980b7a2e68b188)】
【[Nuxt.js（Vue.js）でFont AwesomeとGoogle FontsをCDNで読み込む方法](https://qiita.com/To-K/items/dcab9cbdcd121fdf382b)】

## pagesディレクトリにファイルを作成
pagesディレクトリには、各ページのvueファイルを作成します。
今回のポートフォリオサイトは、
・topページ
・worksページ
・contactページ
の3つのページを作成するので、下記のようなディレクトリ構成にしました。
![pages.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/e61bf468-4496-6d25-add3-3bf3d135e719.jpeg)

Nuxt.jsでは、pagesディレクトリにファイルを作成するだけで、自動でルーティングの設定をおこなってくれます。

```js:router.js
routes: [{
    path: "/contact",
    component: _0b3af656,
    name: "contact"
  }, {
    path: "/works",
    component: _93633e98,
    name: "works"
  }, {
    path: "/",
    component: _e0fb6544,
    name: "index"
  }],
```
router.jsファイルを見ると、上記のようにルーティングしてくれています。かなり便利です(^^

## 共通のコンポーネントを作成

今回のポートフォリオサイトではヘッダーとフッターが全ページの共通部分です。
componentsディレクトリ内にHeader.vueとFooter.vueファイルを作成し、layoutsディレクトリにあるdefault.vueにインポートします。

```vue:default.vue
<template>
  <div>
    <HeaderSection />
    <div class="main-contents">
      <nuxt />
    </div>
    <FooterSection />
  </div>
</template>


<script>
import HeaderSection from "~/components/Header.vue";
import FooterSection from "~/components/Footer.vue";

export default {
  components: {
    HeaderSection,
    FooterSection
  }
};
</script>
```
共通部分の読み込みは下記でもまとめています。
【[Nuxt.jsで簡単のサイトを作ってみた](https://qiita.com/To-K/items/c37cf22d7fa35b774b33)】

###画像はassetsディレクトリに
画像はassetsディレクトリ内に配置します。
読み込み方法はこちら【[Vue.js(Nuxt.js)で画像を表示させる方法（imgタグ、背景画像）](https://qiita.com/To-K/items/6f7b63a452340e30eeb7)】


## 各コンポーネントを作りpagesでインポートしていく
共通部分を読み込んだので各ページを作っていきます。
pagesディレクトリのvueファイルで全て作っていっても良いのですが、下記の様になるべくコンポーネントを別けて作りました。
![components.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/65d85bcf-c01f-7e6a-c9e9-e12e28263811.jpeg)

そして、作ったコンポーネントをpagesディレクトリのvueファイルでインポートしていきます。
下記はトップページのindex.vueです。

```vue:index.vue
<template>
  <div>
    <MainKeyvisualSection />
    <section class="section-area about">
      <AboutSection />
    </section>
    <section class="section-area skills">
      <SkillSection />
    </section>
    <section class="section-area works">
      <WorksSection />
    </section>
    <section class="section-area contact">
      <ContactSection />
    </section>
  </div>
</template>

<script>
import MainKeyvisualSection from "~/components/MainKeyvisual.vue";
import AboutSection from "~/components/About.vue";
import SkillSection from "~/components/Skill.vue";
import WorksSection from "~/components/Works.vue";
import ContactSection from "~/components/Contact.vue";

export default {
  components: {
    MainKeyvisualSection,
    AboutSection,
    SkillSection,
    WorksSection,
    ContactSection
  }
};
</script>
```

Nuxt.jsでは、一つのコンポーネントにHTML、JS、CSSを全て記述します。

```vue:
<template>
 <!--HTMLを記述-->
</template>

<script>
　//JSを記述
</script>

<style lang="scss" scoped>
　//CSSを記述
</style>
```
その為、一度作ったコンポーネントは使い回しも簡単です。

## ヘッダーのページ遷移を作り込んでいく

ヘッダー部分はページ遷移や動きがあるので下記のように作成しました。

```vue:Header.vue
<template>
  <header class="header" ref="header" :class="headerFixed">
    <div class="container">
      <h1 class="main-title">TK Portfolio</h1>

      <nav v-if="windowWidth > 968">
        <ul class="gnav">
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/" exact>Home</nuxt-link>
          </li>
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/works">Works</nuxt-link>
          </li>
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/contact">Contact</nuxt-link>
          </li>
        </ul>
      </nav>

      <div v-else class="sp-nav-btn" @click="spNavClick">
        <i class="fas fa-times" v-if="spNavFlag"></i>
        <i class="fas fa-bars" v-else></i>
      </div>
    </div>
  </header>
</template>

<script>
export default {
  props: ["spNavFlag"],
  data() {
    return {
      headerHeight: "",
      windowHeight: "",
      windowWidth: "",
      headerFixed: null,
      gnavFixed: null,
      nowFixed: false,
      spNavBtnState: true
    };
  },
  mounted() {
    this.windowWidth = window.innerWidth;
    this.headerHeight = this.$refs.header.clientHeight;
    window.addEventListener("scroll", this.scrollWindow);
  },
  methods: {
    scrollWindow() {
      this.windowHeight = window.scrollY;
      if (this.headerHeight < this.windowHeight) {
        this.headerFixed = "header-fixed";
        this.gnavFixed = "gnav-fixed";
        this.nowFixed = false;
      } else {
        this.headerFixed = null;
        this.gnavFixed = null;
      }

      if (this.nowFixed) {
        this.headerFixed = "header-fixed";
        this.gnavFixed = "gnav-fixed";
      }
    },
    spNavClick() {
      this.$emit("clickSpNav");
    }
  },
  watch: {
    $route() {
      if (this.headerFixed) {
        this.nowFixed = true;
      }
    }
  }
};
</script>

<style lang="scss" scoped>
.header {
  padding: 30px 0;
  box-sizing: border-box;
  box-shadow: 0 0 2px #333;
  position: fixed;
  width: 100%;
  z-index: 98;
  .container {
    display: flex;
    justify-content: space-between;
    align-items: flex-end;
    .main-title {
      font-size: 4rem;
    }
    .gnav {
      display: flex;
      justify-content: space-between;
      & li:not(:last-of-type) {
        margin-right: 20px;
      }
      & a:hover {
        border-bottom: 2px solid black;
        transition: 0.2s;
      }
      & a:hover.gnav-fixed {
        border-bottom-color: #fff;
      }
    }
  }
}

.header-fixed {
  transition: 0.3s;
  background: rgba(24, 24, 24, 0.9);
  color: #fff;
}

.link-active.gnav-fixed {
  border-bottom: 2px solid #fff;
}

.link-active {
  border-bottom: 2px solid black;
}

@media screen and (max-width: 968px) {
  .header {
    padding: 20px 0;
    .container {
      display: block;
      text-align: center;
      .main-title {
        font-size: 2.6rem;
      }
    }
  }
}

.sp-nav-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  font-size: 3rem;
}
</style>
```
### ナビゲーションはnuxt-link
ページ遷移はnuxt-linkを使って実装します。

```vue
<nuxt-link :class="gnavFixed" active-class="link-active" to="/" exact>Home</nuxt-link>
```
nuxt-linkは最終的にaタグとして出力されます。
現在のページだけクラスを付けた時は、active-classを使います。グローバルナビゲーションは、現在のページだけクラスを付けたい時は多いと思うので、便利です。
![ナビゲーション.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/b54e06ee-4355-e2d9-5ca0-83c2e0d7b5fd.jpeg)
【[Vue Router（Nuxt.js）で現在のページ（カレントページ）にclassを自動追加する方法](https://qiita.com/To-K/items/f57f845fc2d51d7385e0)】

### スクロールでクラスを付ける

```js
mounted() {
    this.windowWidth = window.innerWidth;
    this.headerHeight = this.$refs.header.clientHeight;
    window.addEventListener("scroll", this.scrollWindow);
  },
  methods: {
    scrollWindow() {
      this.windowHeight = window.scrollY;
      if (this.headerHeight < this.windowHeight) {
        this.headerFixed = "header-fixed";
        this.gnavFixed = "gnav-fixed";
        this.nowFixed = false;
      } else {
        this.headerFixed = null;
        this.gnavFixed = null;
      }
      if (this.nowFixed) {
        this.headerFixed = "header-fixed";
        this.gnavFixed = "gnav-fixed";
      }
    },
```
スクロールによってクラスをつけることで、ヘッダーを変化するようにしています。

###レスポンシブ時には、スマホ用のメニューを表示
![header.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/21f609df-5f9b-ae94-a819-bd341b451cb0.jpeg)
レスポンシブ時は、上記のようにスマホ用のメニューが表示するようにv-ifを使用しています。

```vue
      <nav v-if="windowWidth > 968">
        <ul class="gnav">
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/" exact>Home</nuxt-link>
          </li>
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/works">Works</nuxt-link>
          </li>
          <li>
            <nuxt-link :class="gnavFixed" active-class="link-active" to="/contact">Contact</nuxt-link>
          </li>
        </ul>
      </nav>

      <div v-else class="sp-nav-btn" @click="spNavClick">
        <i class="fas fa-times" v-if="spNavFlag"></i>
        <i class="fas fa-bars" v-else></i>
      </div>
```
それに合わせて、Spnav.vueを作成しスマホ用のメニューがクリックされた時用のリンクを作成します。

```vue:Spnav
<template>
  <div>
    <ul class="sp-nav">
      <li>
        <nuxt-link active-class="link-active" to="/" exact>Home</nuxt-link>
      </li>
      <li>
        <nuxt-link active-class="link-active" to="/works">Works</nuxt-link>
      </li>
      <li>
        <nuxt-link active-class="link-active" to="/contact">Contact</nuxt-link>
      </li>
    </ul>
  </div>
</template>

<style lang="scss" scoped>
.sp-nav {
  transform: translate(0, 66px);
  position: fixed;
  width: 100%;
  z-index: 9999;
  text-align: center;
  background: rgba(24, 24, 24, 0.9);
  & li {
    height: 50px;
    line-height: 50px;
    & a {
      display: block;
      color: #fff;
    }
  }
  & li:not(:last-of-type) {
    border-bottom: 1px dotted #fff;
  }
}
</style>
```
![nuxtスマホ用のメニュー.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/80027960-e6fc-4d76-3a9e-f7bc3fe0a275.jpeg)

Spnav.vueは全ページ共通部分なので、default.vueでインポートします。

```vue:default.vue
<template>
  <div>
    <HeaderSection @clickSpNav="clickSpNav" :spNavFlag="spNavFlag" />
    <template v-if="spNavFlag">
      <Spnav @clickSpNav="clickSpNav" />
    </template>
    <div class="main-contents">
      <nuxt />
    </div>
    <FooterSection />
  </div>
</template>

<script>
import HeaderSection from "~/components/Header.vue";
import FooterSection from "~/components/Footer.vue";
import Spnav from "~/components/Spnav.vue";

export default {
  data() {
    return {
      spNavFlag: false
    };
  },
  components: {
    HeaderSection,
    Spnav,
    FooterSection
  },
  methods: {
    clickSpNav() {
      this.spNavFlag = !this.spNavFlag;
    }
  },
  watch: {
    $route() {
      if (this.spNavFlag) {
        this.spNavFlag = false;
      }
    }
  }
};
</script>
```
defalut.vueとHeader.vueは親子関係にあります。Header.vueのボタンが押された時にdefault.vueのイベントを発火させてSpnav.vueを表示させる仕様にしました。

```js:Header.vue
    spNavClick() {
      this.$emit("clickSpNav");
    }
```

```vue:default.vue
<template v-if="spNavFlag">
  <Spnav @clickSpNav="clickSpNav" />
</template>
<script>
export default {
  data() {
    return {
      spNavFlag: false
    };
  },
  methods: {
    clickSpNav() {
      this.spNavFlag = !this.spNavFlag;
    }
  },
  watch: {
    $route() {
      if (this.spNavFlag) {
        this.spNavFlag = false;
      }
    }
  }
};
</script>
```
子componentsは、`this.$emit("clickSpNav")`
親componentsは、`<Spnav @clickSpNav="clickSpNav" />`でイベントの発火を受け取ります。
そして、親自身のmethodsを実行できます。

```js
  methods: {
    clickSpNav() {
      this.spNavFlag = !this.spNavFlag;
    }
```

## フッターにトップへ戻るボタンを実装

Footer.vueに「トップへ戻るボタン」を作成しました。
![footer.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/c32cd83d-e396-c765-94e1-ff9f395e895d.jpeg)


```vue:Footer.vue
<template>
  <footer class="footer">
    <div class="container">
      <p>
        <small>TK Portfolio</small>
      </p>
      <p v-show="windowHeight > 100" class="top-btn" @click="returnTop">Top</p>
    </div>
  </footer>
</template>

<script>
export default {
  data() {
    return {
      headerHeight: "",
      windowHeight: ""
    };
  },
  mounted() {
    window.addEventListener("scroll", this.scrollWindow);
  },
  methods: {
    scrollWindow() {
      this.windowHeight = window.scrollY;
    },
    returnTop() {
      const duration = 400; // 移動速度
      const interval = 10; // 移動間隔
      const step = -window.scrollY / Math.ceil(duration / interval); // 1回に移動する距離
      const timer = setInterval(() => {
        window.scrollBy(0, step);
        if (window.scrollY <= 0) {
          clearInterval(timer);
        }
      }, interval);
    }
  }
};
</script>

<style lang="scss" scoped>
.footer {
  background: #000;
  color: #fff;
  text-align: center;
  padding: 30px 0;
}

.top-btn {
  position: fixed;
  bottom: 10px;
  right: 10px;
  width: 50px;
  height: 50px;
  line-height: 50px;
  border-radius: 4px;
  background: #333;
  color: #fff;
  cursor: pointer;
}
</style>
```
## コンタクトフォームはGoogleフォームで実装
コンタクトフォームはGoogleフォームで実装することにしました。
しかし、ただフォームを貼るだけと味気ないので、HTML・CSSは自分で書き、Googleフォームと連携することにしました。
![コンタクトフォーム.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/da72ab1b-625f-785d-e53a-c6c9f74a0f37.jpeg)
Googleフォームとの連携は下記の記事を参考にさせて貰いました。
【[Googleフォームを自在にカスタマイズする](https://qiita.com/curioussafuta/items/fa077cbf820faca30598)】

## モーダルウィンドウと画像ギャラリーを実装
ポートフォリオサイトなので、作品を紹介するモーダルウィンドウと画像ギャラリーを実装しました。
・モーダルウィンドウは作品の紹介とコンタクトフォームの確認画面
・画面ギャラリーはバナーやキービジュアルの紹介
にそれぞれ使用しました。
下記のようなイメージです。

![modal.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/cbbffebd-8ff6-31cd-be9c-6529c3312cd8.gif)
![lightbox.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/59d5cbb6-daf1-49a8-258d-1e45fa70e032.gif)


```vue:WorksPage.vue

<template>
  <div class="container">
    <h2>Works</h2>
    <div class="works-area">
      <div class="works-content" @click="show('TkPortfolio')">
        <div class="img-effect">
          <img src="~/assets/web/ptweb.jpg" alt="webdesign01" />
        </div>
      </div>

      <div class="works-content" @click="show('Eternity')">
        <div class="img-effect">
          <img src="~/assets/web/webdesign01.jpg" alt="webdesign01" />
        </div>
      </div>

      <div class="works-content" @click="show('Suslon')">
        <div class="img-effect">
          <img src="~/assets/web/webdesign02.jpg" alt="webdesign02" />
        </div>
      </div>

      <div
        v-for="(img,index) in bannerImages"
        :key="index"
        class="works-content"
        @click="showBanner(index)"
      >
        <img :src="`${img}`" />
      </div>
    </div>

    <client-only>
      <modal name="modal-content" width="80%" height="auto" :scrollable="true">
        <p class="close-btn" @click="hide">
          <i class="fas fa-times"></i>
        </p>
        <component :is="contentTitle" />
      </modal>
      <LightBox ref="lightbox" :images="images" :show-light-box="false" :show-thumbs="false"></LightBox>
    </client-only>
  </div>
</template>
​
​
<script>
import TkPortfolio from "~/components/works/TkPortfolio.vue";
import Eternity from "~/components/works/Eternity.vue";
import Suslon from "~/components/works/Suslon.vue";


import FashionM from "~/assets/banner/fashion-m-banner.jpg";
import FashionW from "~/assets/banner/fashion-w-banner.jpg";
import Fitness from "~/assets/banner/fitness.jpg";

import LightBox from "vue-image-lightbox";
require("vue-image-lightbox/dist/vue-image-lightbox.min.css");

export default {
  data() {
    return {
      contentTitle: null,
      bannerImages: [
        FashionM,
        FashionW,
        Fitness
      ],
      images: [
        { src: FashionM },
        { src: FashionW },
        { src: Fitness }
      ]
    };
  },
  components: {
    TkPortfolio,
    Eternity,
    Suslon,
    LightBox
  },
  methods: {
    show(contentTitle) {
      this.contentTitle = contentTitle;
      this.$modal.show("modal-content");
    },
    hide(contentTitle) {
      this.$modal.hide("modal-content");
      this.contentTitle = null;
    },
    showBanner(index) {
      this.$refs.lightbox.showImage(index);
    }
  }
};
</script>

<style lang="scss" scoped>
.works-area {
  display: flex;
  justify-content: space-around;
  flex-wrap: wrap;
  margin-bottom: 30px;
  .works-content {
    width: 300px;
    box-shadow: 1px 1px 3px #333;
    overflow: hidden;
    margin-bottom: 30px;
    cursor: pointer;
    .img-effect {
      position: relative;
      width: 300px;
      height: 180px;
      transition-duration: 0.3s;
      &:hover {
        transform: scale(1.1);
        &:before {
          opacity: 1;
          transform: scale(1.2);
          filter: blur(0);
        }
      }
      &:before {
        content: "view more";
        position: absolute;
        width: 300px;
        height: 180px;
        background: radial-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.8));
        transition-duration: 0.3s;
        opacity: 0;
        display: flex;
        justify-content: center;
        align-items: center;
        color: #fff;
        font-size: 22px;
        text-shadow: 0 0 2px #000;
        filter: blur(4px);
      }
    }
  }
}

.close-btn {
  width: 50px;
  height: 50px;
  line-height: 50px;
  font-size: 3rem;
  color: #fff;
  text-align: center;
  background: #333;
  cursor: pointer;
}

@media screen and (max-width: 768px) {
  .works-area {
    margin-bottom: 0;
  }
}
</style>
```

![components-works.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/05506136-db9d-3ac7-f4c9-915b512c9143.jpeg)
componentsディレクトリ内に新しくworksディレクトリを作成し、作品ごとにコンポーネントをつくりました。
そのコンポーネントをモーダル上に表示させています。clickイベントで引数を取り、その引数によって表示させるコンポーネントを変えています。
`<component :is="contentTitle" />`

今回は下記のライブラリを用いて実装しました。
・モーダルウィンドウは【[vue-js-modal](https://github.com/euvl/vue-js-modal)】
・画像ギャラリーは【[vue-image-lightbox](https://github.com/pexea12/vue-image-lightbox?ref=kabanoki.net)】

それぞれの使い方については下記の記事にまとめました。
【[vue-js-modalを使ってNuxt.js（vue.js）でモーダルを実装してみた）](https://qiita.com/To-K/items/82443d1d85f2e3bb24a5)】
【[Nuxt.jsで画像ギャラリーを実装（【vue-image-lightbox】を使用）](https://qiita.com/To-K/items/657d7c2662ceed254c8e)】


##静的アセット

最後に下記コマンドで静的アセットします。

```
npm run generate
```
画像ファイルの大きさによっては上手くアセットできない時があります。
その場合アセットサイズを調整することで回避できることがあります。

```js:nuxt.config.js
  build: {
    /*
    ** You can extend webpack config here
    */
    extend(config, ctx) {
      config.performance.maxAssetSize = 100000;
    }
  },
```

![dist.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/98df8ec0-1036-449e-6bb1-c785da20d1fe.jpeg)
無事に静的アセットが完了すると、上記の画像のようにdistディレクトリが生成されます。
このdistフォルダをレンタルサーバーやホスティングサービスにアップするだけで公開可能です。
静的サイトなのでNetlifyやFirebaseも利用できます。

## 最後に
今回はNuxt.jsを使ってポートフォリオサイトを作ってみました。
Nuxt.jsはSPAやSSRで使われるイメージが強いですが、静的アセットも可能です。WEB製作にも便利だなと実感しました。
SPAのようなリッチなページ遷移ができるのも良いですね(^^♪

![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/559347/2f58646c-1461-adde-25c6-36b9195b317e.gif)


完成したポートフォリオサイト
↓
~~https://tksportfolio.netlifycom/~~ (現在非公開)