온라인 포트폴리오 쇼케이스 빌더 - 프로젝트 갤러리 & 스킬셋 시각화 - 바이브코딩 레시피
중급 PHP + JavaScript Laravel + React + Inertia.js AI: Claude

온라인 포트폴리오 쇼케이스 빌더 - 프로젝트 갤러리 & 스킬셋 시각화

리포지토리명 portfolio-showcase-builder
조회수 96 0건 제출

문제 설명

🎨 온라인 포트폴리오 쇼케이스 빌더

개발자, 디자이너, 크리에이터를 위한 인터랙티브 포트폴리오 빌더를 만듭니다. 단순한 이력서가 아닌, 프로젝트를 시각적으로 소개하고 스킬셋을 차트로 표현하며, 방문자가 프로젝트에 '좋아요'를 남길 수 있는 현대적인 포트폴리오 플랫폼입니다.

필수 기능

  • 📂 프로젝트 관리: 프로젝트 제목, 설명, 기술 스택, 이미지, GitHub/라이브 링크 등록
  • 🖼️ 갤러리 뷰: 그리드 레이아웃으로 프로젝트 썸네일 표시, 클릭 시 상세 모달
  • 💡 스킬셋 차트: 본인의 기술 스택을 막대 그래프/레이더 차트로 시각화 (예: React 90%, Laravel 85%, Figma 70%)
  • ❤️ 좋아요 시스템: 방문자가 마음에 드는 프로젝트에 좋아요 클릭 (중복 방지: IP 또는 세션 기반)
  • 🔍 필터링: 기술 스택별로 프로젝트 필터링 (예: "React", "Laravel", "Vue" 태그 클릭)
  • 📱 반응형 디자인: 모바일/태블릿/데스크톱 모두 최적화

선택 추가 기능 (보너스)

  • 🌗 다크모드: 라이트/다크 테마 토글
  • 📊 방문자 통계: 각 프로젝트 조회수 표시
  • 🔗 공유 버튼: 프로젝트 링크를 SNS로 공유
  • 🎬 애니메이션: 프로젝트 카드 호버 시 부드러운 확대/그림자 효과
  • 📧 연락 폼: 방문자가 메시지를 남길 수 있는 간단한 컨택 폼

이 프로젝트는 Laravel 13.x + Inertia.js + React 19로 구축하며, Eloquent ORM으로 프로젝트/스킬 데이터를 관리하고, React 컴포넌트로 동적 UI를 구현합니다. Chart.js 또는 Recharts로 스킬셋 차트를 그리고, Tailwind CSS로 현대적인 디자인을 완성합니다.

제약 조건

  • • Laravel 13.x + Inertia.js + React 19 사용 (모노리스 풀스택)
  • • npx create laravel 또는 Laravel Breeze (React + Inertia) starter kit으로 프로젝트 초기화
  • • SQLite 데이터베이스 사용 (개발 환경)
  • • Tailwind CSS로 스타일링
  • • Chart.js 또는 Recharts로 스킬셋 차트 시각화
  • • 프로젝트 이미지는 public/storage 또는 외부 URL 사용
  • • 좋아요는 세션 기반 중복 방지 (로그인 불필요)
  • • 반응형 그리드 레이아웃 (Tailwind grid 활용)
  • • 모달은 React state로 관리 (Headless UI 또는 직접 구현)
  • • 필터링은 클라이언트 사이드에서 처리 (useState + filter)

프롬프트 레시피

아래 프롬프트를 순서대로 AI에게 보내면 됩니다. 복사 버튼을 눌러 바로 사용하세요.

1
Step 1
mkdir portfolio-showcase-builder && cd portfolio-showcase-builder

이 폴더에서 Laravel 13.x + Inertia.js + React 19 프로젝트를 시작합니다.

다음 명령으로 Laravel Breeze (React + Inertia) starter kit을 설치하세요:
```bash
composer create-project laravel/laravel .
composer require laravel/breeze --dev
php artisan breeze:install react
npm install
npm run dev
```

