💭

画像投稿サイト構築してみた

2025/02/10に公開

はじめに

個人的な興味があり、画像投稿サイトを作成していきます


最終構築状態

プロジェクトの環境構築

1. Next.js プロジェクトの作成

ターミナルで以下のコマンドを実行して、TypeScript ベースの Next.js プロジェクトを作成します。

npx create-next-app@13 --typescript manga-site
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @
Creating a new Next.js app in /Users/manga-app.
cd manga-site

2. Tailwind CSS の導入

Tailwind CSS の公式ガイドに沿ってセットアップします。
参考: Tailwind CSS with Next.js

以下は主要な設定ファイルの例です。

globals.css

app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

Supabase の設定

  1. Supabase プロジェクトの作成
    Supabase ダッシュボード にアクセスし、新規プロジェクトを作成します。

  2. データベースの設定
    Supabase の SQL エディタで、以下の SQL を実行して posts テーブルを作成します。

    sql
    CREATE TABLE posts (
      id serial PRIMARY KEY,
      images text[] NOT NULL,
      created_at timestamptz DEFAULT now()
    );
    

    ポリシーも設定します。

    sql
    create policy "Allow public uploads to manga bucket" on storage.objects
    for insert
    with check (bucket_id = 'manga');
    
  3. Storage の設定
    Supabase Storage で「manga」という名前のバケットを作成し、パブリックアクセスを有効にします。

  4. API キーの取得と環境変数の設定
    Supabase の「Settings」→「API」で、NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY を取得し、プロジェクトルートに .env.local ファイルを作成して以下のように記述します。

    bash
    npm install @supabase/supabase-js
    
    .env.local
    NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
    NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
    
    lib/supabaseClient.ts
    import { createClient } from '@supabase/supabase-js';
    
    const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
    const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
    
    export const supabase = createClient(supabaseUrl, supabaseAnonKey);
    
    

    このファイルは、Supabase のクライアントを初期化するためのコードです。環境変数から Supabase プロジェクトの URL と匿名キーを取得し、createClient 関数でクライアントを作成します。


プロジェクト構成と各ファイルの実装

前提として
この漫画投稿サイトは、以下の機能を持つ予定です。

  • 投稿ページ
    ユーザーが複数枚の画像をアップロードできるフォームを用意。アップロード済みの画像はプレビュー表示され、必要に応じて画像の削除も可能です。

  • ホームページ
    投稿された漫画をカード形式で一覧表示します。各カードにはサムネイルと投稿日時が表示され、カードの右上に削除ボタンがあり、削除操作が行われた場合は画面上から即座に反映されます。

  • 閲覧ページ
    投稿された漫画の画像を1枚ずつ表示し、クリックで次の画像へ切り替えるシンプルな画像ビューアを実装しています。

以下のようなディレクトリ構成で実装を進めます。

my-manga-site/
├── app/
│   ├── layout.tsx       // グローバルレイアウト(Navigation を含む)
│   ├── page.tsx         // ホームページ(投稿一覧)
│   ├── upload/
│   │   └── page.tsx     // 投稿ページ(画像アップロード)
│   └── view/
│       └── [id]/
│           └── page.tsx // 閲覧ページ(画像閲覧)
├── components/
│   ├── Navigation.tsx   // ナビゲーションバー
│   ├── PostCard.tsx     // 投稿カードコンポーネント
│   ├── UploadForm.tsx   // 画像アップロードフォーム
│   └── ImageViewer.tsx  // 画像閲覧用コンポーネント
└── lib/
    └── supabaseClient.ts // Supabase クライアントの初期化

構成により、各機能(投稿、一覧表示、閲覧、ナビゲーションなど)が分かりやすく分離され、メンテナンスや機能拡張がしやすくなっています。

グローバルレイアウトと Navigation

/app/layout.tsx
このファイルは、全ページ共通のレイアウトを定義しています。グローバルCSSの読み込みとともに、Navigation コンポーネントを配置しています。

/app/layout.tsx
import '../app/globals.css';
import Navigation from '@/components/Navigation';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className="bg-gray-50 text-gray-900">
        <Navigation />
        <main className="container mx-auto p-4">
          {children}
        </main>
      </body>
    </html>
  );
}

