๐ช ์์ปค์ค ๊ณต์ฐ ๊ด๋๊ฐ ํ๊ธฐ & ์ฌ์ง ๊ฐค๋ฌ๋ฆฌ - ๊ด๊ฐ์ด ๋ง๋๋ ๊ณต์ฐ ์์นด์ด๋ธ
circus-performance-review-gallery
๋ฌธ์ ์ค๋ช
๐ช ์์ปค์ค ๊ณต์ฐ ๊ด๋๊ฐ ํ๊ธฐ & ์ฌ์ง ๊ฐค๋ฌ๋ฆฌ
์์ปค์ค ๊ณต์ฐ์ ๊ด๋ํ ๊ด๊ฐ๋ค์ด ์ง์ ํ๊ธฐ์ ์ฌ์ง์ ๋จ๊ธฐ๋ ์ปค๋ฎค๋ํฐ ํ๋ซํผ์ ๋ง๋ญ๋๋ค. ๊ด๋๊ฐ๋ค์ ๊ณต์ฐ๋ณ๋ก ๋ณ์ , ํ ์คํธ ํ๊ธฐ, ๊ด๋ ์ธ์ฆ์ท์ ์ ๋ก๋ํ ์ ์์ผ๋ฉฐ, ๋ค๋ฅธ ๊ด๋๊ฐ๋ค์ ์ข์์์ ๋๊ธ๋ก ์ํตํฉ๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ๊ณต์ฐ ๋ชฉ๋ก ํ์ด์ง: ํ์ฌ ์์ ์ค์ธ ์์ปค์ค ๊ณต์ฐ ๋ชฉ๋ก (์ ๋ชฉ, ํฌ์คํฐ ์ด๋ฏธ์ง, ํ๊ท ๋ณ์ )
- ๊ณต์ฐ ์์ธ ํ์ด์ง: ๊ณต์ฐ ์ ๋ณด + ๊ด๋๊ฐ ํ๊ธฐ ๋ชฉ๋ก (์ต์ ์/๋ณ์ ์ ์ ๋ ฌ)
- ํ๊ธฐ ์์ฑ ํผ: ๋ณ์ ์ ํ(1~5), ํ ์คํธ ํ๊ธฐ, ์ฌ์ง ์ ๋ก๋ (์ต๋ 3์ฅ)
- ํ๊ธฐ ์์ธ ๋ชจ๋ฌ: ํ๊ธฐ ํด๋ฆญ ์ ๋ชจ๋ฌ๋ก ์ ์ฒด ๋ด์ฉ + ์ฌ์ง ๊ฐค๋ฌ๋ฆฌ ์ฌ๋ผ์ด๋
- ์ข์์ ๊ธฐ๋ฅ: ํ๊ธฐ์ ์ข์์ ๋๋ฅด๊ธฐ (์ค๋ณต ๋ฐฉ์ง)
- ๋๊ธ ๊ธฐ๋ฅ: ํ๊ธฐ์ ๋๊ธ ๋ฌ๊ธฐ
- ๋ง์ดํ์ด์ง: ๋ด๊ฐ ์ด ํ๊ธฐ ๋ชฉ๋ก, ์ข์์ํ ํ๊ธฐ ๋ชฉ๋ก
๋ณด๋์ค ๊ธฐ๋ฅ
- ์ฌ์ง ์ธ๋ค์ผ ์๋ ์์ฑ (Intervention Image ์ฌ์ฉ)
- ํ๊ธฐ ์ ๊ณ ๊ธฐ๋ฅ (๋ถ์ ์ ํ ์ฝํ ์ธ ๊ด๋ฆฌ)
- ๊ณต์ฐ๋ณ ํ๊ทธ ์์คํ (#๊ฐ๋์ ์ธ #์ด๋ฆฐ์ด์ถ์ฒ #ํ๋ คํ)
- ํ๊ธฐ ์์ฑ ์ ์คํฌ์ผ๋ฌ ๊ฒฝ๊ณ ์ฒดํฌ๋ฐ์ค
- ๊ด๋ ๋ ์ง ์ ๋ ฅ & ๊ณต์ฐ๋ณ ๊ด๋ ํต๊ณ (๋ ์ง๋ณ ํ๊ธฐ ์ ์ฐจํธ)
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (Eloquent ORM, File Storage, Validation)
- ํ๋ก ํธ์๋: React 19 (Hooks, Context API)
- ํ์คํ ์ฐ๊ฒฐ: Inertia.js (SPA ๊ฒฝํ + Laravel ๋ผ์ฐํ )
- ์คํ์ผ๋ง: Tailwind CSS
- ์ด๋ฏธ์ง ์ฒ๋ฆฌ: Laravel Storage (public disk)
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ์ฉ) / MySQL (ํ๋ก๋์ )
ํ์ต ๋ชฉํ
- Laravel ๋ชจ๋ธ ๊ด๊ณ ์ค๊ณ (1:N, N:M)
- Inertia.js๋ก Laravel โ React ๋ฐ์ดํฐ ํ๋ฆ ์ดํด
- ํ์ผ ์ ๋ก๋ & ์คํ ๋ฆฌ์ง ๊ด๋ฆฌ
- React ์ปดํฌ๋ํธ ๋ถ๋ฆฌ & ์ฌ์ฌ์ฉ
- ๋ชจ๋ฌ, ์ฌ๋ผ์ด๋ ๋ฑ ์ธํฐ๋ํฐ๋ธ UI ๊ตฌํ
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ํ์คํ ํ๋ก์ ํธ
- โข Laravel Breeze (React + Inertia) starter kit ์ฌ์ฉ
- โข ๋ณ๋ API ๋ผ์ฐํธ ์์ด Inertia::render()๋ก ๋ฐ์ดํฐ ์ ๋ฌ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๊ฐ๋ฐ ํธ์์ฑ)
- โข Tailwind CSS๋ก ์คํ์ผ๋ง
- โข ์ด๋ฏธ์ง ์ ๋ก๋๋ Laravel Storage์ public disk ์ฌ์ฉ
- โข Eloquent ๊ด๊ณ: Performance hasMany Review, Review belongsTo Performance, Review hasMany Comment, User hasMany Review/Comment
- โข ์ข์์๋ ์ค๊ฐ ํ ์ด๋ธ (review_likes) ์ฌ์ฉํ์ฌ ์ค๋ณต ๋ฐฉ์ง
- โข ํ๋ก ํธ์๋ ์ํ๊ด๋ฆฌ๋ useState/useReducer๋ง ์ฌ์ฉ (์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธ์ง)
- โข ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ ์ฐ์ )
- โข ์ด๋ฏธ์ง ํ์ผ ํฌ๊ธฐ ์ ํ (์ต๋ 2MB per file)
- โข ํ๊ธฐ ์์ฑ ์ ๋ณ์ ํ์, ํ ์คํธ ์ต์ 10์ ์ด์
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir circus-performance-review-gallery && cd circus-performance-review-gallery ์ด ํด๋์์ Laravel + Inertia.js + React ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. ๋ค์ ๋ช ๋ น์ผ๋ก 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๋ก ์ค์ ํ๊ณ ๋ง์ด๊ทธ๋ ์ด์ ์ ์คํํ์ธ์: 1. `.env` ํ์ผ์์ DB_CONNECTION์ sqlite๋ก ๋ณ๊ฒฝ 2. `database/database.sqlite` ํ์ผ ์์ฑ (touch database/database.sqlite) 3. `php artisan migrate`๋ก ๊ธฐ๋ณธ ํ ์ด๋ธ ์์ฑ ๋ง์ง๋ง์ผ๋ก ๊ฐ๋ฐ ์๋ฒ๋ฅผ ์คํํ์ธ์: ```bash php artisan serve ``` ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000 ์ ์ํ์ฌ Laravel Breeze ๊ธฐ๋ณธ ํ๋ฉด(๋ก๊ทธ์ธ/ํ์๊ฐ์ )์ด ์ ์์ ์ผ๋ก ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel Breeze (React + Inertia) ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๋ฉฐ, ๊ฐ๋ฐ ์๋ฒ๊ฐ ์คํ๋ฉ๋๋ค. ๋ธ๋ผ์ฐ์ ์์ ๊ธฐ๋ณธ ์ธ์ฆ ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค.
์์ปค์ค ๊ณต์ฐ(Performance)๊ณผ ํ๊ธฐ(Review), ๋๊ธ(Comment), ์ข์์(ReviewLike) ๋ชจ๋ธ์ ์์ฑํ๊ณ ๋ง์ด๊ทธ๋ ์ด์
์ ์์ฑํ์ธ์.
**๋ชจ๋ธ & ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:**
```bash
php artisan make:model Performance -m
php artisan make:model Review -m
php artisan make:model Comment -m
php artisan make:model ReviewLike -m
```
**๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ:**
1. `create_performances_table.php`:
- id (bigIncrements)
- title (string) - ๊ณต์ฐ ์ ๋ชฉ
- description (text) - ๊ณต์ฐ ์ค๋ช
- poster_image (string, nullable) - ํฌ์คํฐ ์ด๋ฏธ์ง ๊ฒฝ๋ก
- start_date (date) - ๊ณต์ฐ ์์์ผ
- end_date (date) - ๊ณต์ฐ ์ข
๋ฃ์ผ
- timestamps
2. `create_reviews_table.php`:
- id (bigIncrements)
- performance_id (foreignId) - performances ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- user_id (foreignId) - users ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- rating (integer) - ๋ณ์ (1~5)
- content (text) - ํ๊ธฐ ๋ด์ฉ
- watched_at (date, nullable) - ๊ด๋ ๋ ์ง
- is_spoiler (boolean, default false) - ์คํฌ์ผ๋ฌ ์ฌ๋ถ
- timestamps
3. `create_comments_table.php`:
- id (bigIncrements)
- review_id (foreignId) - reviews ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- user_id (foreignId) - users ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- content (text) - ๋๊ธ ๋ด์ฉ
- timestamps
4. `create_review_likes_table.php`:
- id (bigIncrements)
- review_id (foreignId) - reviews ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- user_id (foreignId) - users ํ
์ด๋ธ ์ฐธ์กฐ (onDelete cascade)
- timestamps
- unique(['review_id', 'user_id']) - ์ค๋ณต ์ข์์ ๋ฐฉ์ง
**๋ชจ๋ธ ๊ด๊ณ ์ค์ :**
- `Performance.php`: `public function reviews() { return $this->hasMany(Review::class); }`
- `Review.php`: `public function performance() { return $this->belongsTo(Performance::class); }`, `public function user() { return $this->belongsTo(User::class); }`, `public function comments() { return $this->hasMany(Comment::class); }`, `public function likes() { return $this->hasMany(ReviewLike::class); }`
- `Comment.php`: `public function review() { return $this->belongsTo(Review::class); }`, `public function user() { return $this->belongsTo(User::class); }`
- `ReviewLike.php`: `public function review() { return $this->belongsTo(Review::class); }`, `public function user() { return $this->belongsTo(User::class); }`
- `User.php`์ `public function reviews() { return $this->hasMany(Review::class); }`์ `public function comments() { return $this->hasMany(Comment::class); }` ์ถ๊ฐ
๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
**ํ
์คํธ ๋ฐ์ดํฐ ์๋ฉ:**
`database/seeders/DatabaseSeeder.php`์์ ๋๋ฏธ ๊ณต์ฐ ๋ฐ์ดํฐ 3๊ฐ ์์ฑ:
```php
Performance::create([
'title' => 'ํ์์ ์์ปค์ค: ๋น๊ณผ ๊ทธ๋ฆผ์',
'description' => '์ธ๊ณ์ ์ธ ์์ปค์ค๋จ์ด ์ ๋ณด์ด๋ ํ๋ คํ ๊ณต์ค ๊ณก์์ ๋ง์ ์ผ',
'poster_image' => 'https://picsum.photos/seed/circus1/400/600',
'start_date' => '2024-01-01',
'end_date' => '2024-06-30',
]);
// 2๊ฐ ๋ ์ถ๊ฐ...
```
์๋ฉ ์คํ:
```bash
php artisan db:seed
```
๊ธฐ๋ ๊ฒฐ๊ณผ: Performance, Review, Comment, ReviewLike ๋ชจ๋ธ๊ณผ ๋ง์ด๊ทธ๋ ์ด์ ์ด ์์ฑ๋๊ณ , ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ ์ด๋ธ์ด ์์ฑ๋ฉ๋๋ค. ๋ชจ๋ธ ๊ฐ ๊ด๊ณ๊ฐ ์ค์ ๋๊ณ , ๋๋ฏธ ๊ณต์ฐ ๋ฐ์ดํฐ 3๊ฐ๊ฐ ์๋ฉ๋ฉ๋๋ค.
๊ณต์ฐ ๋ชฉ๋ก ํ์ด์ง๋ฅผ ๊ตฌํํ์ธ์.
**Laravel ์ปจํธ๋กค๋ฌ ์์ฑ:**
```bash
php artisan make:controller PerformanceController
```
`app/Http/Controllers/PerformanceController.php`:
```php
public function index()
{
$performances = Performance::withCount('reviews')
->withAvg('reviews', 'rating')
->get()
->map(function ($performance) {
return [
'id' => $performance->id,
'title' => $performance->title,
'description' => $performance->description,
'poster_image' => $performance->poster_image,
'start_date' => $performance->start_date,
'end_date' => $performance->end_date,
'reviews_count' => $performance->reviews_count,
'average_rating' => round($performance->reviews_avg_rating, 1) ?? 0,
];
});
return Inertia::render('Performances/Index', [
'performances' => $performances,
]);
}
```
**๋ผ์ฐํธ ์ถ๊ฐ** (`routes/web.php`):
```php
use App\Http\Controllers\PerformanceController;
Route::get('/performances', [PerformanceController::class, 'index'])->name('performances.index');
```
**React ์ปดํฌ๋ํธ ์์ฑ** (`resources/js/Pages/Performances/Index.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Index({ auth, performances }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="๊ณต์ฐ ๋ชฉ๋ก" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-8">๐ช ์์ปค์ค ๊ณต์ฐ ๋ชฉ๋ก</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{performances.map((performance) => (
<Link
key={performance.id}
href={`/performances/${performance.id}`}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition"
>
<img
src={performance.poster_image}
alt={performance.title}
className="w-full h-64 object-cover"
/>
<div className="p-4">
<h2 className="text-xl font-bold mb-2">{performance.title}</h2>
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
{performance.description}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-yellow-500 text-lg mr-1">โญ</span>
<span className="font-semibold">
{performance.average_rating || 'ํ๊ฐ ์์'}
</span>
</div>
<span className="text-gray-500 text-sm">
ํ๊ธฐ {performance.reviews_count}๊ฐ
</span>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ `/performances` ์ ์ํ์ฌ ๊ณต์ฐ ๋ชฉ๋ก์ด ์นด๋ ํํ๋ก ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ณต์ฐ ๋ชฉ๋ก ํ์ด์ง๊ฐ ๊ตฌํ๋๊ณ , ๊ฐ ๊ณต์ฐ์ด ํฌ์คํฐ ์ด๋ฏธ์ง, ์ ๋ชฉ, ํ๊ท ๋ณ์ , ํ๊ธฐ ๊ฐ์์ ํจ๊ป ์นด๋ ํํ๋ก ํ์๋ฉ๋๋ค.
๊ณต์ฐ ์์ธ ํ์ด์ง์ ํ๊ธฐ ๋ชฉ๋ก์ ๊ตฌํํ์ธ์.
**์ปจํธ๋กค๋ฌ ๋ฉ์๋ ์ถ๊ฐ** (`PerformanceController.php`):
```php
public function show($id)
{
$performance = Performance::with([
'reviews' => function ($query) {
$query->with(['user', 'comments.user', 'likes'])
->withCount('likes')
->latest();
}
])->findOrFail($id);
$reviews = $performance->reviews->map(function ($review) {
return [
'id' => $review->id,
'rating' => $review->rating,
'content' => $review->content,
'watched_at' => $review->watched_at,
'is_spoiler' => $review->is_spoiler,
'created_at' => $review->created_at->diffForHumans(),
'user' => [
'name' => $review->user->name,
],
'likes_count' => $review->likes_count,
'comments' => $review->comments->map(function ($comment) {
return [
'id' => $comment->id,
'content' => $comment->content,
'created_at' => $comment->created_at->diffForHumans(),
'user' => ['name' => $comment->user->name],
];
}),
];
});
return Inertia::render('Performances/Show', [
'performance' => [
'id' => $performance->id,
'title' => $performance->title,
'description' => $performance->description,
'poster_image' => $performance->poster_image,
'start_date' => $performance->start_date,
'end_date' => $performance->end_date,
],
'reviews' => $reviews,
]);
}
```
**๋ผ์ฐํธ ์ถ๊ฐ:**
```php
Route::get('/performances/{id}', [PerformanceController::class, 'show'])->name('performances.show');
```
**React ์ปดํฌ๋ํธ** (`resources/js/Pages/Performances/Show.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Show({ auth, performance, reviews }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title={performance.title} />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<div className="flex flex-col md:flex-row gap-6">
<img
src={performance.poster_image}
alt={performance.title}
className="w-full md:w-1/3 rounded-lg"
/>
<div className="flex-1">
<h1 className="text-3xl font-bold mb-4">{performance.title}</h1>
<p className="text-gray-700 mb-4">{performance.description}</p>
<p className="text-sm text-gray-500">
๊ณต์ฐ ๊ธฐ๊ฐ: {performance.start_date} ~ {performance.end_date}
</p>
<Link
href={`/performances/${performance.id}/reviews/create`}
className="mt-6 inline-block bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700"
>
ํ๊ธฐ ์์ฑํ๊ธฐ
</Link>
</div>
</div>
</div>
<h2 className="text-2xl font-bold mb-4">๊ด๋๊ฐ ํ๊ธฐ ({reviews.length})</h2>
<div className="space-y-4">
{reviews.map((review) => (
<div key={review.id} className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-semibold">{review.user.name}</span>
<span className="text-yellow-500">
{'โญ'.repeat(review.rating)}
</span>
</div>
<span className="text-sm text-gray-500">{review.created_at}</span>
</div>
<p className="text-gray-700 mb-3">{review.content}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<button className="flex items-center gap-1 hover:text-red-500">
โค๏ธ {review.likes_count}
</button>
<span>๐ฌ {review.comments.length}</span>
</div>
</div>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ ๊ณต์ฐ ์์ธ ํ์ด์ง ์ ์ํ์ฌ ํ๊ธฐ ๋ชฉ๋ก์ด ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ณต์ฐ ์์ธ ํ์ด์ง๊ฐ ๊ตฌํ๋๊ณ , ๊ณต์ฐ ์ ๋ณด์ ํจ๊ป ํ๊ธฐ ๋ชฉ๋ก์ด ํ์๋ฉ๋๋ค. ๊ฐ ํ๊ธฐ๋ ์์ฑ์, ๋ณ์ , ๋ด์ฉ, ์ข์์ ์, ๋๊ธ ์๋ฅผ ํฌํจํฉ๋๋ค.
ํ๊ธฐ ์์ฑ ํผ์ ๊ตฌํํ์ธ์. ๋ณ์ ์ ํ, ํ
์คํธ ์
๋ ฅ, ์ด๋ฏธ์ง ์
๋ก๋(์ต๋ 3์ฅ) ๊ธฐ๋ฅ์ ํฌํจํฉ๋๋ค.
**์ปจํธ๋กค๋ฌ ๋ฉ์๋ ์ถ๊ฐ** (`ReviewController` ์์ฑ):
```bash
php artisan make:controller ReviewController
```
`app/Http/Controllers/ReviewController.php`:
```php
public function create($performanceId)
{
$performance = Performance::findOrFail($performanceId);
return Inertia::render('Reviews/Create', [
'performance' => [
'id' => $performance->id,
'title' => $performance->title,
],
]);
}
public function store(Request $request, $performanceId)
{
$validated = $request->validate([
'rating' => 'required|integer|min:1|max:5',
'content' => 'required|string|min:10',
'watched_at' => 'nullable|date',
'is_spoiler' => 'boolean',
'images.*' => 'nullable|image|max:2048', // ์ต๋ 2MB
]);
$review = Review::create([
'performance_id' => $performanceId,
'user_id' => auth()->id(),
'rating' => $validated['rating'],
'content' => $validated['content'],
'watched_at' => $validated['watched_at'] ?? null,
'is_spoiler' => $validated['is_spoiler'] ?? false,
]);
// ์ด๋ฏธ์ง ์
๋ก๋ ์ฒ๋ฆฌ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ)
return redirect()->route('performances.show', $performanceId)
->with('success', 'ํ๊ธฐ๊ฐ ์์ฑ๋์์ต๋๋ค!');
}
```
**๋ผ์ฐํธ ์ถ๊ฐ:**
```php
use App\Http\Controllers\ReviewController;
Route::middleware('auth')->group(function () {
Route::get('/performances/{id}/reviews/create', [ReviewController::class, 'create'])->name('reviews.create');
Route::post('/performances/{id}/reviews', [ReviewController::class, 'store'])->name('reviews.store');
});
```
**React ์ปดํฌ๋ํธ** (`resources/js/Pages/Reviews/Create.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
import { useState } from 'react';
export default function Create({ auth, performance }) {
const { data, setData, post, processing, errors } = useForm({
rating: 5,
content: '',
watched_at: '',
is_spoiler: false,
images: [],
});
const [previewImages, setPreviewImages] = useState([]);
const handleImageChange = (e) => {
const files = Array.from(e.target.files).slice(0, 3); // ์ต๋ 3์ฅ
setData('images', files);
const previews = files.map((file) => URL.createObjectURL(file));
setPreviewImages(previews);
};
const handleSubmit = (e) => {
e.preventDefault();
post(route('reviews.store', performance.id));
};
return (
<AuthenticatedLayout user={auth.user}>
<Head title="ํ๊ธฐ ์์ฑ" />
<div className="py-12">
<div className="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold mb-6">
{performance.title} ํ๊ธฐ ์์ฑ
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">๋ณ์ </label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setData('rating', star)}
className="text-3xl"
>
{star <= data.rating ? 'โญ' : 'โ'}
</button>
))}
</div>
{errors.rating && (
<p className="text-red-500 text-sm mt-1">{errors.rating}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">ํ๊ธฐ ๋ด์ฉ</label>
<textarea
value={data.content}
onChange={(e) => setData('content', e.target.value)}
rows="6"
className="w-full border rounded-lg p-3"
placeholder="๊ณต์ฐ์ ๊ด๋ํ ์๊ฐ์ ์์ ๋กญ๊ฒ ์์ฑํด์ฃผ์ธ์ (์ต์ 10์)"
/>
{errors.content && (
<p className="text-red-500 text-sm mt-1">{errors.content}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">๊ด๋ ๋ ์ง (์ ํ)</label>
<input
type="date"
value={data.watched_at}
onChange={(e) => setData('watched_at', e.target.value)}
className="border rounded-lg p-2"
/>
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={data.is_spoiler}
onChange={(e) => setData('is_spoiler', e.target.checked)}
/>
<span className="text-sm">์คํฌ์ผ๋ฌ ํฌํจ</span>
</label>
</div>
<div>
<label className="block text-sm font-medium mb-2">
์ฌ์ง ์
๋ก๋ (์ต๋ 3์ฅ)
</label>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageChange}
className="block"
/>
<div className="flex gap-2 mt-3">
{previewImages.map((src, index) => (
<img
key={index}
src={src}
alt={`Preview ${index + 1}`}
className="w-24 h-24 object-cover rounded"
/>
))}
</div>
</div>
<button
type="submit"
disabled={processing}
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
ํ๊ธฐ ๋ฑ๋กํ๊ธฐ
</button>
</form>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ ํ๊ธฐ ์์ฑ ํผ์ด ์ ์์ ์ผ๋ก ํ์๋๊ณ , ๋ณ์ ์ ํ๊ณผ ํผ ์ ์ถ์ด ์๋ํ๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ๊ธฐ ์์ฑ ํผ์ด ๊ตฌํ๋๊ณ , ๋ณ์ ์ ํ, ํ ์คํธ ์ ๋ ฅ, ๊ด๋ ๋ ์ง, ์คํฌ์ผ๋ฌ ์ฒดํฌ๋ฐ์ค, ์ด๋ฏธ์ง ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ธฐ๋ฅ์ด ์๋ํฉ๋๋ค. ํผ ์ ์ถ ์ ๋ฐ์ดํฐ๊ฐ Laravel ์ปจํธ๋กค๋ฌ๋ก ์ ์ก๋ฉ๋๋ค.
์ด๋ฏธ์ง ์
๋ก๋ ๊ธฐ๋ฅ์ ์์ฑํ๊ณ , ํ๊ธฐ์ ์ด๋ฏธ์ง๋ฅผ ์ฒจ๋ถํ ์ ์๋๋ก ๊ตฌํํ์ธ์.
**ReviewImage ๋ชจ๋ธ ์์ฑ:**
```bash
php artisan make:model ReviewImage -m
```
**๋ง์ด๊ทธ๋ ์ด์
** (`create_review_images_table.php`):
```php
Schema::create('review_images', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->constrained()->onDelete('cascade');
$table->string('path'); // ์ด๋ฏธ์ง ์ ์ฅ ๊ฒฝ๋ก
$table->timestamps();
});
```
๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
**๋ชจ๋ธ ๊ด๊ณ ์ถ๊ฐ:**
- `Review.php`: `public function images() { return $this->hasMany(ReviewImage::class); }`
- `ReviewImage.php`: `public function review() { return $this->belongsTo(Review::class); }`
**์คํ ๋ฆฌ์ง ๋งํฌ ์์ฑ:**
```bash
php artisan storage:link
```
**์ปจํธ๋กค๋ฌ ์์ ** (`ReviewController::store`):
```php
public function store(Request $request, $performanceId)
{
$validated = $request->validate([
'rating' => 'required|integer|min:1|max:5',
'content' => 'required|string|min:10',
'watched_at' => 'nullable|date',
'is_spoiler' => 'boolean',
'images.*' => 'nullable|image|max:2048',
]);
$review = Review::create([
'performance_id' => $performanceId,
'user_id' => auth()->id(),
'rating' => $validated['rating'],
'content' => $validated['content'],
'watched_at' => $validated['watched_at'] ?? null,
'is_spoiler' => $validated['is_spoiler'] ?? false,
]);
// ์ด๋ฏธ์ง ์
๋ก๋ ์ฒ๋ฆฌ
if ($request->hasFile('images')) {
foreach ($request->file('images') as $image) {
$path = $image->store('review-images', 'public');
$review->images()->create(['path' => $path]);
}
}
return redirect()->route('performances.show', $performanceId)
->with('success', 'ํ๊ธฐ๊ฐ ์์ฑ๋์์ต๋๋ค!');
}
```
**๊ณต์ฐ ์์ธ ํ์ด์ง์์ ์ด๋ฏธ์ง ํ์** (`PerformanceController::show` ์์ ):
```php
$reviews = $performance->reviews->map(function ($review) {
return [
'id' => $review->id,
'rating' => $review->rating,
'content' => $review->content,
'watched_at' => $review->watched_at,
'is_spoiler' => $review->is_spoiler,
'created_at' => $review->created_at->diffForHumans(),
'user' => ['name' => $review->user->name],
'likes_count' => $review->likes_count,
'images' => $review->images->map(fn($img) => asset('storage/' . $img->path)),
'comments' => $review->comments->map(function ($comment) {
return [
'id' => $comment->id,
'content' => $comment->content,
'created_at' => $comment->created_at->diffForHumans(),
'user' => ['name' => $comment->user->name],
];
}),
];
});
```
**Show.jsx์์ ์ด๋ฏธ์ง ํ์:**
```jsx
{review.images && review.images.length > 0 && (
<div className="flex gap-2 mt-3">
{review.images.map((imgUrl, index) => (
<img
key={index}
src={imgUrl}
alt={`ํ๊ธฐ ์ด๋ฏธ์ง ${index + 1}`}
className="w-32 h-32 object-cover rounded cursor-pointer"
/>
))}
</div>
)}
```
ํ๊ธฐ ์์ฑ ํ ์ด๋ฏธ์ง๊ฐ ์ ์์ ์ผ๋ก ์
๋ก๋๋๊ณ ์์ธ ํ์ด์ง์ ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ด๋ฏธ์ง ์ ๋ก๋ ๊ธฐ๋ฅ์ด ์์ฑ๋๊ณ , ํ๊ธฐ ์์ฑ ์ ์ต๋ 3์ฅ์ ์ด๋ฏธ์ง๋ฅผ ์ฒจ๋ถํ ์ ์์ต๋๋ค. ๊ณต์ฐ ์์ธ ํ์ด์ง์์ ํ๊ธฐ์ ์ฒจ๋ถ๋ ์ด๋ฏธ์ง๊ฐ ์ธ๋ค์ผ ํํ๋ก ํ์๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!