LoginSignup
27
31

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-29

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

ezgif.com-video-to-gif (1).gif

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

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

npx create-nuxt-app <プロジェクト名>

インストールが終わると下記のディレクトリ構成が作られます。
キャプチャ.JPG

サーバーの起動

下記コマンドでサーバーを起動できます。

npm run dev

Sassのインストール

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

npm install --save-dev node-sass sass-loader

こちらでもまとめています。
Nuxt.jsでSass記述ができるようにする方法

headタグの設定

Nuxt.jsではheadタグの設定は、nuxt.config.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を設定する方法
Nuxt.js(Vue.js)でFont AwesomeとGoogle FontsをCDNで読み込む方法

pagesディレクトリにファイルを作成

pagesディレクトリには、各ページのvueファイルを作成します。
今回のポートフォリオサイトは、
・topページ
・worksページ
・contactページ
の3つのページを作成するので、下記のようなディレクトリ構成にしました。
pages.JPG

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

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にインポートします。

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で簡単のサイトを作ってみた

画像はassetsディレクトリに

画像はassetsディレクトリ内に配置します。
読み込み方法はこちら【Vue.js(Nuxt.js)で画像を表示させる方法(imgタグ、背景画像)

各コンポーネントを作りpagesでインポートしていく

共通部分を読み込んだので各ページを作っていきます。
pagesディレクトリのvueファイルで全て作っていっても良いのですが、下記の様になるべくコンポーネントを別けて作りました。
components.JPG

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

<template>
 <!--HTMLを記述-->
</template>

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

<style lang="scss" scoped>
 //CSSを記述
</style>

その為、一度作ったコンポーネントは使い回しも簡単です。

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

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

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を使って実装します。

<nuxt-link :class="gnavFixed" active-class="link-active" to="/" exact>Home</nuxt-link>

nuxt-linkは最終的にaタグとして出力されます。
現在のページだけクラスを付けた時は、active-classを使います。グローバルナビゲーションは、現在のページだけクラスを付けたい時は多いと思うので、便利です。
ナビゲーション.JPG
Vue Router(Nuxt.js)で現在のページ(カレントページ)にclassを自動追加する方法

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

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
レスポンシブ時は、上記のようにスマホ用のメニューが表示するようにv-ifを使用しています。

      <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を作成しスマホ用のメニューがクリックされた時用のリンクを作成します。

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

Spnav.vueは全ページ共通部分なので、default.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を表示させる仕様にしました。

Header.vue
    spNavClick() {
      this.$emit("clickSpNav");
    }
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を実行できます。

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

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

Footer.vueに「トップへ戻るボタン」を作成しました。
footer.JPG

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
Googleフォームとの連携は下記の記事を参考にさせて貰いました。
Googleフォームを自在にカスタマイズする

モーダルウィンドウと画像ギャラリーを実装

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

modal.gif
lightbox.gif

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
componentsディレクトリ内に新しくworksディレクトリを作成し、作品ごとにコンポーネントをつくりました。
そのコンポーネントをモーダル上に表示させています。clickイベントで引数を取り、その引数によって表示させるコンポーネントを変えています。
<component :is="contentTitle" />

今回は下記のライブラリを用いて実装しました。
・モーダルウィンドウは【vue-js-modal
・画像ギャラリーは【vue-image-lightbox

それぞれの使い方については下記の記事にまとめました。
vue-js-modalを使ってNuxt.js(vue.js)でモーダルを実装してみた)
Nuxt.jsで画像ギャラリーを実装(【vue-image-lightbox】を使用)

静的アセット

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

npm run generate

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

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

dist.JPG
無事に静的アセットが完了すると、上記の画像のようにdistディレクトリが生成されます。
このdistフォルダをレンタルサーバーやホスティングサービスにアップするだけで公開可能です。
静的サイトなのでNetlifyやFirebaseも利用できます。

最後に

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

ezgif.com-video-to-gif (1).gif

完成したポートフォリオサイト

https://tksportfolio.netlifycom/ (現在非公開)

27
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
31