/components/Navigation.tsx
Navigation コンポーネントは、全ページに共通するナビゲーションバーを実装しています。Next.js の usePathname フックを用いて、現在のページに応じたリンクのスタイルを変更しています。

/components/Navigation.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

const Navigation = () => {
  const pathname = usePathname();

  return (
    <nav className="flex justify-center gap-4 p-4 bg-white shadow mb-4">
      <Link href="/" className={pathname === '/' ? 'text-blue-600' : 'text-gray-600'}>
        Home
      </Link>
      <Link href="/upload" className={pathname === '/upload' ? 'text-blue-600' : 'text-gray-600'}>
        Upload
      </Link>
    </nav>
  );
};

export default Navigation;

ポイントとして

  • クライアントコンポーネント化
    usePathname などのフックはクライアントコンポーネントでのみ使用可能なため、先頭に "use client"; を記述しています。
  • スタイルの切り替え
    現在のパスに応じて、リンクの色を変更してユーザーにどのページにいるかを示します。

ホームページ(投稿一覧)

/app/page.tsx
ホームページでは、Supabase の posts テーブルから投稿データを取得し、各投稿を PostCard コンポーネントとして表示します。投稿がない場合のフォールバック表示も追加できます。

/app/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import PostCard from '@/components/PostCard';

interface Post {
  id: number;
  images: string[];
  created_at: string;
}

export default function Home() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    async function fetchPosts() {
      setLoading(true);
      const { data, error } = await supabase
        .from('posts')
        .select('*')
        .order('created_at', { ascending: false });
      if (error) console.error(error);
      else setPosts(data as Post[]);
      setLoading(false);
    }
    fetchPosts();
  }, []);

  // 削除後に state を更新して即座に反映
  const handleDelete = (id: number) => {
    setPosts((prev) => prev.filter((post) => post.id !== id));
  };

  return (
    <div className="container mx-auto p-4">
      {loading ? (
        <p>Loading...</p>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {posts.map((post) => (
            <PostCard key={post.id} post={post} onDelete={handleDelete} />
          ))}
        </div>
      )}
    </div>
  );
}

ポイントとして

  • データ取得
    useEffect フックを利用して、コンポーネントのマウント時に Supabase からデータを取得します。
  • 状態管理
    投稿データは state で管理し、削除時にはその state を更新して画面に即時反映します。

/components/PostCard.tsx
各投稿をカード形式で表示し、投稿内容(サムネイル画像や投稿日時)を確認できるようにします。また、削除ボタンで投稿を削除できるようにしています。

/components/PostCard.tsx
import Link from 'next/link';
import { supabase } from '@/lib/supabaseClient';

interface PostCardProps {
  post: {
    id: number;
    images: string[];
    created_at: string;
  };
  onDelete?: (id: number) => void;
}

const PostCard: React.FC<PostCardProps> = ({ post, onDelete }) => {
  const handleDelete = async () => {
    if (!confirm("この投稿を削除してよろしいですか?")) return;
    const { error } = await supabase
      .from('posts')
      .delete()
      .eq('id', post.id);
    if (error) {
      console.error("削除エラー:", error);
      alert("削除に失敗しました。");
    } else {
      alert("削除しました。");
      if (onDelete) onDelete(post.id);
    }
  };

  return (
    <div className="relative border rounded-lg overflow-hidden shadow hover:shadow-lg transition cursor-pointer">
      <Link href={`/view/${post.id}`}>
        <div>
          {post.images && post.images.length > 0 ? (
            <img src={post.images[0]} alt="Thumbnail" className="w-full h-48 object-cover" />
          ) : (
            <div className="w-full h-48 bg-gray-300 flex items-center justify-center">No Image</div>
          )}
          <div className="p-4">
            <p className="text-sm text-gray-500">
              {new Date(post.created_at).toLocaleString()}
            </p>
          </div>
        </div>
      </Link>
      <button
        onClick={(e) => { e.stopPropagation(); handleDelete(); }}
        className="absolute top-2 right-2 bg-red-600 text-white rounded p-1 text-xs"
      ></button>
    </div>
  );
};

