v2.2.0リリースノート: 写真ポートフォリオとして

この記事はNext.js版の内容です。現在はAstroで構築し直したため、情報が古い可能性があります。 当時のリポジトリは こちらあるので参考にしてください。

変形のプロセスを記録するのが習慣になっている。 気づけば膨大なデータがストレージの奥深くで眠っている。 掘り返すこともなければ、忘れ去られるわけでもなく、ただそこにあり続ける。 まあ、それはそれで悪くない。

でも、たまには引っ張り出して、改めて日の光を当ててみるのもいいかもしれない。 そんなことを思い立ち、日々の記録として残していこうと考えた。

いうわけで、今回は写真ポートフォリオ機能を作ってみたので、その過程を記しておく。

必要な機能

写真ポートフォリオを作るにあたって、国内外の写真共有サイトをひと通り眺めてみた。 良い意味でも悪い意味でも、参考になったのが以下のサイト群。

参考にした写真共有サイト(国内)

参考にした写真共有サイト(国外)

この中でも1XのUIが、イメージしているものに最も近かった。 写真のレベルが異次元に高いのも、インスピレーションを刺激された理由の一つかもしれない。

必要な機能をまとめると以下のようになる。

  • EXIFデータを表示する
  • モーダルで画面いっぱいに画像を表示する
  • GitHubで画像、メタデータを管理する

あくまで写真が主役なので、無駄な情報は表示させない。

機能の実装

画像の管理方

画像やメタデータ(EXIF)はGitHubで管理する設計にした。 具体的には、以下のリポジトリに保存している。

このリポジトリには記事も保存している。 つまり、サイトのコンテンツだけを疎結合に保存する針を取った。 シンプルで、メンテナンス性も高い。

画像のメタデータは、MarkdownのFrontmatterに記載する。 例えば、こんな感じ。

photos/2019-08-14/01.md
---
path: "/photos/2019-08-14/01.jpg"
camera: "Canon EOS 60D"
lens: "EF-S18-55mm f/3.5-5.6 IS II"
fNumber: 8
focalLength: 55
shutterSpeed: "1/160"
iso: 100
status: published
publishedAt: 2019-08-14
---

基本的にFrontmatterのみ使用して、画像のメタデータを管理している。 画像へのパスも記載しているため、これだけで情報が完結する。

写真にタイトルを付けるのは個人的に好きではないし、考える時間ももったいない。 そこで、タイトルは自動生成するようにした。

フォーマットは以下のようなシンプルな形式。

# 「撮影日 / 作成日時の古い順にカウントした番号」
2019-08-14 / 10

これなら、無駄な頭脳的コストをかけずに済む。

上述したメタデータを元に、ページをNext.jsのSSG(Static Site Generation)で生成する。 記事のメタデータをオブジェクト化する際は Contentlayer使用。 記事生成のプロセスと統一することで、シンプルな管理を維持している。

カメラ名やレンズ名のように値が定数化しているものは、Enumを使って入力を制限する。 例えば、以下のようなレンズオブジェクトを定義する。

apps/web/src/config/photo/lens.ts
export type Lens = Record<"name" | "manufacturer", string>;
export const lenses = [
{
name: "EF-S18-55mm f/3.5-5.6 IS II",
manufacturer: "Canon",
},
{
name: "EF-S55-250mm f/4-5.6 IS II",
manufacturer: "Canon",
},
{
name: "FE 55mm F1.8 ZA",
manufacturer: "SONY",
},
] as const satisfies Lens[];
export const lensNames = lenses.map(({ name }) => name);

こうすることで、存在しないカメラ名やレンズ名を入力するとビルド時にエラーが発生する。 人間はミスをするもの。 人間を信用しない設計こそが、長期的な安定性を生む。

apps/web/contentlayer.config.ts
const Photo = defineDocumentType(() => ({
name: "Photo",
filePathPattern: "photos/**/*.md",
fields: {
path: {
type: "string",
required: true,
},
camera: {
type: "enum",
options: cameraNames,
required: true,
},
lens: {
type: "enum",
options: lensNames,
required: true,
},
// ...
},
}));

この法の他のメリットは ビルド時に型が保証されることである。 enumを指定すると、Contentlayerが自動的にユニオン型を付与してくれるので、型安全なコードが書きやすくなる。 結果として、不要なバグを減らせるので一石二鳥というわけだ。

ビルド時には、以下の処理も行っている。

  • 画像の横幅・縦幅の取得
  • ページのレイアウトを崩さないために、事前に画像サイズを取得
  • Plaiceholder使ったBlur画像の生成
    • 高解像度の写真を表示する前に、ぼかし画像を一瞬挟むことでスムーズなUXを提供