데이터베이스는 SQLite로 설정합니다. `.env` 파일을 다음과 같이 수정:
```
DB_CONNECTION=sqlite
# DB_HOST, DB_PORT, DB_DATABASE 주석 처리
```

`database/database.sqlite` 파일을 생성하고 마이그레이션 실행:
```bash
touch database/database.sqlite
php artisan migrate
```

Tailwind CSS가 이미 설정되어 있으므로 바로 사용 가능합니다.

프로젝트 구조를 확인하고, `resources/js/Pages/Welcome.jsx`를 열어 Inertia + React가 정상 작동하는지 확인하세요. `php artisan serve`로 서버를 실행하고 브라우저에서 http://localhost:8000에 접속해 "Welcome" 페이지가 보이면 성공입니다.

기대 결과: Laravel 13.x + Breeze (React + Inertia) 프로젝트가 생성되고, SQLite 데이터베이스가 설정되며, 개발 서버가 실행됩니다. 브라우저에서 기본 Welcome 페이지가 정상 표시됩니다.

2
Step 2
포트폴리오 프로젝트와 스킬셋을 저장할 데이터베이스 테이블을 만듭니다.

다음 명령으로 마이그레이션 파일을 생성하세요:
```bash
php artisan make:migration create_projects_table
php artisan make:migration create_skills_table
php artisan make:migration create_project_likes_table
```

각 마이그레이션 파일을 다음과 같이 작성:

**projects 테이블** (`database/migrations/xxxx_create_projects_table.php`):
```php
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('description');
    $table->json('tech_stack'); // ["React", "Laravel", "Tailwind"]
    $table->string('image_url')->nullable();
    $table->string('github_url')->nullable();
    $table->string('live_url')->nullable();
    $table->integer('likes_count')->default(0);
    $table->integer('views_count')->default(0);
    $table->timestamps();
});
```

**skills 테이블** (`database/migrations/xxxx_create_skills_table.php`):
```php
Schema::create('skills', function (Blueprint $table) {
    $table->id();
    $table->string('name'); // 예: "React", "Laravel"
    $table->integer('proficiency'); // 0~100
    $table->timestamps();
});
```

**project_likes 테이블** (세션 기반 중복 방지용):
```php
Schema::create('project_likes', function (Blueprint $table) {
    $table->id();
    $table->foreignId('project_id')->constrained()->onDelete('cascade');
    $table->string('session_id'); // 방문자 세션 ID
    $table->timestamps();
    $table->unique(['project_id', 'session_id']); // 중복 방지
});
```

마이그레이션 실행:
```bash
php artisan migrate
```

다음으로 Eloquent 모델을 생성:
```bash
php artisan make:model Project
php artisan make:model Skill
php artisan make:model ProjectLike
```

각 모델 파일(`app/Models/Project.php` 등)에서 `$fillable` 속성을 설정하고, `Project` 모델에 `tech_stack`을 JSON으로 캐스팅:
```php
protected $fillable = ['title', 'description', 'tech_stack', 'image_url', 'github_url', 'live_url', 'likes_count', 'views_count'];
protected $casts = ['tech_stack' => 'array'];
```

`Skill` 모델:
```php
protected $fillable = ['name', 'proficiency'];
```

`ProjectLike` 모델:
```php
protected $fillable = ['project_id', 'session_id'];
```

기대 결과: projects, skills, project_likes 테이블이 생성되고, Eloquent 모델이 준비됩니다. 데이터베이스 구조가 완성되어 프로젝트와 스킬 데이터를 저장할 수 있습니다.

3
Step 3
샘플 데이터를 시더(Seeder)로 생성하여 개발 중 바로 테스트할 수 있도록 합니다.

다음 명령으로 시더를 생성:
```bash
php artisan make:seeder ProjectSeeder
php artisan make:seeder SkillSeeder
```

