Use Cases

Profil utilisateur avec Claude Code

Découvrez profil utilisateur avec Claude Code. Conseils pratiques et exemples de code inclus.

ユーザープロフィール機能をClaude Codeで構築する

ユーザープロフィールはSNS、SaaS、コミュニティサイトなど多くのアプリに必要な機能です。Claude Codeを使えば、アバターアップロード、プロフィールEdit、公開ページを含む完全な機能を効率的に実装できます。

データモデル

> ユーザープロフィール機能を作って。
> アバター画像のアップロード、表示名・自己紹介・SNSリンクのEdit、
> 公開プロフィールページを実装して。
// src/types/profile.ts
export interface UserProfile {
  id: string;
  userId: string;
  displayName: string;
  bio: string;
  avatarUrl?: string;
  location?: string;
  website?: string;
  socialLinks: {
    twitter?: string;
    github?: string;
    linkedin?: string;
  };
  isPublic: boolean;
  createdAt: Date;
  updatedAt: Date;
}

プロフィール更新API

// src/app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

const profileSchema = z.object({
  displayName: z.string().min(1).max(50),
  bio: z.string().max(500).optional(),
  location: z.string().max(100).optional(),
  website: z.string().url().optional().or(z.literal('')),
  socialLinks: z.object({
    twitter: z.string().optional(),
    github: z.string().optional(),
    linkedin: z.string().optional(),
  }).optional(),
  isPublic: z.boolean().optional(),
});

export async function PUT(request: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
  }

  const body = await request.json();
  const validated = profileSchema.parse(body);

  const profile = await prisma.profile.upsert({
    where: { userId: session.user.id },
    update: { ...validated, updatedAt: new Date() },
    create: { ...validated, userId: session.user.id },
  });

  return NextResponse.json(profile);
}

export async function GET(request: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
  }

  const profile = await prisma.profile.findUnique({
    where: { userId: session.user.id },
  });

  return NextResponse.json(profile);
}

アバターアップロード

// src/app/api/profile/avatar/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { uploadToS3 } from '@/lib/storage';
import sharp from 'sharp';

export async function POST(request: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get('avatar') as File;

  if (!file) {
    return NextResponse.json({ error: 'ファイルが必要です' }, { status: 400 });
  }

  // ファイルサイズチェック(5MB上限)
  if (file.size > 5 * 1024 * 1024) {
    return NextResponse.json({ error: 'ファイルサイズは5MB以下にしてください' }, { status: 400 });
  }

  const buffer = Buffer.from(await file.arrayBuffer());

  // 画像をリサイズ・最適化
  const optimized = await sharp(buffer)
    .resize(256, 256, { fit: 'cover' })
    .webp({ quality: 80 })
    .toBuffer();

  const key = `avatars/${session.user.id}.webp`;
  const url = await uploadToS3(optimized, key, 'image/webp');

  await prisma.profile.update({
    where: { userId: session.user.id },
    data: { avatarUrl: url },
  });

  return NextResponse.json({ avatarUrl: url });
}

プロフィールEditフォーム

// src/components/ProfileForm.tsx
'use client';
import { useState, useRef } from 'react';
import { UserProfile } from '@/types/profile';

export function ProfileForm({ profile }: { profile: UserProfile }) {
  const [form, setForm] = useState(profile);
  const [saving, setSaving] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append('avatar', file);

    const res = await fetch('/api/profile/avatar', { method: 'POST', body: formData });
    const data = await res.json();
    setForm((prev) => ({ ...prev, avatarUrl: data.avatarUrl }));
  };

  const handleSave = async () => {
    setSaving(true);
    await fetch('/api/profile', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    setSaving(false);
  };

  return (
    <div className="max-w-2xl mx-auto space-y-6">
      <div className="flex items-center gap-6">
        <div
          onClick={() => fileInputRef.current?.click()}
          className="w-24 h-24 rounded-full bg-gray-200 overflow-hidden cursor-pointer hover:opacity-80"
        >
          {form.avatarUrl ? (
            <img src={form.avatarUrl} alt="アバター" className="w-full h-full object-cover" />
          ) : (
            <div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">+</div>
          )}
        </div>
        <input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
        <div>
          <p className="font-medium dark:text-white">プロフィール画像</p>
          <p className="text-sm text-gray-500">JPG, PNG, WebP(最大5MB)</p>
        </div>
      </div>

      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1 dark:text-gray-300">表示名</label>
          <input
            value={form.displayName}
            onChange={(e) => setForm({ ...form, displayName: e.target.value })}
            className="w-full border rounded-lg px-4 py-2 dark:bg-gray-800 dark:border-gray-700"
          />
        </div>
        <div>
          <label className="block text-sm font-medium mb-1 dark:text-gray-300">自己紹介</label>
          <textarea
            value={form.bio}
            onChange={(e) => setForm({ ...form, bio: e.target.value })}
            className="w-full border rounded-lg px-4 py-2 h-24 dark:bg-gray-800 dark:border-gray-700"
            maxLength={500}
          />
          <p className="text-xs text-gray-400 mt-1">{form.bio.length}/500</p>
        </div>
        <button onClick={handleSave} disabled={saving} className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50">
          {saving ? '保存中...' : '保存'}
        </button>
      </div>
    </div>
  );
}

関連記事

画像アップロードの詳細はファイルアップロード実装、バリデーションの設計はフォームバリデーションも参考にしてください。

画像処理ライブラリsharpの公式ドキュメント(sharp.pixelplumbing.com)もあわせてご確認ください。

#Claude Code #プロフィール #ユーザー管理 #画像アップロード #React