온라인 포트폴리오 쇼케이스 빌더 - 프로젝트 갤러리 & 스킬셋 시각화
portfolio-showcase-builder
문제 설명
🎨 온라인 포트폴리오 쇼케이스 빌더
개발자, 디자이너, 크리에이터를 위한 인터랙티브 포트폴리오 빌더를 만듭니다. 단순한 이력서가 아닌, 프로젝트를 시각적으로 소개하고 스킬셋을 차트로 표현하며, 방문자가 프로젝트에 '좋아요'를 남길 수 있는 현대적인 포트폴리오 플랫폼입니다.
필수 기능
- 📂 프로젝트 관리: 프로젝트 제목, 설명, 기술 스택, 이미지, 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에게 보내면 됩니다. 복사 버튼을 눌러 바로 사용하세요.
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 페이지가 정상 표시됩니다.
포트폴리오 프로젝트와 스킬셋을 저장할 데이터베이스 테이블을 만듭니다.
다음 명령으로 마이그레이션 파일을 생성하세요:
```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 모델이 준비됩니다. 데이터베이스 구조가 완성되어 프로젝트와 스킬 데이터를 저장할 수 있습니다.
샘플 데이터를 시더(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개가 데이터베이스에 추가됩니다. 개발 중 실제 데이터를 바로 확인할 수 있습니다.
포트폴리오 메인 페이지를 만듭니다. 프로젝트 갤러리와 스킬셋 차트를 표시하는 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에 접속하면 프로젝트 갤러리가 그리드로 표시되고, 기술 스택 필터 버튼이 작동하는지 확인하세요.
기대 결과: 포트폴리오 메인 페이지가 생성되고, 프로젝트 갤러리가 그리드 레이아웃으로 표시됩니다. 기술 스택 필터를 클릭하면 프로젝트가 필터링됩니다. 스킬셋 섹션은 아직 빈 상태입니다.
스킬셋을 시각적으로 표현하기 위해 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 막대 그래프가 추가되어, 각 스킬의 숙련도가 시각적으로 표현됩니다. 차트가 반응형으로 작동하여 화면 크기에 맞게 조정됩니다.
프로젝트에 '좋아요' 기능을 추가합니다. 방문자가 프로젝트 카드에서 하트 버튼을 클릭하면 좋아요 수가 증가하고, 중복 클릭은 세션 기반으로 방지합니다.
먼저 컨트롤러에 좋아요 메서드를 추가 (`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가 자동으로 상태를 업데이트합니다.
프로젝트 상세 정보를 보여주는 모달을 추가합니다. 프로젝트 카드를 클릭하면 모달이 열리고, 더 큰 이미지와 전체 설명, 링크를 표시합니다.
`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)
로그인 후 제출아직 제출된 작품이 없습니다. 첫 번째 도전자가 되어보세요!