**ProjectSeeder** (`database/seeders/ProjectSeeder.php`):
```php
use App\Models\Project;

public function run()
{
    Project::create([
        'title' => 'E-commerce 쇼핑몰',
        'description' => 'Laravel과 React로 만든 풀스택 쇼핑몰. 상품 관리, 장바구니, 결제 연동 포함.',
        'tech_stack' => ['Laravel', 'React', 'Stripe', 'Tailwind'],
        'image_url' => 'https://via.placeholder.com/600x400?text=E-commerce',
        'github_url' => 'https://github.com/example/ecommerce',
        'live_url' => 'https://example-ecommerce.com',
    ]);

    Project::create([
        'title' => '실시간 채팅 앱',
        'description' => 'WebSocket 기반 실시간 채팅 애플리케이션. 그룹 채팅, 이미지 공유 기능.',
        'tech_stack' => ['Vue', 'Node.js', 'Socket.io', 'MongoDB'],
        'image_url' => 'https://via.placeholder.com/600x400?text=Chat+App',
        'github_url' => 'https://github.com/example/chat-app',
        'live_url' => null,
    ]);

    Project::create([
        'title' => 'AI 이미지 생성기',
        'description' => 'OpenAI DALL-E API를 활용한 이미지 생성 서비스. 프롬프트 입력으로 AI 아트 생성.',
        'tech_stack' => ['Next.js', 'OpenAI API', 'Vercel', 'Tailwind'],
        'image_url' => 'https://via.placeholder.com/600x400?text=AI+Image+Generator',
        'github_url' => 'https://github.com/example/ai-image-gen',
        'live_url' => 'https://ai-image-gen.vercel.app',
    ]);
}
```

**SkillSeeder** (`database/seeders/SkillSeeder.php`):
```php
use App\Models\Skill;

public function run()
{
    $skills = [
        ['name' => 'React', 'proficiency' => 90],
        ['name' => 'Laravel', 'proficiency' => 85],
        ['name' => 'Vue.js', 'proficiency' => 75],
        ['name' => 'Node.js', 'proficiency' => 80],
        ['name' => 'Tailwind CSS', 'proficiency' => 95],
        ['name' => 'Figma', 'proficiency' => 70],
    ];

    foreach ($skills as $skill) {
        Skill::create($skill);
    }
}
```

`DatabaseSeeder.php`에서 시더를 호출:
```php
public function run()
{
    $this->call([
        ProjectSeeder::class,
        SkillSeeder::class,
    ]);
}
```

시더 실행:
```bash
php artisan db:seed
```

데이터베이스에 샘플 프로젝트 3개와 스킬 6개가 추가되었는지 확인하세요 (Tinker로 확인 가능: `php artisan tinker` → `Project::all()`).

기대 결과: 샘플 프로젝트 3개와 스킬 6개가 데이터베이스에 추가됩니다. 개발 중 실제 데이터를 바로 확인할 수 있습니다.

4
Step 4
포트폴리오 메인 페이지를 만듭니다. 프로젝트 갤러리와 스킬셋 차트를 표시하는 Inertia 페이지를 구현합니다.

먼저 컨트롤러를 생성:
```bash
php artisan make:controller PortfolioController
```

**PortfolioController** (`app/Http/Controllers/PortfolioController.php`):
```php
use App\Models\Project;
use App\Models\Skill;
use Inertia\Inertia;

public function index()
{
    $projects = Project::orderBy('created_at', 'desc')->get();
    $skills = Skill::orderBy('proficiency', 'desc')->get();

    return Inertia::render('Portfolio/Index', [
        'projects' => $projects,
        'skills' => $skills,
    ]);
}
```

라우트 등록 (`routes/web.php`):
```php
use App\Http\Controllers\PortfolioController;

Route::get('/', [PortfolioController::class, 'index'])->name('portfolio.index');
```