export default PostCard;

ポイントとして

  • 画像が存在しない場合のフォールバック表示を実装。
  • 削除ボタンはクリックイベントの伝播を止め、投稿詳細ページに遷移しないようにしています。

投稿ページ(画像アップロード)

/app/upload/page.tsx
投稿ページは、ユーザーが画像をアップロードするためのフォームを表示します。ここでは UploadForm コンポーネントを使用しています。

/app/upload/page.tsx
import UploadForm from '@/components/UploadForm';

export default function UploadPage() {
  return (
    <div className="max-w-3xl mx-auto p-4">
      <UploadForm />
    </div>
  );
}

/components/UploadForm.tsx
UploadForm コンポーネントは、以下の機能を実現します。

  • 画像の追加
  • プレビュー表示
  • 画像削除
  • 投稿処理
/components/UploadForm.tsx
'use client';

import React, { useRef, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';

interface ImageUpload {
  file: File;
  preview: string;
}

const UploadForm: React.FC = () => {
  const [imageUploads, setImageUploads] = useState<ImageUpload[]>([]);
  const [uploading, setUploading] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = e.target.files;
    if (selectedFiles) {
      if (selectedFiles.length + imageUploads.length > 100) {
        alert('最大100枚まで選択できます。');
        return;
      }
      const newUploads: ImageUpload[] = [];
      for (let i = 0; i < selectedFiles.length; i++) {
        const file = selectedFiles[i];
        const preview = URL.createObjectURL(file);
        newUploads.push({ file, preview });
      }
      setImageUploads((prev) => [...prev, ...newUploads]);
      if (fileInputRef.current) {
        fileInputRef.current.value = '';
      }
    }
  };

  const handleDeleteImage = (index: number) => {
    setImageUploads((prev) => prev.filter((_, i) => i !== index));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (imageUploads.length === 0) return;
    setUploading(true);
    const uploadedUrls: string[] = [];

    for (let i = 0; i < imageUploads.length; i++) {
      const { file } = imageUploads[i];
      let fileExt = file.name.split('.').pop();
      if (!fileExt) fileExt = 'png';
      const fileName = `${Date.now()}-${i}.${fileExt}`;
      const filePath = `uploads/${fileName}`;
      const { error: uploadError } = await supabase
        .storage
        .from('manga')
        .upload(filePath, file);
      if (uploadError) {
        console.error('Upload error:', uploadError);
        continue;
      }
      const { data } = supabase.storage.from('manga').getPublicUrl(filePath);
      if (!data || !data.publicUrl) {
        console.error('Error getting public URL:', data);
        continue;
      }
      uploadedUrls.push(data.publicUrl);
    }

    // DB に画像の公開 URL 配列を保存
    const { error: dbError } = await supabase
      .from('posts')
      .insert([{ images: uploadedUrls }]);
    if (dbError) {
      console.error('DB insert error:', dbError);
    } else {
      alert('アップロード成功!');
    }
    setUploading(false);
    setImageUploads([]);
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700">画像ファイル</label>
        <div className="flex items-center space-x-2">
          <input
            ref={fileInputRef}
            type="file"
            multiple
            accept="image/*"
            onChange={handleFileChange}
            className="hidden"
          />
          <button
            type="button"
            onClick={() => fileInputRef.current?.click()}
            className="px-4 py-2 bg-green-600 text-white rounded"
          >
            + 画像追加
          </button>
        </div>
      </div>
      {imageUploads.length > 0 && (
        <div className="grid grid-cols-4 gap-4">
          {imageUploads.map((upload, index) => (
            <div key={index} className="relative border p-2 rounded">
              <img
                src={upload.preview}
                alt={`preview-${index}`}
                className="w-full h-32 object-cover rounded"
              />
              <button
                type="button"
                onClick={() => handleDeleteImage(index)}
                className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 text-xs"
              ></button>
            </div>
          ))}
        </div>
      )}
      <button
        type="submit"
        disabled={uploading}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        {uploading ? 'Uploading...' : '投稿する'}
      </button>
    </form>
  );
};

