๐ฎ ์ธ๋ ๊ฒ์ ์คํฌ๋ฆฐ์ท ๊ฐค๋ฌ๋ฆฌ - ํ๋ ์ด ์๊ฐ ์์นด์ด๋ธ
indie-game-screenshot-gallery
๋ฌธ์ ์ค๋ช
๐ฎ ์ธ๋ ๊ฒ์ ์คํฌ๋ฆฐ์ท ๊ฐค๋ฌ๋ฆฌ
๊ฒ์ด๋จธ๋ค์ด ์ข์ํ๋ ์ธ๋ ๊ฒ์์ ๋ฉ์ง ์๊ฐ์ ์คํฌ๋ฆฐ์ท์ผ๋ก ๋จ๊ธฐ๊ณ ๊ณต์ ํ๋ ๊ฐค๋ฌ๋ฆฌ ํ๋ซํผ์ ๋๋ค. Steam, Epic Games ๋ฑ์์ ์บก์ฒํ ๊ฒ์ ์คํฌ๋ฆฐ์ท์ ์ ๋ก๋ํ๊ณ , ๊ฒ์๋ณ๋ก ๋ถ๋ฅํ์ฌ ๋๋ง์ ๊ฒ์ ํ๋ ์ด ์์นด์ด๋ธ๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ๊ฒ์ ์คํฌ๋ฆฐ์ท ์ ๋ก๋ (์ด๋ฏธ์ง ํ์ผ)
- ๊ฒ์ ์ ๋ชฉ, ์ฅ๋ฅด, ํ๋ ์ด ๋ ์ง ์ ๋ ฅ
- ์คํฌ๋ฆฐ์ท ๋ชฉ๋ก ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ํ์
- ๊ฒ์๋ณ ํํฐ๋ง (๋๋กญ๋ค์ด ์ ํ)
- ์คํฌ๋ฆฐ์ท ์์ธ ๋ณด๊ธฐ ๋ชจ๋ฌ (ํฐ ์ด๋ฏธ์ง + ๋ฉํ ์ ๋ณด)
- ์ข์์ ๋ฒํผ (ํํธ ์์ด์ฝ, ํด๋ฆญ ์ ์นด์ดํธ ์ฆ๊ฐ)
- ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ/๋ฐ์คํฌํฑ)
๋ณด๋์ค ๊ธฐ๋ฅ
- ์ฅ๋ฅด๋ณ ํํฐ๋ง ์ถ๊ฐ (์ก์ , RPG, ํผ์ฆ ๋ฑ)
- ๊ฒ์ ๊ธฐ๋ฅ (๊ฒ์ ์ ๋ชฉ์ผ๋ก ๊ฒ์)
- ์คํฌ๋ฆฐ์ท ์ ๋ ฌ (์ต์ ์/์ข์์์)
- ์ด๋ฏธ์ง lazy loading์ผ๋ก ์ฑ๋ฅ ์ต์ ํ
- ๋คํฌ ๋ชจ๋ ์ง์
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (RESTful API)
- ํ๋ก ํธ์๋: React 19 + Vite
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite
- ์คํ์ผ๋ง: Tailwind CSS
- ์ด๋ฏธ์ง ์ ๋ก๋: Laravel Storage (public disk)
๊ฒ์ด๋จธ๋ค์ด ์์ ์ ๊ฒ์ ํ๋ ์ด ํ์ด๋ผ์ดํธ๋ฅผ ์๊ฐ์ ์ผ๋ก ๊ธฐ๋กํ๊ณ ๊ณต์ ํ ์ ์๋ ์ค์ฉ์ ์ธ ์๋น์ค์ ๋๋ค.
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข React 19 ์ต์ ๋ฒ์ ์ฌ์ฉ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๊ฐํธํ ๋ก์ปฌ ๊ฐ๋ฐ)
- โข Tailwind CSS๋ก ์คํ์ผ๋ง
- โข Laravel Storage๋ก ์ด๋ฏธ์ง ์ ๋ก๋ ์ฒ๋ฆฌ
- โข fetch API๋ก Laravel API ํธ์ถ
- โข CORS ์ค์ ํ์ (Laravel API โ React ํ๋ก ํธ์๋)
- โข ๋ฐ์ํ ๋์์ธ ์ ์ฉ
- โข useState๋ง ์ฌ์ฉ (๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธ์ง)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir indie-game-screenshot-gallery && cd indie-game-screenshot-gallery ์ด ํด๋์์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. Laravel 13.x ๋ฐฑ์๋ API ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ ๊ธฐ๋ณธ ์ค์ ์ ์๋ฃํ์ธ์. **์๊ตฌ์ฌํญ:** 1. `backend` ํด๋์ Laravel 13.x ํ๋ก์ ํธ ์์ฑ (`composer create-project laravel/laravel backend`) 2. `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ์ค์ : ``` DB_CONNECTION=sqlite # DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD ์ฃผ์ ์ฒ๋ฆฌ ``` 3. `database/database.sqlite` ํ์ผ ์์ฑ (๋น ํ์ผ) 4. CORS ์ค์ : - `config/cors.php`์์ `'supports_credentials' => true` ์ค์ - `'allowed_origins'`์ `['http://localhost:5173']` ์ถ๊ฐ (React Vite ๊ธฐ๋ณธ ํฌํธ) 5. Storage ์ค์ : - `php artisan storage:link` ๋ช ๋ น์ผ๋ก public ์คํ ๋ฆฌ์ง ์ฌ๋ณผ๋ฆญ ๋งํฌ ์์ฑ - `config/filesystems.php`์์ `'default' => 'public'` ํ์ธ **๋ฐฑ์๋ ํด๋ ๊ตฌ์กฐ ์์:** ``` backend/ โโโ app/ โโโ config/ โโโ database/ โ โโโ database.sqlite โโโ public/ โ โโโ storage/ (์ฌ๋ณผ๋ฆญ ๋งํฌ) โโโ routes/ โโโ .env ``` **ํ์ธ ๋ฐฉ๋ฒ:** - `php artisan serve`๋ก Laravel ์๋ฒ ์คํ (http://localhost:8000) - ๋ธ๋ผ์ฐ์ ์์ ์ ์ ์ Laravel ๊ธฐ๋ณธ ํ์ด์ง ํ์ - `php artisan route:list`๋ก ๋ผ์ฐํธ ๋ชฉ๋ก ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ๋ฐฑ์๋ ํ๋ก์ ํธ๊ฐ `backend` ํด๋์ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์ ๋ฐ CORS ์ค์ ์ด ์๋ฃ๋ฉ๋๋ค. Storage ์ฌ๋ณผ๋ฆญ ๋งํฌ๊ฐ ์์ฑ๋์ด ์ด๋ฏธ์ง ์ ๋ก๋ ์ค๋น๊ฐ ์๋ฃ๋ฉ๋๋ค.
Laravel ๋ฐฑ์๋์์ ์คํฌ๋ฆฐ์ท ๊ด๋ฆฌ API๋ฅผ ๊ตฌ์ถํ์ธ์.
**์๊ตฌ์ฌํญ:**
### 1. ๋ง์ด๊ทธ๋ ์ด์
์์ฑ
`php artisan make:migration create_screenshots_table`๋ก ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ ํ ๋ค์ ์คํค๋ง ์ ์:
```php
Schema::create('screenshots', function (Blueprint $table) {
$table->id();
$table->string('game_title'); // ๊ฒ์ ์ ๋ชฉ
$table->string('genre'); // ์ฅ๋ฅด (Action, RPG, Puzzle ๋ฑ)
$table->date('play_date'); // ํ๋ ์ด ๋ ์ง
$table->string('image_path'); // ์ด๋ฏธ์ง ํ์ผ ๊ฒฝ๋ก
$table->integer('likes')->default(0); // ์ข์์ ์
$table->timestamps();
});
```
### 2. ๋ชจ๋ธ ์์ฑ
`php artisan make:model Screenshot`๋ก ๋ชจ๋ธ ์์ฑ ํ fillable ํ๋ ์ค์ :
```php
protected $fillable = ['game_title', 'genre', 'play_date', 'image_path', 'likes'];
```
### 3. API ์ปจํธ๋กค๋ฌ ์์ฑ
`php artisan make:controller Api/ScreenshotController`๋ก ์ปจํธ๋กค๋ฌ ์์ฑ ํ ๋ค์ ๋ฉ์๋ ๊ตฌํ:
- `index()`: ๋ชจ๋ ์คํฌ๋ฆฐ์ท ๋ชฉ๋ก ๋ฐํ (JSON)
- ์ต์ ์ ์ ๋ ฌ (`orderBy('created_at', 'desc')`)
- ์ด๋ฏธ์ง URL์ `Storage::url($screenshot->image_path)`๋ก ๋ณํ
- `store(Request $request)`: ์ ์คํฌ๋ฆฐ์ท ์
๋ก๋
- ์ ํจ์ฑ ๊ฒ์ฌ:
```php
$request->validate([
'game_title' => 'required|string|max:255',
'genre' => 'required|string',
'play_date' => 'required|date',
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120', // 5MB
]);
```
- ์ด๋ฏธ์ง ํ์ผ ์ ์ฅ: `$request->file('image')->store('screenshots', 'public')`
- ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ ํ ์์ฑ๋ ์คํฌ๋ฆฐ์ท ๋ฐํ
- `show($id)`: ํน์ ์คํฌ๋ฆฐ์ท ์์ธ ์ ๋ณด ๋ฐํ
- `like($id)`: ์ข์์ ์ฆ๊ฐ
- `$screenshot->increment('likes')`๋ก ์ข์์ ์ ์ฆ๊ฐ
- ์
๋ฐ์ดํธ๋ ์คํฌ๋ฆฐ์ท ๋ฐํ
### 4. API ๋ผ์ฐํธ ๋ฑ๋ก
`routes/api.php`์ ๋ค์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
use App\Http\Controllers\Api\ScreenshotController;
Route::get('/screenshots', [ScreenshotController::class, 'index']);
Route::post('/screenshots', [ScreenshotController::class, 'store']);
Route::get('/screenshots/{id}', [ScreenshotController::class, 'show']);
Route::post('/screenshots/{id}/like', [ScreenshotController::class, 'like']);
```
### 5. ๋ง์ด๊ทธ๋ ์ด์
์คํ
`php artisan migrate`๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ
์ด๋ธ ์์ฑ
**ํ์ธ ๋ฐฉ๋ฒ:**
- Postman ๋๋ curl๋ก API ํ
์คํธ:
- `GET http://localhost:8000/api/screenshots` โ ๋น ๋ฐฐ์ด ๋ฐํ
- `POST http://localhost:8000/api/screenshots` (FormData๋ก ์ด๋ฏธ์ง ์
๋ก๋) โ ์คํฌ๋ฆฐ์ท ์์ฑ
- `php artisan route:list`๋ก API ๋ผ์ฐํธ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel ๋ฐฑ์๋์ ์คํฌ๋ฆฐ์ท CRUD API๊ฐ ๊ตฌ์ถ๋ฉ๋๋ค. ์ด๋ฏธ์ง ์ ๋ก๋, ๋ชฉ๋ก ์กฐํ, ์์ธ ์กฐํ, ์ข์์ ๊ธฐ๋ฅ์ด ๋ชจ๋ ๋์ํ๋ฉฐ, Postman์ผ๋ก ํ ์คํธ ๊ฐ๋ฅํฉ๋๋ค.
React 19 ํ๋ก ํธ์๋ ํ๋ก์ ํธ๋ฅผ ์์ฑํ๊ณ ๊ธฐ๋ณธ ๋ ์ด์์์ ๊ตฌ์ฑํ์ธ์.
**์๊ตฌ์ฌํญ:**
### 1. React ํ๋ก์ ํธ ์์ฑ
ํ๋ก์ ํธ ๋ฃจํธ์์ ๋ค์ ๋ช
๋ น ์คํ:
```bash
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```
### 2. Tailwind CSS ์ค์
`tailwind.config.js` ํ์ผ ์์ :
```js
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
```
`src/index.css`์ Tailwind ๋๋ ํฐ๋ธ ์ถ๊ฐ:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
### 3. ๊ธฐ๋ณธ ๋ ์ด์์ ์ปดํฌ๋ํธ ์์ฑ
`src/App.jsx` ํ์ผ์ ๋ค์๊ณผ ๊ฐ์ด ์์ฑ:
```jsx
import { useState } from 'react'
function App() {
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900">
{/* ํค๋ */}
<header className="bg-black bg-opacity-50 backdrop-blur-md border-b border-purple-500/30">
<div className="container mx-auto px-4 py-6">
<h1 className="text-4xl font-bold text-white flex items-center gap-3">
๐ฎ <span>Indie Game Gallery</span>
</h1>
<p className="text-purple-300 mt-2">Share your epic gaming moments</p>
</div>
</header>
{/* ๋ฉ์ธ ์ปจํ
์ธ */}
<main className="container mx-auto px-4 py-8">
{/* ์
๋ก๋ ํผ ์์ญ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ) */}
<section className="bg-white bg-opacity-10 backdrop-blur-md rounded-2xl p-6 mb-8 border border-purple-500/30">
<h2 className="text-2xl font-bold text-white mb-4">๐ธ Upload Screenshot</h2>
<p className="text-purple-200">Upload form will be added here...</p>
</section>
{/* ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ ์์ญ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ) */}
<section>
<h2 className="text-2xl font-bold text-white mb-6">๐ผ๏ธ Gallery</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<p className="text-purple-200 col-span-full text-center py-12">No screenshots yet. Upload your first one!</p>
</div>
</section>
</main>
{/* ํธํฐ */}
<footer className="bg-black bg-opacity-50 backdrop-blur-md border-t border-purple-500/30 mt-16">
<div className="container mx-auto px-4 py-6 text-center text-purple-300">
<p>Built with Laravel 13 + React 19 ๐</p>
</div>
</footer>
</div>
)
}
export default App
```
### 4. ๊ฐ๋ฐ ์๋ฒ ์คํ
`npm run dev`๋ก React ๊ฐ๋ฐ ์๋ฒ ์คํ (http://localhost:5173)
**ํ์ธ ๋ฐฉ๋ฒ:**
- ๋ธ๋ผ์ฐ์ ์์ http://localhost:5173 ์ ์
- ๊ฒ์ ๊ฐค๋ฌ๋ฆฌ ํค๋์ ๊ธฐ๋ณธ ๋ ์ด์์์ด ํ์๋จ
- ๋คํฌ ํ
๋ง์ ๊ฒ์ด๋ฐ ๋ถ์๊ธฐ ๋์์ธ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: React 19 ํ๋ก ํธ์๋ ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ Tailwind CSS๊ฐ ์ค์ ๋ฉ๋๋ค. ๊ฒ์ ๊ฐค๋ฌ๋ฆฌ ํค๋, ์ ๋ก๋ ํผ ์น์ ํ๋ ์ด์คํ๋, ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ ํ๋ ์ด์คํ๋๊ฐ ํฌํจ๋ ๊ธฐ๋ณธ ๋ ์ด์์์ด ํ์๋ฉ๋๋ค.
React ํ๋ก ํธ์๋์์ ์คํฌ๋ฆฐ์ท ์
๋ก๋ ํผ๊ณผ ๊ฐค๋ฌ๋ฆฌ ๋ชฉ๋ก์ ๊ตฌํํ์ธ์.
**์๊ตฌ์ฌํญ:**
### 1. `src/App.jsx` ์์
```jsx
import { useState, useEffect } from 'react'
const API_BASE_URL = 'http://localhost:8000/api'
function App() {
const [screenshots, setScreenshots] = useState([])
const [loading, setLoading] = useState(true)
const [formData, setFormData] = useState({
game_title: '',
genre: '',
play_date: '',
image: null
})
const [uploading, setUploading] = useState(false)
// ์คํฌ๋ฆฐ์ท ๋ชฉ๋ก ๋ถ๋ฌ์ค๊ธฐ
useEffect(() => {
fetchScreenshots()
}, [])
const fetchScreenshots = async () => {
try {
const response = await fetch(`${API_BASE_URL}/screenshots`)
const data = await response.json()
setScreenshots(data)
} catch (error) {
console.error('Failed to fetch screenshots:', error)
} finally {
setLoading(false)
}
}
// ํผ ์
๋ ฅ ์ฒ๋ฆฌ
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const handleFileChange = (e) => {
setFormData(prev => ({ ...prev, image: e.target.files[0] }))
}
// ์
๋ก๋ ์ฒ๋ฆฌ
const handleSubmit = async (e) => {
e.preventDefault()
setUploading(true)
const formDataToSend = new FormData()
formDataToSend.append('game_title', formData.game_title)
formDataToSend.append('genre', formData.genre)
formDataToSend.append('play_date', formData.play_date)
formDataToSend.append('image', formData.image)
try {
const response = await fetch(`${API_BASE_URL}/screenshots`, {
method: 'POST',
body: formDataToSend
})
if (response.ok) {
// ํผ ์ด๊ธฐํ
setFormData({ game_title: '', genre: '', play_date: '', image: null })
document.getElementById('image-upload').value = ''
// ๋ชฉ๋ก ์๋ก๊ณ ์นจ
fetchScreenshots()
alert('Screenshot uploaded successfully! ๐')
}
} catch (error) {
console.error('Upload failed:', error)
alert('Upload failed. Please try again.')
} finally {
setUploading(false)
}
}
// ์ข์์ ์ฒ๋ฆฌ
const handleLike = async (id) => {
try {
const response = await fetch(`${API_BASE_URL}/screenshots/${id}/like`, {
method: 'POST'
})
if (response.ok) {
fetchScreenshots() // ๋ชฉ๋ก ์๋ก๊ณ ์นจ
}
} catch (error) {
console.error('Like failed:', error)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900">
<header className="bg-black bg-opacity-50 backdrop-blur-md border-b border-purple-500/30">
<div className="container mx-auto px-4 py-6">
<h1 className="text-4xl font-bold text-white flex items-center gap-3">
๐ฎ <span>Indie Game Gallery</span>
</h1>
<p className="text-purple-300 mt-2">Share your epic gaming moments</p>
</div>
</header>
<main className="container mx-auto px-4 py-8">
{/* ์
๋ก๋ ํผ */}
<section className="bg-white bg-opacity-10 backdrop-blur-md rounded-2xl p-6 mb-8 border border-purple-500/30">
<h2 className="text-2xl font-bold text-white mb-4">๐ธ Upload Screenshot</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-purple-200 mb-2">Game Title</label>
<input
type="text"
name="game_title"
value={formData.game_title}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg bg-white bg-opacity-20 border border-purple-400 text-white placeholder-purple-300 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="e.g., Hollow Knight"
/>
</div>
<div>
<label className="block text-purple-200 mb-2">Genre</label>
<select
name="genre"
value={formData.genre}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg bg-white bg-opacity-20 border border-purple-400 text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Select Genre</option>
<option value="Action">Action</option>
<option value="RPG">RPG</option>
<option value="Puzzle">Puzzle</option>
<option value="Adventure">Adventure</option>
<option value="Platformer">Platformer</option>
<option value="Strategy">Strategy</option>
</select>
</div>
<div>
<label className="block text-purple-200 mb-2">Play Date</label>
<input
type="date"
name="play_date"
value={formData.play_date}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg bg-white bg-opacity-20 border border-purple-400 text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-purple-200 mb-2">Screenshot Image</label>
<input
id="image-upload"
type="file"
accept="image/*"
onChange={handleFileChange}
required
className="w-full px-4 py-2 rounded-lg bg-white bg-opacity-20 border border-purple-400 text-white file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-purple-600 file:text-white hover:file:bg-purple-700"
/>
</div>
<button
type="submit"
disabled={uploading}
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 px-6 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? 'โณ Uploading...' : '๐ Upload Screenshot'}
</button>
</form>
</section>
{/* ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ */}
<section>
<h2 className="text-2xl font-bold text-white mb-6">๐ผ๏ธ Gallery</h2>
{loading ? (
<p className="text-purple-200 text-center py-12">Loading...</p>
) : screenshots.length === 0 ? (
<p className="text-purple-200 text-center py-12">No screenshots yet. Upload your first one!</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{screenshots.map(screenshot => (
<div key={screenshot.id} className="bg-white bg-opacity-10 backdrop-blur-md rounded-xl overflow-hidden border border-purple-500/30 hover:border-purple-400 transition">
<img
src={`http://localhost:8000${screenshot.image_url}`}
alt={screenshot.game_title}
className="w-full h-64 object-cover"
/>
<div className="p-4">
<h3 className="text-xl font-bold text-white mb-2">{screenshot.game_title}</h3>
<div className="flex items-center gap-2 text-sm text-purple-300 mb-3">
<span className="bg-purple-600 px-2 py-1 rounded">{screenshot.genre}</span>
<span>๐
{screenshot.play_date}</span>
</div>
<button
onClick={() => handleLike(screenshot.id)}
className="flex items-center gap-2 text-pink-400 hover:text-pink-300 transition"
>
โค๏ธ <span>{screenshot.likes}</span>
</button>
</div>
</div>
))}
</div>
)}
</section>
</main>
<footer className="bg-black bg-opacity-50 backdrop-blur-md border-t border-purple-500/30 mt-16">
<div className="container mx-auto px-4 py-6 text-center text-purple-300">
<p>Built with Laravel 13 + React 19 ๐</p>
</div>
</footer>
</div>
)
}
export default App
```
**ํ์ธ ๋ฐฉ๋ฒ:**
1. Laravel ๋ฐฑ์๋ ์๋ฒ ์คํ (`php artisan serve`)
2. React ํ๋ก ํธ์๋ ์๋ฒ ์คํ (`npm run dev`)
3. ๋ธ๋ผ์ฐ์ ์์ http://localhost:5173 ์ ์
4. ์
๋ก๋ ํผ์์ ๊ฒ์ ์ ๋ณด์ ์คํฌ๋ฆฐ์ท ์ด๋ฏธ์ง ์
๋ ฅ ํ ์
๋ก๋
5. ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋์ ์คํฌ๋ฆฐ์ท ์นด๋๊ฐ ํ์๋๋์ง ํ์ธ
6. ์ข์์ ๋ฒํผ ํด๋ฆญ ์ ์นด์ดํธ๊ฐ ์ฆ๊ฐํ๋์ง ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: React ํ๋ก ํธ์๋์์ ์คํฌ๋ฆฐ์ท ์ ๋ก๋ ํผ๊ณผ ๊ฐค๋ฌ๋ฆฌ ๋ชฉ๋ก์ด ์์ฑ๋ฉ๋๋ค. ์ฌ์ฉ์๋ ๊ฒ์ ์ ๋ชฉ, ์ฅ๋ฅด, ํ๋ ์ด ๋ ์ง, ์ด๋ฏธ์ง๋ฅผ ์ ๋ ฅํ์ฌ ์คํฌ๋ฆฐ์ท์ ์ ๋ก๋ํ ์ ์๊ณ , ์ ๋ก๋๋ ์คํฌ๋ฆฐ์ท์ด ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ํ์๋ฉ๋๋ค. ์ข์์ ๋ฒํผ๋ ๋์ํฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!