이제 React 페이지 컴포넌트를 만듭니다. `resources/js/Pages/Portfolio/Index.jsx` 파일 생성:
```jsx
import { Head } from '@inertiajs/react';
import { useState } from 'react';

export default function Index({ projects, skills }) {
    const [selectedTech, setSelectedTech] = useState(null);

    // 필터링된 프로젝트
    const filteredProjects = selectedTech
        ? projects.filter(p => p.tech_stack.includes(selectedTech))
        : projects;

    // 모든 기술 스택 추출 (중복 제거)
    const allTechs = [...new Set(projects.flatMap(p => p.tech_stack))];

    return (
        <>
            <Head title="내 포트폴리오" />
            <div className="min-h-screen bg-gray-50">
                {/* 헤더 */}
                <header className="bg-white shadow-sm">
                    <div className="max-w-7xl mx-auto px-4 py-6">
                        <h1 className="text-3xl font-bold text-gray-900">🎨 내 포트폴리오</h1>
                        <p className="text-gray-600 mt-2">프로젝트 갤러리 & 스킬셋 시각화</p>
                    </div>
                </header>

                <main className="max-w-7xl mx-auto px-4 py-8">
                    {/* 기술 스택 필터 */}
                    <div className="mb-8">
                        <h2 className="text-xl font-semibold mb-4">기술 스택 필터</h2>
                        <div className="flex flex-wrap gap-2">
                            <button
                                onClick={() => setSelectedTech(null)}
                                className={`px-4 py-2 rounded-full text-sm font-medium transition ${
                                    !selectedTech
                                        ? 'bg-indigo-600 text-white'
                                        : 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
                                }`}
                            >
                                전체
                            </button>
                            {allTechs.map(tech => (
                                <button
                                    key={tech}
                                    onClick={() => setSelectedTech(tech)}
                                    className={`px-4 py-2 rounded-full text-sm font-medium transition ${
                                        selectedTech === tech
                                            ? 'bg-indigo-600 text-white'
                                            : 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
                                    }`}
                                >
                                    {tech}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* 프로젝트 갤러리 */}
                    <section className="mb-12">
                        <h2 className="text-2xl font-bold mb-6">📂 프로젝트</h2>
                        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                            {filteredProjects.map(project => (
                                <div key={project.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
                                    <img src={project.image_url} alt={project.title} className="w-full h-48 object-cover" />
                                    <div className="p-4">
                                        <h3 className="text-lg font-semibold text-gray-900">{project.title}</h3>
                                        <p className="text-gray-600 text-sm mt-2">{project.description}</p>
                                        <div className="flex flex-wrap gap-2 mt-3">
                                            {project.tech_stack.map(tech => (
                                                <span key={tech} className="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
                                                    {tech}
                                                </span>
                                            ))}
                                        </div>
                                        <div className="flex items-center justify-between mt-4">
                                            <span className="text-gray-500 text-sm">❤️ {project.likes_count}</span>
                                            <div className="flex gap-2">
                                                {project.github_url && (
                                                    <a href={project.github_url} target="_blank" className="text-indigo-600 hover:underline text-sm">
                                                        GitHub
                                                    </a>
                                                )}
                                                {project.live_url && (
                                                    <a href={project.live_url} target="_blank" className="text-indigo-600 hover:underline text-sm">
                                                        Live
                                                    </a>
                                                )}
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            ))}
                        </div>
                    </section>

                    {/* 스킬셋 섹션 (다음 단계에서 차트 추가 예정) */}
                    <section>
                        <h2 className="text-2xl font-bold mb-6">💡 스킬셋</h2>
                        <div className="bg-white rounded-lg shadow-md p-6">
                            <p className="text-gray-600">차트는 다음 단계에서 추가됩니다.</p>
                        </div>
                    </section>
                </main>
            </div>
        </>
    );
}
```

서버를 재시작하고 브라우저에서 http://localhost:8000에 접속하면 프로젝트 갤러리가 그리드로 표시되고, 기술 스택 필터 버튼이 작동하는지 확인하세요.