export default UploadForm;

ポイントとして

  • 状態管理
    imageUploads という state で、追加された画像ファイルとプレビューURLを管理しています。
  • ファイル操作
    fileInputRef を使って隠しファイル入力を操作し、ユーザーがボタンを押すとファイル選択ダイアログが表示されます。
  • アップロード処理
    各画像は Supabase Storage にアップロードされ、公開URL を取得して posts テーブルに保存されます。

閲覧ページ(画像閲覧)

/app/view/[id]/page.tsx
投稿データから画像 URL 配列を取得し、ImageViewer コンポーネントに渡します。ここでは、各投稿の詳細ページとして表示されます。

/app/view/[id]/page.tsx
import { supabase } from '@/lib/supabaseClient';
import ImageViewer from '@/components/ImageViewer';

export default async function ViewPost({ params }: { params: { id: string } }) {
  const { id } = params;
  const { data, error } = await supabase
    .from('posts')
    .select('*')
    .eq('id', id)
    .single();

  if (error) {
    return <div>投稿の読み込みに失敗しました。</div>;
  }

  return (
    <div className="max-w-3xl mx-auto">
      <ImageViewer images={data.images} effects={Array(data.images.length).fill('pageFlip')} />
    </div>
  );
}

/components/ImageViewer.tsx
ImageViewer コンポーネントは、取得した画像を1枚ずつ表示し、クリックで次の画像に切り替えるシンプルなビューアです。
ここでは、全画像に同じ演出('pageFlip')を適用しています。

/components/ImageViewer.tsx
'use client';

import { useState } from 'react';

interface ImageViewerProps {
  images: string[];
  effects: string[]; // 現在は全画像に同じ効果(例:"pageFlip")を適用
}

const ImageViewer: React.FC<ImageViewerProps> = ({ images, effects }) => {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [animating, setAnimating] = useState(false);

  const currentEffect = effects && effects[currentIndex] ? effects[currentIndex] : 'pageFlip';

  const triggerTransition = (callback: () => void) => {
    setAnimating(true);
    setTimeout(() => {
      callback();
      setAnimating(false);
    }, 500);
  };

  // クリックで次の画像へ切り替え(最後なら先頭に戻る)
  const handleClick = () => {
    if (animating) return;
    if (currentIndex < images.length - 1) {
      triggerTransition(() => setCurrentIndex(currentIndex + 1));
    } else {
      triggerTransition(() => setCurrentIndex(0));
    }
  };

  return (
    <div
      className="w-full h-screen flex items-center justify-center bg-black overflow-hidden cursor-pointer"
      onClick={handleClick}
    >
      <div className={`relative w-full h-full transition-transform duration-500 ${animating ? `animate-${currentEffect}` : ''}`}>
        <img
          src={images[currentIndex]}
          alt={`Page ${currentIndex + 1}`}
          className="object-contain w-full h-full"
        />
        <div className="absolute inset-0 bg-black opacity-10 pointer-events-none"></div>
      </div>
    </div>
  );
};

export default ImageViewer;

ポイントとして

  • シンプルな切り替え
    クリックイベントを使って画像を次々に切り替え、現在の画像がアニメーション(ここでは 'pageFlip' を適用)しながら切り替わります。

最後に

以上の内容からNext.js プロジェクトからTailwind CSS をセットアップ。さらに Supabase を利用してプロジェクト・テーブル・Storage の設定を完了することで、開発環境を整えました。
次に、グローバルレイアウトに Navigation コンポーネントを読み込むことで、全ページ共通のナビゲーションを実現し、ホームページでは投稿一覧を表示、削除ボタンの操作で即座に画面が更新される仕組みを実装しました。また、投稿ページでは画像の追加、プレビュー、削除が可能な UploadForm コンポーネントを提供し、閲覧ページでは ImageViewer コンポーネントを用いて画像を1枚ずつ表示し、クリック操作で次の画像へ切り替えるシンプルなインターフェースを実装しました。

皆さんの学びになれば幸いです。

Discussion