ただし、これらの詳細な処理については割愛する。

モーダル機能の実装

普通のモーダルでは面白くないので、Next.jsの Intercepting Routes使ってみた。 文字どおりルーティングをIntercept(途中で捕まえる)することで、ユーザが別のコンテキストに切り替えることなく、ルーティング先のコンテンツを表示できる。

具体的な動作は以下のとおり。

  1. 画像をクリックするとモーダルが開く
  2. ページをリロードするとモーダルではなく写真ページ全体がレンダリングされる
  3. 直接URLを開くとInterceptされずに写真ページがそのまま表示される

言葉だけでは少し分かりにくいかもしれないので、実際に画像一覧ページを触ってもらうのが一番。 また、Web版のInstagramも同じような動作をするので、参考になるかもしれない(InstagramはNext.jsで作られてはいない点に注意)。

Intercepting Routesを使うことで以下のメリットがある。

  • モーダルコンテンツがURL経由で共有可能になる
  • ページを更新してもモーダルのコンテキストが保持される
  • ブラウザバックで前のページに戻るのではなくモーダルが閉じる
  • ブラウザフォワードでモーダルを再度開ける

今回は写真のモーダルに使ってみたが、ログインモーダルやショッピングカートのモーダルなどにも応用できそう。

お、react-medium-image-zoom使って、Mediumっぽい画像ズーム機能とモーダルを組み合わせて実装しようとしたが、うまくいかなかった。 これが実装できれば、さらに良かったんだけど。 そもそも仕組み的に可能なのかを調査しないといけないMotion使うことになるだろうか。JSはあまり使いたくないが)。

OGP画像の生成で詰まった

写真詳細ページのOGP画像は next/og使って生成した。 Next.jsではopengraph-image.tsx定義することで、OGP画像を自動生成してくれるので、毎回手動で用意する手間が省ける。

ローカルで開発サーバを立ち上げたときは問題なく動作していた。 しかし、本番環境にデプロイしてOGP画像のURLにアクセスすると500エラーが。

OGP画像のURLにアクセスしたときの処理の大まかな流れは以下のとおり。

  1. リクエストされたslug基づき、該当する写真データを取得する
  2. 環境変数を基に画像のフルURLを作成し、その画像データを外部からfetchする
  3. フェッチが成功した場合、その画像をカスタムデザインに埋め込みOGP画像として返す
  4. フェッチが失敗した場合や該当画像が存在しない場合は404エラーを返す

今回問題だったのは2である。 最初は以下のように環境変数VERCEL_URL使ってURLを作成していた。

const url = `https://${env.VERCEL_URL || "http://localhost:3000"}${path}`;

デプロイ先のVercelにはシステム環境変数を自動で設定してくれる便利な機能がある参考)。 この中にVERCEL_URLいう環境変数があり、これが本番環境のURLだと思っていたhttps://kkhys.meのような)。

しかし、実際は「デプロイメントURL」であり、デプロイごとに生成されるURLだったhttps://xxx.vercel.appいうような)。 別オリジンのURLをfetchしていたため、CORSポリシー違反でエラーが発生したというわけである。

つまり、本番環境ではVERCEL_URL使わないようにすれば解決する。

const baseUrl =
env.VERCEL_ENV === "production"
? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`
: env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";

VERCEL_ENV実行環境を識別し、本番環境であればVERCEL_PROJECT_PRODUCTION_URLカスタムドメインを取得する。

これで、

  • 本番環境 → https://kkhys.me
  • プレビュー環境 → https://xxx.vercel.app
  • ローカル環境 → http://localhost:3000

正しくベースURLとして定義される。

結果、OGP画像も問題なく取得可能になった。

今後追加したい機能

写真の追加は選別も含めると意外と手間がかかるため、段階的に進める予定。 それとは別に、以下の機能を実装していきたい。

  • トップページに写真のカルーセルを表示させる
  • 写真詳細ページに他の写真のカルーセルを表示させる
  • モーダルをリロードせずとも、写真詳細ページに飛べるようにする(良い感じのUIが思い付かない)
  • 画像枚数が増えてきたら、無限スクロール的なUIにする
  • タグ付け機能を追加する
  • カメラやレンズなどのフィルタリング機能を追加する
  • 撮影日時でソートできるようにする
  • EXIFデータを入力するのが面倒すぎるので、写真をアップロードしたらフォーマットされたEXIFデータが出力されるジェネレータを作る
  • 表示方法を選択可能にする(Flow or Thumbs)
  • (コメント機能を追加する)
  • (Goodボタンを追加する)