기대 결과: 포트폴리오 메인 페이지가 생성되고, 프로젝트 갤러리가 그리드 레이아웃으로 표시됩니다. 기술 스택 필터를 클릭하면 프로젝트가 필터링됩니다. 스킬셋 섹션은 아직 빈 상태입니다.

5
Step 5
스킬셋을 시각적으로 표현하기 위해 Chart.js를 사용하여 막대 그래프를 추가합니다.

먼저 Chart.js와 react-chartjs-2를 설치:
```bash
npm install chart.js react-chartjs-2
```

`resources/js/Pages/Portfolio/Index.jsx`를 수정하여 스킬셋 차트를 추가:
```jsx
import { Bar } from 'react-chartjs-2';
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';

// Chart.js 등록
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);

export default function Index({ projects, skills }) {
    // ... (기존 코드 유지)

    // 차트 데이터 준비
    const chartData = {
        labels: skills.map(s => s.name),
        datasets: [
            {
                label: '숙련도 (%)',
                data: skills.map(s => s.proficiency),
                backgroundColor: 'rgba(79, 70, 229, 0.6)', // Indigo
                borderColor: 'rgba(79, 70, 229, 1)',
                borderWidth: 1,
            },
        ],
    };

    const chartOptions = {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
            legend: {
                display: false,
            },
            title: {
                display: false,
            },
        },
        scales: {
            y: {
                beginAtZero: true,
                max: 100,
            },
        },
    };

    return (
        <>
            {/* ... (기존 헤더, 필터, 갤러리 코드 유지) */}

            {/* 스킬셋 차트 */}
            <section>
                <h2 className="text-2xl font-bold mb-6">💡 스킬셋</h2>
                <div className="bg-white rounded-lg shadow-md p-6">
                    <div className="h-80">
                        <Bar data={chartData} options={chartOptions} />
                    </div>
                </div>
            </section>
        </>
    );
}
```

`npm run dev`로 Vite를 재시작하고 브라우저를 새로고침하면 스킬셋 막대 그래프가 표시됩니다. 각 스킬의 숙련도가 0~100% 범위로 시각화됩니다.

기대 결과: 스킬셋 섹션에 Chart.js 막대 그래프가 추가되어, 각 스킬의 숙련도가 시각적으로 표현됩니다. 차트가 반응형으로 작동하여 화면 크기에 맞게 조정됩니다.

6
Step 6
프로젝트에 '좋아요' 기능을 추가합니다. 방문자가 프로젝트 카드에서 하트 버튼을 클릭하면 좋아요 수가 증가하고, 중복 클릭은 세션 기반으로 방지합니다.

먼저 컨트롤러에 좋아요 메서드를 추가 (`app/Http/Controllers/PortfolioController.php`):
```php
use App\Models\ProjectLike;
use Illuminate\Http\Request;

public function likeProject(Request $request, Project $project)
{
    $sessionId = $request->session()->getId();

    // 이미 좋아요를 눌렀는지 확인
    $existingLike = ProjectLike::where('project_id', $project->id)
        ->where('session_id', $sessionId)
        ->first();

    if ($existingLike) {
        return back()->with('error', '이미 좋아요를 눌렀습니다.');
    }

    // 좋아요 추가
    ProjectLike::create([
        'project_id' => $project->id,
        'session_id' => $sessionId,
    ]);

    // 좋아요 카운트 증가
    $project->increment('likes_count');

    return back()->with('success', '좋아요!');
}
```

라우트 추가 (`routes/web.php`):
```php
Route::post('/projects/{project}/like', [PortfolioController::class, 'likeProject'])->name('projects.like');
```

