๐จ ์นํฐ ์๊ฐ ํฌํธํด๋ฆฌ์ค & ์ปค๋ฏธ์ ์ ์ ํ๋ซํผ
webtoon-portfolio-commission-platform
๋ฌธ์ ์ค๋ช
๐จ ์นํฐ ์๊ฐ ํฌํธํด๋ฆฌ์ค & ์ปค๋ฏธ์ ์ ์ ํ๋ซํผ
์นํฐ ์๊ฐ, ์ผ๋ฌ์คํธ๋ ์ดํฐ๊ฐ ์์ ์ ์ํ์ ๊ฐค๋ฌ๋ฆฌ ํํ๋ก ์ ์ํ๊ณ , ํฌ๋ค์ด ์ปค๋ฏธ์ (์ ๋ฃ ์๋ขฐ)์ ์ ์ฒญํ ์ ์๋ ํ์คํ ํ๋ซํผ์ ๋๋ค. ์๊ฐ๋ ํฌํธํด๋ฆฌ์ค๋ฅผ ์ ๋ก๋ํ๊ณ , ์๋ขฐ์ธ์ ์ํ๋ ์์ ์ ํ(์บ๋ฆญํฐ ๋์์ธ, ์ผ๋ฌ์คํธ, ์นํฐ ์ปท ๋ฑ)์ ์ ํํ์ฌ ๊ฒฌ์ ์ ์์ฒญํ ์ ์์ต๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ์ํ ๊ฐค๋ฌ๋ฆฌ: ์๊ฐ๊ฐ ์ํ ์ด๋ฏธ์ง ์ ๋ก๋ (์ ๋ชฉ, ์ค๋ช , ํ๊ทธ)
- ์ํ ํํฐ๋ง: ํ๊ทธ๋ณ, ์ํ ์ ํ๋ณ ํํฐ๋ง (์: ์บ๋ฆญํฐ, ๋ฐฐ๊ฒฝ, ์นํฐ ์ปท)
- ์ปค๋ฏธ์ ์๋ขฐ ํผ: ์๋ขฐ์ธ์ด ์์ ์ ํ, ์์ฐ, ์์ธ ์๊ตฌ์ฌํญ ์ ๋ ฅ
- ์๋ขฐ ๊ด๋ฆฌ: ์๊ฐ๊ฐ ๋ฐ์ ์๋ขฐ ๋ชฉ๋ก ํ์ธ (์ํ: ๋๊ธฐ/์งํ์ค/์๋ฃ)
- ์ข์์ ๊ธฐ๋ฅ: ๋ฐฉ๋ฌธ์๊ฐ ๋ง์์ ๋๋ ์ํ์ ์ข์์ ํ์
- ๋ฐ์ํ ๋์์ธ: ๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ/๋ฐ์คํฌํฑ ๋์
์ ํ ์ถ๊ฐ ๊ธฐ๋ฅ (๋ณด๋์ค)
- ์ํ ์์ธ ํ์ด์ง์ ์ฌ๋ผ์ด๋์ผ ํจ๊ณผ
- ์ปค๋ฏธ์ ๊ฐ๊ฒฉํ ํ์ด์ง (์์ ์ ํ๋ณ ๊ธฐ๋ณธ ์๊ธ ์๋ด)
- ์๊ฐ ํ๋กํ ํ์ด์ง (์๊ฐ, SNS ๋งํฌ, ์์ ์คํ์ผ ์ค๋ช )
- ์๋ขฐ ์ํ ๋ณ๊ฒฝ ์ ์ด๋ฉ์ผ ์๋ฆผ (Laravel Notification)
- ์ํ ์ ๋ก๋ ์ ์ด๋ฏธ์ง ๋ฆฌ์ฌ์ด์ง (Intervention Image)
๊ธฐ์ ์คํ
- Laravel 13.x + Inertia.js + React 19
- Tailwind CSS (๋ฐ์ํ UI)
- SQLite (๊ฐ๋ฐ ํ๊ฒฝ์ฉ)
- Laravel Breeze (Inertia React starter kit)
์ด ํ๋ก์ ํธ๋ฅผ ํตํด ํ์ผ ์ ๋ก๋, ๋ค๋๋ค ๊ด๊ณ(์ํ-ํ๊ทธ), ์ํ ๊ด๋ฆฌ, Inertia.js ํผ ์ฒ๋ฆฌ ๋ฑ์ ํ์ตํ ์ ์์ต๋๋ค.
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ๊ตฌ์กฐ (๋ณ๋ API ๋ผ์ฐํธ ์์)
- โข Laravel Breeze (React + Inertia) starter kit์ผ๋ก ํ๋ก์ ํธ ์ด๊ธฐํ
- โข Tailwind CSS๋ก ๋ฐ์ํ UI ๊ตฌํ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ง์ด๊ทธ๋ ์ด์ ํฌํจ)
- โข ์ด๋ฏธ์ง ์ ๋ก๋๋ Laravel Storage ์ฌ์ฉ (public disk)
- โข Inertia::render()๋ก Laravel ์ปจํธ๋กค๋ฌ์์ React ์ปดํฌ๋ํธ๋ก ๋ฐ์ดํฐ ์ ๋ฌ
- โข ๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๊ธ์ง (useState, useReducer๋ง ์ฌ์ฉ)
- โข ์ค์๊ฐ ์ฑํ ๊ธฐ๋ฅ ๊ธ์ง (์ปค๋ฏธ์ ์๋ขฐ๋ ํผ ์ ์ถ ๋ฐฉ์)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir webtoon-portfolio-commission-platform && cd webtoon-portfolio-commission-platform ์ด ํด๋์์ ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. Laravel 13.x + Inertia.js + React 19 ํ๋ก์ ํธ๋ฅผ ์์ฑํ์ธ์. Laravel Breeze (React + Inertia) starter kit์ ์ฌ์ฉํฉ๋๋ค. ```bash npx create laravel webtoon-portfolio-commission-platform cd webtoon-portfolio-commission-platform composer require laravel/breeze --dev php artisan breeze:install react npm install npm run dev ``` ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ SQLite๋ฅผ ์ฌ์ฉํฉ๋๋ค. `.env` ํ์ผ์ ์์ ํ์ฌ: ``` DB_CONNECTION=sqlite ``` `database/database.sqlite` ํ์ผ์ ์์ฑํ๊ณ ๋ง์ด๊ทธ๋ ์ด์ ์ ์คํํ์ธ์: ```bash touch database/database.sqlite php artisan migrate ``` ํ๋ก์ ํธ๊ฐ ์ ์์ ์ผ๋ก ์คํ๋๋์ง ํ์ธํ์ธ์ (`php artisan serve`).
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel Breeze (React + Inertia) ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๋ฉฐ, ๊ธฐ๋ณธ ์ธ์ฆ ํ์ด์ง(๋ก๊ทธ์ธ/ํ์๊ฐ์ )๊ฐ ์๋ํฉ๋๋ค. http://localhost:8000 ์ ์ ์ Welcome ํ์ด์ง๊ฐ ๋ณด์ ๋๋ค.
์ํ(Portfolio) ๋ชจ๋ธ๊ณผ ๋ง์ด๊ทธ๋ ์ด์
์ ์์ฑํ์ธ์.
```bash
php artisan make:model Portfolio -m
```
๋ง์ด๊ทธ๋ ์ด์
ํ์ผ(`database/migrations/xxxx_create_portfolios_table.php`)์ ๋ค์๊ณผ ๊ฐ์ด ์์ฑ:
```php
public function up()
{
Schema::create('portfolios', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('description')->nullable();
$table->string('image_path');
$table->string('type'); // ์: 'character', 'background', 'webtoon'
$table->integer('likes_count')->default(0);
$table->timestamps();
});
}
```
ํ๊ทธ(Tag) ๋ชจ๋ธ๊ณผ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
```bash
php artisan make:model Tag -m
```
๋ง์ด๊ทธ๋ ์ด์
ํ์ผ:
```php
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
```
์ํ-ํ๊ทธ ์ค๊ฐ ํ
์ด๋ธ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
```bash
php artisan make:migration create_portfolio_tag_table
```
๋ง์ด๊ทธ๋ ์ด์
ํ์ผ:
```php
public function up()
{
Schema::create('portfolio_tag', function (Blueprint $table) {
$table->foreignId('portfolio_id')->constrained()->onDelete('cascade');
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
$table->primary(['portfolio_id', 'tag_id']);
});
}
```
๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
Portfolio ๋ชจ๋ธ์ ๊ด๊ณ ์ค์ :
```php
class Portfolio extends Model
{
protected $fillable = ['user_id', 'title', 'description', 'image_path', 'type', 'likes_count'];
public function user()
{
return $this->belongsTo(User::class);
}
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
```
Tag ๋ชจ๋ธ:
```php
class Tag extends Model
{
protected $fillable = ['name'];
public function portfolios()
{
return $this->belongsToMany(Portfolio::class);
}
}
```
๊ธฐ๋ ๊ฒฐ๊ณผ: portfolios, tags, portfolio_tag ํ ์ด๋ธ์ด ์์ฑ๋๊ณ , Portfolio์ Tag ๋ชจ๋ธ์ ๋ค๋๋ค ๊ด๊ณ๊ฐ ์ค์ ๋ฉ๋๋ค.
์ํ ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง๋ฅผ ๊ตฌํํ์ธ์.
PortfolioController ์์ฑ:
```bash
php artisan make:controller PortfolioController
```
`app/Http/Controllers/PortfolioController.php`:
```php
use App\Models\Portfolio;
use Inertia\Inertia;
class PortfolioController extends Controller
{
public function index(Request $request)
{
$query = Portfolio::with(['user', 'tags']);
if ($request->has('type') && $request->type !== 'all') {
$query->where('type', $request->type);
}
if ($request->has('tag')) {
$query->whereHas('tags', function($q) use ($request) {
$q->where('name', $request->tag);
});
}
$portfolios = $query->latest()->paginate(12);
$tags = \App\Models\Tag::all();
return Inertia::render('Portfolio/Index', [
'portfolios' => $portfolios,
'tags' => $tags,
'filters' => $request->only(['type', 'tag'])
]);
}
}
```
๋ผ์ฐํธ ์ถ๊ฐ (`routes/web.php`):
```php
use App\Http\Controllers\PortfolioController;
Route::get('/portfolios', [PortfolioController::class, 'index'])->name('portfolios.index');
```
React ์ปดํฌ๋ํธ ์์ฑ (`resources/js/Pages/Portfolio/Index.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
import { useState } from 'react';
export default function Index({ portfolios, tags, filters }) {
const [selectedType, setSelectedType] = useState(filters.type || 'all');
const [selectedTag, setSelectedTag] = useState(filters.tag || '');
return (
<AuthenticatedLayout>
<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="mb-6 flex gap-4 flex-wrap">
<div>
<label className="block text-sm font-medium mb-2">์ํ ์ ํ</label>
<select
value={selectedType}
onChange={(e) => window.location.href = `/portfolios?type=${e.target.value}`}
className="border rounded px-3 py-2"
>
<option value="all">์ ์ฒด</option>
<option value="character">์บ๋ฆญํฐ</option>
<option value="background">๋ฐฐ๊ฒฝ</option>
<option value="webtoon">์นํฐ ์ปท</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">ํ๊ทธ</label>
<select
value={selectedTag}
onChange={(e) => window.location.href = `/portfolios?tag=${e.target.value}`}
className="border rounded px-3 py-2"
>
<option value="">์ ์ฒด</option>
{tags.map(tag => (
<option key={tag.id} value={tag.name}>{tag.name}</option>
))}
</select>
</div>
</div>
{/* ๊ฐค๋ฌ๋ฆฌ ๊ทธ๋ฆฌ๋ */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{portfolios.data.map(portfolio => (
<div key={portfolio.id} className="bg-white rounded-lg shadow hover:shadow-lg transition">
<img
src={`/storage/${portfolio.image_path}`}
alt={portfolio.title}
className="w-full h-48 object-cover rounded-t-lg"
/>
<div className="p-4">
<h3 className="font-bold text-lg mb-2">{portfolio.title}</h3>
<p className="text-sm text-gray-600 mb-2">{portfolio.description}</p>
<div className="flex gap-2 flex-wrap mb-2">
{portfolio.tags.map(tag => (
<span key={tag.id} className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
#{tag.name}
</span>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">by {portfolio.user.name}</span>
<button className="text-red-500">โค๏ธ {portfolio.likes_count}</button>
</div>
</div>
</div>
))}
</div>
{/* ํ์ด์ง๋ค์ด์
*/}
<div className="mt-6 flex justify-center gap-2">
{portfolios.links.map((link, index) => (
<Link
key={index}
href={link.url}
className={`px-3 py-2 border rounded ${link.active ? 'bg-blue-500 text-white' : 'bg-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๋ค๋น๊ฒ์ด์
๋ฉ๋ด์ '์ํ ๊ฐค๋ฌ๋ฆฌ' ๋งํฌ ์ถ๊ฐ (`resources/js/Layouts/AuthenticatedLayout.jsx`).
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง๊ฐ ๋ ๋๋ง๋๊ณ , ์ํ ์ ํ ๋ฐ ํ๊ทธ๋ณ ํํฐ๋ง์ด ์๋ํฉ๋๋ค. ํ์ด์ง๋ค์ด์ ์ด ํ์๋ฉ๋๋ค.
์ํ ์
๋ก๋ ๊ธฐ๋ฅ์ ๊ตฌํํ์ธ์. (์๊ฐ๋ง ์ฌ์ฉ ๊ฐ๋ฅ)
PortfolioController์ create, store ๋ฉ์๋ ์ถ๊ฐ:
```php
public function create()
{
$tags = \App\Models\Tag::all();
return Inertia::render('Portfolio/Create', ['tags' => $tags]);
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|max:255',
'description' => 'nullable',
'image' => 'required|image|max:2048',
'type' => 'required|in:character,background,webtoon',
'tags' => 'array'
]);
$path = $request->file('image')->store('portfolios', 'public');
$portfolio = Portfolio::create([
'user_id' => auth()->id(),
'title' => $request->title,
'description' => $request->description,
'image_path' => $path,
'type' => $request->type
]);
if ($request->has('tags')) {
$portfolio->tags()->sync($request->tags);
}
return redirect()->route('portfolios.index')->with('success', '์ํ์ด ์
๋ก๋๋์์ต๋๋ค.');
}
```
๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::middleware('auth')->group(function () {
Route::get('/portfolios/create', [PortfolioController::class, 'create'])->name('portfolios.create');
Route::post('/portfolios', [PortfolioController::class, 'store'])->name('portfolios.store');
});
```
React ์ปดํฌ๋ํธ (`resources/js/Pages/Portfolio/Create.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
export default function Create({ tags }) {
const { data, setData, post, processing, errors } = useForm({
title: '',
description: '',
image: null,
type: 'character',
tags: []
});
const handleSubmit = (e) => {
e.preventDefault();
post(route('portfolios.store'));
};
return (
<AuthenticatedLayout>
<Head title="์ํ ์
๋ก๋" />
<div className="py-12">
<div className="max-w-2xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-8">์ํ ์
๋ก๋</h1>
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow">
<div className="mb-4">
<label className="block font-medium mb-2">์ ๋ชฉ</label>
<input
type="text"
value={data.title}
onChange={e => setData('title', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
</div>
<div className="mb-4">
<label className="block font-medium mb-2">์ค๋ช
</label>
<textarea
value={data.description}
onChange={e => setData('description', e.target.value)}
className="w-full border rounded px-3 py-2"
rows="4"
/>
</div>
<div className="mb-4">
<label className="block font-medium mb-2">์ด๋ฏธ์ง</label>
<input
type="file"
onChange={e => setData('image', e.target.files[0])}
className="w-full border rounded px-3 py-2"
/>
{errors.image && <p className="text-red-500 text-sm mt-1">{errors.image}</p>}
</div>
<div className="mb-4">
<label className="block font-medium mb-2">์ํ ์ ํ</label>
<select
value={data.type}
onChange={e => setData('type', e.target.value)}
className="w-full border rounded px-3 py-2"
>
<option value="character">์บ๋ฆญํฐ</option>
<option value="background">๋ฐฐ๊ฒฝ</option>
<option value="webtoon">์นํฐ ์ปท</option>
</select>
</div>
<div className="mb-4">
<label className="block font-medium mb-2">ํ๊ทธ (๋ณต์ ์ ํ ๊ฐ๋ฅ)</label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<label key={tag.id} className="flex items-center">
<input
type="checkbox"
value={tag.id}
checked={data.tags.includes(tag.id)}
onChange={e => {
if (e.target.checked) {
setData('tags', [...data.tags, tag.id]);
} else {
setData('tags', data.tags.filter(t => t !== tag.id));
}
}}
className="mr-2"
/>
{tag.name}
</label>
))}
</div>
</div>
<button
type="submit"
disabled={processing}
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
์
๋ก๋
</button>
</form>
</div>
</div>
</AuthenticatedLayout>
);
}
```
Storage ๋งํฌ ์์ฑ:
```bash
php artisan storage:link
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์ ๋ก๋ ํ์ด์ง๊ฐ ์๋ํ๊ณ , ์ด๋ฏธ์ง ํ์ผ๊ณผ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ์ ์ถํ๋ฉด DB์ ์ ์ฅ๋๊ณ ๊ฐค๋ฌ๋ฆฌ์ ํ์๋ฉ๋๋ค.
์ปค๋ฏธ์
์๋ขฐ ๋ชจ๋ธ๊ณผ ํผ์ ๊ตฌํํ์ธ์.
Commission ๋ชจ๋ธ ์์ฑ:
```bash
php artisan make:model Commission -m
```
๋ง์ด๊ทธ๋ ์ด์
ํ์ผ:
```php
public function up()
{
Schema::create('commissions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); // ์๋ขฐ์ธ
$table->string('commission_type'); // ์: 'character', 'illustration', 'webtoon'
$table->decimal('budget', 10, 2);
$table->text('details');
$table->string('status')->default('pending'); // pending, in_progress, completed
$table->timestamps();
});
}
```
๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
Commission ๋ชจ๋ธ:
```php
class Commission extends Model
{
protected $fillable = ['user_id', 'commission_type', 'budget', 'details', 'status'];
public function user()
{
return $this->belongsTo(User::class);
}
}
```
CommissionController ์์ฑ:
```bash
php artisan make:controller CommissionController
```
`app/Http/Controllers/CommissionController.php`:
```php
use App\Models\Commission;
use Inertia\Inertia;
class CommissionController extends Controller
{
public function create()
{
return Inertia::render('Commission/Create');
}
public function store(Request $request)
{
$request->validate([
'commission_type' => 'required',
'budget' => 'required|numeric|min:0',
'details' => 'required'
]);
Commission::create([
'user_id' => auth()->id(),
'commission_type' => $request->commission_type,
'budget' => $request->budget,
'details' => $request->details
]);
return redirect()->route('commissions.index')->with('success', '์ปค๋ฏธ์
์๋ขฐ๊ฐ ์ ์๋์์ต๋๋ค.');
}
public function index()
{
$commissions = Commission::with('user')->latest()->paginate(10);
return Inertia::render('Commission/Index', ['commissions' => $commissions]);
}
}
```
๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::middleware('auth')->group(function () {
Route::get('/commissions/create', [CommissionController::class, 'create'])->name('commissions.create');
Route::post('/commissions', [CommissionController::class, 'store'])->name('commissions.store');
Route::get('/commissions', [CommissionController::class, 'index'])->name('commissions.index');
});
```
React ์ปดํฌ๋ํธ (`resources/js/Pages/Commission/Create.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
commission_type: 'character',
budget: '',
details: ''
});
const handleSubmit = (e) => {
e.preventDefault();
post(route('commissions.store'));
};
return (
<AuthenticatedLayout>
<Head title="์ปค๋ฏธ์
์๋ขฐ" />
<div className="py-12">
<div className="max-w-2xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-8">์ปค๋ฏธ์
์๋ขฐํ๊ธฐ</h1>
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow">
<div className="mb-4">
<label className="block font-medium mb-2">์์
์ ํ</label>
<select
value={data.commission_type}
onChange={e => setData('commission_type', e.target.value)}
className="w-full border rounded px-3 py-2"
>
<option value="character">์บ๋ฆญํฐ ๋์์ธ</option>
<option value="illustration">์ผ๋ฌ์คํธ</option>
<option value="webtoon">์นํฐ ์ปท</option>
</select>
</div>
<div className="mb-4">
<label className="block font-medium mb-2">์์ฐ (์)</label>
<input
type="number"
value={data.budget}
onChange={e => setData('budget', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{errors.budget && <p className="text-red-500 text-sm mt-1">{errors.budget}</p>}
</div>
<div className="mb-4">
<label className="block font-medium mb-2">์์ธ ์๊ตฌ์ฌํญ</label>
<textarea
value={data.details}
onChange={e => setData('details', e.target.value)}
className="w-full border rounded px-3 py-2"
rows="6"
placeholder="์ํ๋ ์คํ์ผ, ์บ๋ฆญํฐ ํน์ง, ๋ฐฐ๊ฒฝ ์์ ๋ฑ์ ์์ธํ ์ ์ด์ฃผ์ธ์."
/>
{errors.details && <p className="text-red-500 text-sm mt-1">{errors.details}</p>}
</div>
<button
type="submit"
disabled={processing}
className="bg-green-500 text-white px-6 py-2 rounded hover:bg-green-600 disabled:opacity-50"
>
์๋ขฐ ์ ์
</button>
</form>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ปค๋ฏธ์ ์๋ขฐ ํผ์ด ์๋ํ๊ณ , ์ ์ถ ์ DB์ ์ ์ฅ๋ฉ๋๋ค. ์๊ฐ๋ ๋ฐ์ ์๋ขฐ ๋ชฉ๋ก์ ํ์ธํ ์ ์์ต๋๋ค.
์๋ขฐ ๊ด๋ฆฌ ํ์ด์ง์ ์ข์์ ๊ธฐ๋ฅ์ ๊ตฌํํ์ธ์.
์๋ขฐ ๋ชฉ๋ก ํ์ด์ง (`resources/js/Pages/Commission/Index.jsx`):
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Index({ commissions }) {
const statusLabels = {
pending: '๋๊ธฐ์ค',
in_progress: '์งํ์ค',
completed: '์๋ฃ'
};
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800'
};
return (
<AuthenticatedLayout>
<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="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">์๋ขฐ์ธ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">์์
์ ํ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">์์ฐ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">์ํ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">์ ์์ผ</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{commissions.data.map(commission => (
<tr key={commission.id}>
<td className="px-6 py-4">{commission.user.name}</td>
<td className="px-6 py-4">{commission.commission_type}</td>
<td className="px-6 py-4">{commission.budget.toLocaleString()}์</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded text-xs ${statusColors[commission.status]}`}>
{statusLabels[commission.status]}
</span>
</td>
<td className="px-6 py-4">{new Date(commission.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
์ข์์ ๊ธฐ๋ฅ ๊ตฌํ (PortfolioController์ ๋ฉ์๋ ์ถ๊ฐ):
```php
public function like(Portfolio $portfolio)
{
$portfolio->increment('likes_count');
return back();
}
```
๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::post('/portfolios/{portfolio}/like', [PortfolioController::class, 'like'])->name('portfolios.like');
```
๊ฐค๋ฌ๋ฆฌ ํ์ด์ง์์ ์ข์์ ๋ฒํผ ์์ (`resources/js/Pages/Portfolio/Index.jsx`):
```jsx
import { useForm } from '@inertiajs/react';
// ์ปดํฌ๋ํธ ๋ด๋ถ
const { post } = useForm();
const handleLike = (portfolioId) => {
post(route('portfolios.like', portfolioId));
};
// ์ข์์ ๋ฒํผ
<button
onClick={() => handleLike(portfolio.id)}
className="text-red-500 hover:text-red-600"
>
โค๏ธ {portfolio.likes_count}
</button>
```
๋ค๋น๊ฒ์ด์
์ '์ปค๋ฏธ์
์๋ขฐ', '์๋ขฐ ๋ชฉ๋ก' ๋งํฌ ์ถ๊ฐ.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์๋ขฐ ๊ด๋ฆฌ ํ์ด์ง์์ ๋ฐ์ ์ปค๋ฏธ์ ๋ชฉ๋ก์ด ํ์๋๊ณ , ์ํ ๊ฐค๋ฌ๋ฆฌ์์ ์ข์์ ๋ฒํผ์ ํด๋ฆญํ๋ฉด ์นด์ดํธ๊ฐ ์ฆ๊ฐํฉ๋๋ค. ๋ชจ๋ ๊ธฐ๋ฅ์ด ํตํฉ๋์ด ์๋ํฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!