이제 React 페이지에서 좋아요 버튼을 추가합니다. `resources/js/Pages/Portfolio/Index.jsx` 수정:
```jsx
import { useForm } from '@inertiajs/react';

export default function Index({ projects, skills }) {
    // ... (기존 코드 유지)

    return (
        <>
            {/* ... (기존 헤더, 필터 유지) */}

            {/* 프로젝트 갤러리 */}
            <section className="mb-12">
                <h2 className="text-2xl font-bold mb-6">📂 프로젝트</h2>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                    {filteredProjects.map(project => (
                        <ProjectCard key={project.id} project={project} />
                    ))}
                </div>
            </section>

            {/* ... (스킬셋 차트 유지) */}
        </>
    );
}

// 프로젝트 카드 컴포넌트 분리
function ProjectCard({ project }) {
    const { post, processing } = useForm();

    const handleLike = () => {
        post(route('projects.like', project.id), {
            preserveScroll: true,
        });
    };

    return (
        <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
            <img src={project.image_url} alt={project.title} className="w-full h-48 object-cover" />
            <div className="p-4">
                <h3 className="text-lg font-semibold text-gray-900">{project.title}</h3>
                <p className="text-gray-600 text-sm mt-2">{project.description}</p>
                <div className="flex flex-wrap gap-2 mt-3">
                    {project.tech_stack.map(tech => (
                        <span key={tech} className="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
                            {tech}
                        </span>
                    ))}
                </div>
                <div className="flex items-center justify-between mt-4">
                    <button
                        onClick={handleLike}
                        disabled={processing}
                        className="flex items-center gap-1 text-red-500 hover:text-red-600 transition disabled:opacity-50"
                    >
                        ❤️ <span className="text-sm">{project.likes_count}</span>
                    </button>
                    <div className="flex gap-2">
                        {project.github_url && (
                            <a href={project.github_url} target="_blank" className="text-indigo-600 hover:underline text-sm">
                                GitHub
                            </a>
                        )}
                        {project.live_url && (
                            <a href={project.live_url} target="_blank" className="text-indigo-600 hover:underline text-sm">
                                Live
                            </a>
                        )}
                    </div>
                </div>
            </div>
        </div>
    );
}
```

브라우저에서 프로젝트 카드의 하트 버튼을 클릭하면 좋아요 수가 증가하고, 같은 세션에서 다시 클릭하면 중복 방지 메시지가 표시됩니다 (flash 메시지는 선택적으로 추가 가능).

기대 결과: 프로젝트 카드에 좋아요 버튼이 추가되고, 클릭 시 좋아요 수가 증가합니다. 세션 기반으로 중복 좋아요가 방지됩니다. 페이지 새로고침 없이 Inertia가 자동으로 상태를 업데이트합니다.

7
Step 7
프로젝트 상세 정보를 보여주는 모달을 추가합니다. 프로젝트 카드를 클릭하면 모달이 열리고, 더 큰 이미지와 전체 설명, 링크를 표시합니다.

`resources/js/Pages/Portfolio/Index.jsx`에 모달 상태와 컴포넌트를 추가:
```jsx
import { useState } from 'react';

export default function Index({ projects, skills }) {
    const [selectedTech, setSelectedTech] = useState(null);
    const [selectedProject, setSelectedProject] = useState(null); // 모달용 상태

    const filteredProjects = selectedTech
        ? projects.filter(p => p.tech_stack.includes(selectedTech))
        : projects;

    const allTechs = [...new Set(projects.flatMap(p => p.tech_stack))];

    return (
        <>
            {/* ... (기존 헤더, 필터 유지) */}

            {/* 프로젝트 갤러리 */}
            <section className="mb-12">
                <h2 className="text-2xl font-bold mb-6">📂 프로젝트</h2>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                    {filteredProjects.map(project => (
                        <ProjectCard
                            key={project.id}
                            project={project}
                            onClick={() => setSelectedProject(project)}
                        />
                    ))}
                </div>
            </section>

            {/* ... (스킬셋 차트 유지) */}

            {/* 프로젝트 상세 모달 */}
            {selectedProject && (
                <ProjectModal project={selectedProject} onClose={() => setSelectedProject(null)} />
            )}
        </>
    );
}

function ProjectCard({ project, onClick }) {
    const { post, processing } = useForm();

    const handleLike = (e) => {
        e.stopPropagation(); // 카드 클릭 이벤트 전파 방지
        post(route('projects.like', project.id), {
            preserveScroll: true,
        });
    };

    return (
        <div
            onClick={onClick}
            className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition cursor-pointer"
        >
            <img src={project.image_url} alt={project.title} className="w-full h-48 object-cover" />
            <div className="p-4">
                <h3 className="text-lg font-semibold text-gray-900">{project.title}</h3>
                <p className="text-gray-600 text-sm mt-2 line-clamp-2">{project.description}</p>
                <div className="flex flex-wrap gap-2 mt-3">
                    {project.tech_stack.map(tech => (
                        <span key={tech} className="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
                            {tech}
                        </span>
                    ))}
                </div>
                <div className="flex items-center justify-between mt-4">
                    <button
                        onClick={handleLike}
                        disabled={processing}
                        className="flex items-center gap-1 text-red-500 hover:text-red-600 transition disabled:opacity-50"
                    >
                        ❤️ <span className="text-sm">{project.likes_count}</span>
                    </button>
                    <span className="text-gray-400 text-xs">클릭하여 자세히 보기</span>
                </div>
            </div>
        </div>
    );
}

function ProjectModal({ project, onClose }) {
    return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" onClick={onClose}>
            <div
                className="bg-white rounded-lg shadow-2xl max-w-2xl w-full mx-4 overflow-hidden"
                onClick={(e) => e.stopPropagation()}
            >
                <div className="relative">
                    <img src={project.image_url} alt={project.title} className="w-full h-64 object-cover" />
                    <button
                        onClick={onClose}
                        className="absolute top-4 right-4 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100 transition"
                    >
                        ✕
                    </button>
                </div>
                <div className="p-6">
                    <h2 className="text-2xl font-bold text-gray-900">{project.title}</h2>
                    <p className="text-gray-600 mt-3">{project.description}</p>
                    <div className="flex flex-wrap gap-2 mt-4">
                        {project.tech_stack.map(tech => (
                            <span key={tech} className="px-3 py-1 bg-indigo-100 text-indigo-800 text-sm rounded-full">
                                {tech}
                            </span>
                        ))}
                    </div>
                    <div className="flex gap-4 mt-6">
                        {project.github_url && (
                            <a
                                href={project.github_url}
                                target="_blank"
                                className="flex-1 bg-gray-800 text-white text-center py-2 rounded-lg hover:bg-gray-700 transition"
                            >
                                GitHub 보기
                            </a>
                        )}
                        {project.live_url && (
                            <a
                                href={project.live_url}
                                target="_blank"
                                className="flex-1 bg-indigo-600 text-white text-center py-2 rounded-lg hover:bg-indigo-700 transition"
                            >
                                라이브 데모
                            </a>
                        )}
                    </div>
                    <div className="mt-4 text-center text-gray-500 text-sm">
                        ❤️ {project.likes_count} · 👁️ {project.views_count} views
                    </div>
                </div>
            </div>
        </div>
    );
}
```

브라우저에서 프로젝트 카드를 클릭하면 모달이 열리고, 모달 바깥이나 닫기 버튼을 클릭하면 모달이 닫힙니다. 모달에서 GitHub/Live 링크를 클릭하면 새 탭에서 열립니다.

기대 결과: 프로젝트 카드를 클릭하면 상세 정보를 보여주는 모달이 열립니다. 모달에는 큰 이미지, 전체 설명, 기술 스택, GitHub/Live 링크, 좋아요/조회수가 표시됩니다. 모달은 ESC 키나 바깥 클릭으로 닫을 수 있습니다.

제출된 작품 (0)

로그인 후 제출

아직 제출된 작품이 없습니다. 첫 번째 도전자가 되어보세요!

댓글 (0)

로그인 후 댓글을 남길 수 있습니다.
아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!