๐ŸŽจ ์˜จ๋ผ์ธ ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋” - ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์„น์…˜ ํŽธ์ง‘๊ธฐ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ค‘๊ธ‰ PHP + JavaScript Laravel + React + Inertia.js AI: Claude

๐ŸŽจ ์˜จ๋ผ์ธ ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋” - ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์„น์…˜ ํŽธ์ง‘๊ธฐ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… portfolio-builder-drag-drop
์กฐํšŒ์ˆ˜ 109 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽจ ์˜จ๋ผ์ธ ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋” - ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์„น์…˜ ํŽธ์ง‘๊ธฐ

ํ”„๋ฆฌ๋žœ์„œ, ๋””์ž์ด๋„ˆ, ๊ฐœ๋ฐœ์ž๋ฅผ ์œ„ํ•œ ๋น„์ฃผ์–ผ ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋”์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ์„น์…˜ ์ˆœ์„œ๋ฅผ ์กฐ์ •ํ•˜๊ณ , ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ”„๋ฆฌ๋ทฐ๋ฅผ ํ™•์ธํ•˜๋ฉฐ, ์™„์„ฑ๋œ ํฌํŠธํด๋ฆฌ์˜ค๋ฅผ ๊ณต๊ฐœ URL๋กœ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  • โœ… ์„น์…˜ ๊ด€๋ฆฌ: Hero, About, Projects, Skills, Contact ๋“ฑ ๋‹ค์–‘ํ•œ ์„น์…˜ ํƒ€์ž… ์ถ”๊ฐ€/์‚ญ์ œ
  • โœ… ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ: ์„น์…˜ ์ˆœ์„œ๋ฅผ ๋งˆ์šฐ์Šค๋กœ ์ž์œ ๋กญ๊ฒŒ ์žฌ๋ฐฐ์น˜ (react-beautiful-dnd ๋˜๋Š” dnd-kit ํ™œ์šฉ)
  • โœ… ์‹ค์‹œ๊ฐ„ ํ”„๋ฆฌ๋ทฐ: ํŽธ์ง‘ ํ™”๋ฉด๊ณผ ๊ณต๊ฐœ ํŽ˜์ด์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๋™์‹œ์— ํ™•์ธ
  • โœ… ํ”„๋กœ์ ํŠธ ๊ฐค๋Ÿฌ๋ฆฌ: ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ, ์ œ๋ชฉ, ์„ค๋ช…, ๋งํฌ ์ถ”๊ฐ€
  • โœ… ์Šคํ‚ฌ์…‹ ์‹œ๊ฐํ™”: ์Šคํ‚ฌ๋ช…๊ณผ ์ˆ™๋ จ๋„(%) ์ž…๋ ฅ โ†’ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋กœ ํ‘œ์‹œ
  • โœ… ๊ณต๊ฐœ URL ์ƒ์„ฑ: ์‚ฌ์šฉ์ž๋ณ„ ๊ณ ์œ  ์Šฌ๋Ÿฌ๊ทธ(์˜ˆ: /portfolio/johndoe)๋กœ ํฌํŠธํด๋ฆฌ์˜ค ๊ณต๊ฐœ
  • โœ… ๋‹คํฌ ๋ชจ๋“œ ์ง€์›: ํŽธ์ง‘๊ธฐ์™€ ๊ณต๊ฐœ ํŽ˜์ด์ง€ ๋ชจ๋‘ ๋‹คํฌ/๋ผ์ดํŠธ ๋ชจ๋“œ ํ† ๊ธ€

์„ ํƒ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ (๋ณด๋„ˆ์Šค)

  • ๐ŸŽ ํ…Œ๋งˆ ํ”„๋ฆฌ์…‹: ๋ฏธ๋ฆฌ ๋””์ž์ธ๋œ 3๊ฐ€์ง€ ์ปฌ๋Ÿฌ ํ…Œ๋งˆ ์ค‘ ์„ ํƒ
  • ๐ŸŽ SEO ๋ฉ”ํƒ€ ํƒœ๊ทธ: ํฌํŠธํด๋ฆฌ์˜ค ์ œ๋ชฉ, ์„ค๋ช…, OG ์ด๋ฏธ์ง€ ์„ค์ •
  • ๐ŸŽ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ: ์„น์…˜ ๋“ฑ์žฅ ์‹œ fade-in, slide-up ์• ๋‹ˆ๋ฉ”์ด์…˜
  • ๐ŸŽ PDF ๋‹ค์šด๋กœ๋“œ: ํฌํŠธํด๋ฆฌ์˜ค๋ฅผ PDF๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ (Puppeteer ๋˜๋Š” html2pdf)

๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: Laravel 13.x (์ปจํŠธ๋กค๋Ÿฌ, Eloquent ๋ชจ๋ธ, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜)
  • ํ”„๋ก ํŠธ์—”๋“œ: React 19 + Inertia.js (๋ชจ๋…ธ๋ฆฌ์Šค ํ’€์Šคํƒ)
  • ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ: @dnd-kit/core, @dnd-kit/sortable
  • ์Šคํƒ€์ผ๋ง: Tailwind CSS
  • ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ: Laravel Storage (public disk)
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: SQLite (๋˜๋Š” MySQL)

ํ•ต์‹ฌ ํ•™์Šต ํฌ์ธํŠธ

  • Laravel + Inertia.js ๋ชจ๋…ธ๋ฆฌ์Šค ์•„ํ‚คํ…์ฒ˜ ์ดํ•ด
  • React์—์„œ ๋ณต์žกํ•œ ์ƒํƒœ ๊ด€๋ฆฌ (์„น์…˜ ๋ฐฐ์—ด, ๋“œ๋ž˜๊ทธ ์ด๋ฒคํŠธ)
  • ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ†ตํ•ฉ ๋ฐ ์ตœ์ ํ™”
  • ํŒŒ์ผ ์—…๋กœ๋“œ ๋ฐ Laravel Storage ํ™œ์šฉ
  • ๊ณต๊ฐœ URL ๋ผ์šฐํŒ… ๋ฐ ์Šฌ๋Ÿฌ๊ทธ ๊ธฐ๋ฐ˜ ์กฐํšŒ
  • ์‹ค์‹œ๊ฐ„ ํ”„๋ฆฌ๋ทฐ์™€ ํŽธ์ง‘ ํ™”๋ฉด ๋™๊ธฐํ™”

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ๊ตฌ์กฐ ํ•„์ˆ˜
  • โ€ข ๋ณ„๋„ REST API ๋ผ์šฐํŠธ ์—†์ด Inertia::render()๋กœ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • โ€ข ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” @dnd-kit ์‚ฌ์šฉ (react-beautiful-dnd๋Š” React 19 ํ˜ธํ™˜ ์ด์Šˆ ๊ฐ€๋Šฅ์„ฑ)
  • โ€ข ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๋Š” Laravel Storage์˜ public disk ์‚ฌ์šฉ
  • โ€ข ๊ณต๊ฐœ ํฌํŠธํด๋ฆฌ์˜ค๋Š” `/portfolio/{slug}` ํ˜•์‹์˜ ๋ผ์šฐํŠธ๋กœ ์ ‘๊ทผ
  • โ€ข Tailwind CSS๋กœ ๋ฐ˜์‘ํ˜• ๋””์ž์ธ ๊ตฌํ˜„
  • โ€ข ์„น์…˜ ํƒ€์ž…๋ณ„๋กœ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ ๋ Œ๋”๋ง (Hero, About, Projects, Skills, Contact ๋“ฑ)
  • โ€ข SQLite ๋˜๋Š” MySQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
mkdir portfolio-builder-drag-drop && cd portfolio-builder-drag-drop

์ด ํด๋”์—์„œ Laravel 13.x + Inertia.js + React 19 ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”:

```bash
npx create-laravel@latest .
```

์„ค์น˜ ์ค‘ ๋‹ค์Œ ์˜ต์…˜์„ ์„ ํƒํ•˜์„ธ์š”:
- Starter kit: Breeze
- Stack: React with Inertia
- Dark mode support: Yes
- TypeScript: No (์„ ํƒ์‚ฌํ•ญ)
- Database: SQLite

์„ค์น˜ ์™„๋ฃŒ ํ›„ ๋‹ค์Œ์„ ํ™•์ธํ•˜์„ธ์š”:

1. `php artisan migrate` ์‹คํ–‰ํ•˜์—ฌ ๊ธฐ๋ณธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ
2. `npm install` ๋ฐ `npm run dev` ์‹คํ–‰ํ•˜์—ฌ Vite ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹œ์ž‘
3. `php artisan serve` ์‹คํ–‰ํ•˜์—ฌ Laravel ์„œ๋ฒ„ ์‹œ์ž‘
4. ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000 ์ ‘์†ํ•˜์—ฌ Breeze ๊ธฐ๋ณธ ํ™”๋ฉด ํ™•์ธ

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ๊ฐ€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ์„ฑ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ:

- `app/Models/User.php` (๊ธฐ๋ณธ User ๋ชจ๋ธ)
- `resources/js/Pages/` (Inertia React ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ)
- `routes/web.php` (์›น ๋ผ์šฐํŠธ)
- `database/migrations/` (๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ)

์ด์ œ ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋”๋ฅผ ์œ„ํ•œ ๊ธฐ๋ณธ ์„ธํŒ…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel 13.x + Breeze + Inertia.js + React 19 ํ”„๋กœ์ ํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , ๊ธฐ๋ณธ ์ธ์ฆ ํ™”๋ฉด์ด localhost:8000์—์„œ ์ •์ƒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊ฐ€ ์„ค์ •๋˜๊ณ , npm run dev์™€ php artisan serve๊ฐ€ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.

2
Step 2
ํฌํŠธํด๋ฆฌ์˜ค ๋นŒ๋”๋ฅผ ์œ„ํ•œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ ๋‘ ๊ฐœ์˜ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜์„ธ์š”:

```bash
php artisan make:migration create_portfolios_table
php artisan make:migration create_sections_table
```

**portfolios ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ** (`database/migrations/xxxx_create_portfolios_table.php`):

```php
Schema::create('portfolios', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->string('title'); // ํฌํŠธํด๋ฆฌ์˜ค ์ œ๋ชฉ
    $table->string('slug')->unique(); // ๊ณต๊ฐœ URL ์Šฌ๋Ÿฌ๊ทธ (์˜ˆ: johndoe)
    $table->text('description')->nullable(); // ๊ฐ„๋‹จํ•œ ์†Œ๊ฐœ
    $table->string('theme')->default('default'); // ํ…Œ๋งˆ ํ”„๋ฆฌ์…‹ (default, dark, colorful)
    $table->boolean('is_published')->default(false); // ๊ณต๊ฐœ ์—ฌ๋ถ€
    $table->timestamps();
});
```

**sections ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ** (`database/migrations/xxxx_create_sections_table.php`):

```php
Schema::create('sections', function (Blueprint $table) {
    $table->id();
    $table->foreignId('portfolio_id')->constrained()->onDelete('cascade');
    $table->string('type'); // hero, about, projects, skills, contact
    $table->integer('order')->default(0); // ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์ˆœ์„œ
    $table->json('content'); // ์„น์…˜๋ณ„ ๋ฐ์ดํ„ฐ (์ œ๋ชฉ, ์„ค๋ช…, ์ด๋ฏธ์ง€ URL ๋“ฑ)
    $table->boolean('is_visible')->default(true); // ์„น์…˜ ํ‘œ์‹œ ์—ฌ๋ถ€
    $table->timestamps();
});
```

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:

```bash
php artisan migrate
```

๋‹ค์Œ์œผ๋กœ Eloquent ๋ชจ๋ธ์„ ์ƒ์„ฑํ•˜์„ธ์š”:

```bash
php artisan make:model Portfolio
php artisan make:model Section
```

**Portfolio ๋ชจ๋ธ** (`app/Models/Portfolio.php`):

```php
class Portfolio extends Model
{
    protected $fillable = ['user_id', 'title', 'slug', 'description', 'theme', 'is_published'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function sections()
    {
        return $this->hasMany(Section::class)->orderBy('order');
    }
}
```

**Section ๋ชจ๋ธ** (`app/Models/Section.php`):

```php
class Section extends Model
{
    protected $fillable = ['portfolio_id', 'type', 'order', 'content', 'is_visible'];
    protected $casts = ['content' => 'array'];

    public function portfolio()
    {
        return $this->belongsTo(Portfolio::class);
    }
}
```

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์กฐ๊ฐ€ ์™„์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: portfolios์™€ sections ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋˜๊ณ , Portfolio์™€ Section Eloquent ๋ชจ๋ธ์ด ์ •์˜๋ฉ๋‹ˆ๋‹ค. ๊ด€๊ณ„(hasMany, belongsTo)๊ฐ€ ์„ค์ •๋˜๊ณ , content ํ•„๋“œ๋Š” JSON ํƒ€์ž…์œผ๋กœ ์บ์ŠคํŒ…๋ฉ๋‹ˆ๋‹ค.

3
Step 3
ํฌํŠธํด๋ฆฌ์˜ค ํŽธ์ง‘๊ธฐ์˜ ํ•ต์‹ฌ ์ปจํŠธ๋กค๋Ÿฌ์™€ ๋ผ์šฐํŠธ๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

**์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ**:

```bash
php artisan make:controller PortfolioController
```

**PortfolioController** (`app/Http/Controllers/PortfolioController.php`):

```php
use App\Models\Portfolio;
use App\Models\Section;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Str;

class PortfolioController extends Controller
{
    // ํŽธ์ง‘๊ธฐ ๋ฉ”์ธ ํŽ˜์ด์ง€
    public function edit()
    {
        $portfolio = auth()->user()->portfolio ?? auth()->user()->portfolio()->create([
            'title' => 'My Portfolio',
            'slug' => Str::slug(auth()->user()->name . '-' . Str::random(5)),
            'description' => 'Welcome to my portfolio',
        ]);

        return Inertia::render('Portfolio/Edit', [
            'portfolio' => $portfolio->load('sections'),
        ]);
    }

    // ํฌํŠธํด๋ฆฌ์˜ค ๊ธฐ๋ณธ ์ •๋ณด ์—…๋ฐ์ดํŠธ
    public function update(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'theme' => 'required|in:default,dark,colorful',
            'is_published' => 'boolean',
        ]);

        $portfolio = auth()->user()->portfolio;
        $portfolio->update($validated);

        return back();
    }

    // ์„น์…˜ ์ถ”๊ฐ€
    public function addSection(Request $request)
    {
        $validated = $request->validate([
            'type' => 'required|in:hero,about,projects,skills,contact',
        ]);

        $portfolio = auth()->user()->portfolio;
        $maxOrder = $portfolio->sections()->max('order') ?? 0;

        $portfolio->sections()->create([
            'type' => $validated['type'],
            'order' => $maxOrder + 1,
            'content' => $this->getDefaultContent($validated['type']),
        ]);

        return back();
    }

    // ์„น์…˜ ์ˆœ์„œ ์—…๋ฐ์ดํŠธ (๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ)
    public function reorderSections(Request $request)
    {
        $validated = $request->validate([
            'sections' => 'required|array',
            'sections.*.id' => 'required|exists:sections,id',
            'sections.*.order' => 'required|integer',
        ]);

        foreach ($validated['sections'] as $section) {
            Section::where('id', $section['id'])->update(['order' => $section['order']]);
        }

        return back();
    }

    // ์„น์…˜ ๋‚ด์šฉ ์—…๋ฐ์ดํŠธ
    public function updateSection(Request $request, Section $section)
    {
        $validated = $request->validate([
            'content' => 'required|array',
        ]);

        $section->update(['content' => $validated['content']]);

        return back();
    }

    // ์„น์…˜ ์‚ญ์ œ
    public function deleteSection(Section $section)
    {
        $section->delete();
        return back();
    }

    // ๊ณต๊ฐœ ํฌํŠธํด๋ฆฌ์˜ค ํŽ˜์ด์ง€
    public function show($slug)
    {
        $portfolio = Portfolio::where('slug', $slug)
            ->where('is_published', true)
            ->with('sections')
            ->firstOrFail();

        return Inertia::render('Portfolio/Show', [
            'portfolio' => $portfolio,
        ]);
    }

    private function getDefaultContent($type)
    {
        return match($type) {
            'hero' => ['title' => 'Welcome', 'subtitle' => 'I am a developer'],
            'about' => ['text' => 'About me...'],
            'projects' => ['items' => []],
            'skills' => ['items' => []],
            'contact' => ['email' => '', 'phone' => ''],
        };
    }
}
```

**๋ผ์šฐํŠธ ์„ค์ •** (`routes/web.php`):

```php
use App\Http\Controllers\PortfolioController;

Route::middleware('auth')->group(function () {
    Route::get('/portfolio/edit', [PortfolioController::class, 'edit'])->name('portfolio.edit');
    Route::patch('/portfolio', [PortfolioController::class, 'update'])->name('portfolio.update');
    Route::post('/portfolio/sections', [PortfolioController::class, 'addSection'])->name('portfolio.sections.add');
    Route::patch('/portfolio/sections/reorder', [PortfolioController::class, 'reorderSections'])->name('portfolio.sections.reorder');
    Route::patch('/portfolio/sections/{section}', [PortfolioController::class, 'updateSection'])->name('portfolio.sections.update');
    Route::delete('/portfolio/sections/{section}', [PortfolioController::class, 'deleteSection'])->name('portfolio.sections.delete');
});

Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show');
```

์ด์ œ ๋ฐฑ์—”๋“œ ๋กœ์ง์ด ์™„์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: PortfolioController๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , ํŽธ์ง‘๊ธฐ ํŽ˜์ด์ง€, ์„น์…˜ ์ถ”๊ฐ€/์ˆ˜์ •/์‚ญ์ œ/์ˆœ์„œ ๋ณ€๊ฒฝ, ๊ณต๊ฐœ ํŽ˜์ด์ง€ ์กฐํšŒ ๋“ฑ์˜ ๋ผ์šฐํŠธ๊ฐ€ ์„ค์ •๋ฉ๋‹ˆ๋‹ค. Inertia::render()๋กœ React ์ปดํฌ๋„ŒํŠธ์— ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

4
Step 4
React ํŽธ์ง‘๊ธฐ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ๊ธฐ๋Šฅ์„ ์œ„ํ•ด @dnd-kit ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•˜์„ธ์š”.

**๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜**:

```bash
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
```

**ํŽธ์ง‘๊ธฐ ํŽ˜์ด์ง€** (`resources/js/Pages/Portfolio/Edit.jsx`):

```jsx
import { useState } from 'react';
import { Head, useForm, router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import SortableSection from '@/Components/SortableSection';

export default function Edit({ auth, portfolio }) {
    const [sections, setSections] = useState(portfolio.sections);
    const { data, setData, patch } = useForm({
        title: portfolio.title,
        description: portfolio.description,
        theme: portfolio.theme,
        is_published: portfolio.is_published,
    });

    const sensors = useSensors(useSensor(PointerSensor));

    const handleDragEnd = (event) => {
        const { active, over } = event;
        if (active.id !== over.id) {
            setSections((items) => {
                const oldIndex = items.findIndex((i) => i.id === active.id);
                const newIndex = items.findIndex((i) => i.id === over.id);
                const newItems = arrayMove(items, oldIndex, newIndex);

                // ์ˆœ์„œ ์—…๋ฐ์ดํŠธ API ํ˜ธ์ถœ
                router.patch('/portfolio/sections/reorder', {
                    sections: newItems.map((item, index) => ({ id: item.id, order: index })),
                });

                return newItems;
            });
        }
    };

    const addSection = (type) => {
        router.post('/portfolio/sections', { type }, {
            onSuccess: () => router.reload({ only: ['portfolio'] }),
        });
    };

    const handleSubmit = (e) => {
        e.preventDefault();
        patch('/portfolio');
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="Edit Portfolio" />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">Portfolio Editor</h2>

                        {/* ๊ธฐ๋ณธ ์ •๋ณด ํผ */}
                        <form onSubmit={handleSubmit} className="mb-8 space-y-4">
                            <div>
                                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
                                <input
                                    type="text"
                                    value={data.title}
                                    onChange={(e) => setData('title', e.target.value)}
                                    className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                                />
                            </div>
                            <div>
                                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
                                <textarea
                                    value={data.description}
                                    onChange={(e) => setData('description', e.target.value)}
                                    className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                                />
                            </div>
                            <div>
                                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Theme</label>
                                <select
                                    value={data.theme}
                                    onChange={(e) => setData('theme', e.target.value)}
                                    className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                                >
                                    <option value="default">Default</option>
                                    <option value="dark">Dark</option>
                                    <option value="colorful">Colorful</option>
                                </select>
                            </div>
                            <div className="flex items-center">
                                <input
                                    type="checkbox"
                                    checked={data.is_published}
                                    onChange={(e) => setData('is_published', e.target.checked)}
                                    className="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                                />
                                <label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Publish Portfolio</label>
                            </div>
                            <button type="submit" className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
                                Save Settings
                            </button>
                        </form>

                        {/* ์„น์…˜ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */}
                        <div className="mb-6 flex gap-2 flex-wrap">
                            {['hero', 'about', 'projects', 'skills', 'contact'].map((type) => (
                                <button
                                    key={type}
                                    onClick={() => addSection(type)}
                                    className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 capitalize"
                                >
                                    Add {type}
                                </button>
                            ))}
                        </div>

                        {/* ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์„น์…˜ ๋ฆฌ์ŠคํŠธ */}
                        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
                            <SortableContext items={sections.map((s) => s.id)} strategy={verticalListSortingStrategy}>
                                <div className="space-y-4">
                                    {sections.map((section) => (
                                        <SortableSection key={section.id} section={section} />
                                    ))}
                                </div>
                            </SortableContext>
                        </DndContext>

                        {/* ๊ณต๊ฐœ URL ์•ˆ๋‚ด */}
                        {data.is_published && (
                            <div className="mt-8 p-4 bg-green-50 dark:bg-green-900 rounded-md">
                                <p className="text-sm text-green-800 dark:text-green-200">
                                    Your portfolio is live at:{' '}
                                    <a
                                        href={`/portfolio/${portfolio.slug}`}
                                        target="_blank"
                                        className="font-medium underline"
                                    >
                                        /portfolio/{portfolio.slug}
                                    </a>
                                </p>
                            </div>
                        )}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋‹ค์Œ ๋‹จ๊ณ„์—์„œ SortableSection ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: @dnd-kit ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์„ค์น˜๋˜๊ณ , Edit.jsx ํŽ˜์ด์ง€์—์„œ ํฌํŠธํด๋ฆฌ์˜ค ๊ธฐ๋ณธ ์ •๋ณด ํŽธ์ง‘ ํผ๊ณผ ์„น์…˜ ์ถ”๊ฐ€ ๋ฒ„ํŠผ, ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ Œ๋”๋ง๋ฉ๋‹ˆ๋‹ค. handleDragEnd ํ•จ์ˆ˜๊ฐ€ ์ˆœ์„œ ๋ณ€๊ฒฝ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

5
Step 5
๋“œ๋ž˜๊ทธ ๊ฐ€๋Šฅํ•œ ์„น์…˜ ์ปดํฌ๋„ŒํŠธ์™€ ์„น์…˜๋ณ„ ํŽธ์ง‘ ํผ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

**SortableSection ์ปดํฌ๋„ŒํŠธ** (`resources/js/Components/SortableSection.jsx`):

```jsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useState } from 'react';
import { router } from '@inertiajs/react';

export default function SortableSection({ section }) {
    const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: section.id });
    const [isEditing, setIsEditing] = useState(false);
    const [content, setContent] = useState(section.content);

    const style = {
        transform: CSS.Transform.toString(transform),
        transition,
    };

    const handleSave = () => {
        router.patch(`/portfolio/sections/${section.id}`, { content }, {
            onSuccess: () => setIsEditing(false),
        });
    };

    const handleDelete = () => {
        if (confirm('Delete this section?')) {
            router.delete(`/portfolio/sections/${section.id}`);
        }
    };

    return (
        <div ref={setNodeRef} style={style} className="bg-gray-50 dark:bg-gray-700 p-4 rounded-md border border-gray-200 dark:border-gray-600">
            <div className="flex items-center justify-between mb-3">
                <div className="flex items-center gap-3">
                    <button {...listeners} {...attributes} className="cursor-grab text-gray-500 hover:text-gray-700">
                        โ˜ฐ
                    </button>
                    <h3 className="text-lg font-semibold text-gray-900 dark:text-white capitalize">{section.type}</h3>
                </div>
                <div className="flex gap-2">
                    <button
                        onClick={() => setIsEditing(!isEditing)}
                        className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
                    >
                        {isEditing ? 'Cancel' : 'Edit'}
                    </button>
                    <button
                        onClick={handleDelete}
                        className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
                    >
                        Delete
                    </button>
                </div>
            </div>

            {isEditing ? (
                <div className="space-y-3">
                    {section.type === 'hero' && (
                        <>
                            <input
                                type="text"
                                placeholder="Title"
                                value={content.title || ''}
                                onChange={(e) => setContent({ ...content, title: e.target.value })}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                            />
                            <input
                                type="text"
                                placeholder="Subtitle"
                                value={content.subtitle || ''}
                                onChange={(e) => setContent({ ...content, subtitle: e.target.value })}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                            />
                        </>
                    )}
                    {section.type === 'about' && (
                        <textarea
                            placeholder="About text"
                            value={content.text || ''}
                            onChange={(e) => setContent({ ...content, text: e.target.value })}
                            className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                            rows="4"
                        />
                    )}
                    {section.type === 'projects' && (
                        <div>
                            <p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Projects (JSON array of objects with title, description, image, link)</p>
                            <textarea
                                value={JSON.stringify(content.items || [], null, 2)}
                                onChange={(e) => {
                                    try {
                                        setContent({ ...content, items: JSON.parse(e.target.value) });
                                    } catch {}
                                }}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 font-mono text-sm"
                                rows="6"
                            />
                        </div>
                    )}
                    {section.type === 'skills' && (
                        <div>
                            <p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Skills (JSON array of objects with name, level)</p>
                            <textarea
                                value={JSON.stringify(content.items || [], null, 2)}
                                onChange={(e) => {
                                    try {
                                        setContent({ ...content, items: JSON.parse(e.target.value) });
                                    } catch {}
                                }}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 font-mono text-sm"
                                rows="6"
                            />
                        </div>
                    )}
                    {section.type === 'contact' && (
                        <>
                            <input
                                type="email"
                                placeholder="Email"
                                value={content.email || ''}
                                onChange={(e) => setContent({ ...content, email: e.target.value })}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                            />
                            <input
                                type="tel"
                                placeholder="Phone"
                                value={content.phone || ''}
                                onChange={(e) => setContent({ ...content, phone: e.target.value })}
                                className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
                            />
                        </>
                    )}
                    <button
                        onClick={handleSave}
                        className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
                    >
                        Save Changes
                    </button>
                </div>
            ) : (
                <div className="text-sm text-gray-600 dark:text-gray-400">
                    <pre className="whitespace-pre-wrap">{JSON.stringify(content, null, 2)}</pre>
                </div>
            )}
        </div>
    );
}
```

์ด์ œ ํŽธ์ง‘๊ธฐ์—์„œ ์„น์…˜์„ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ์ˆœ์„œ๋ฅผ ๋ณ€๊ฒฝํ•˜๊ณ , ๊ฐ ์„น์…˜์˜ ๋‚ด์šฉ์„ ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค!

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: SortableSection ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , ๊ฐ ์„น์…˜ ํƒ€์ž…(hero, about, projects, skills, contact)์— ๋งž๋Š” ํŽธ์ง‘ ํผ์ด ๋ Œ๋”๋ง๋ฉ๋‹ˆ๋‹ค. ๋“œ๋ž˜๊ทธ ํ•ธ๋“ค(โ˜ฐ)์„ ํด๋ฆญํ•˜์—ฌ ์„น์…˜ ์ˆœ์„œ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๊ณ , Edit/Delete ๋ฒ„ํŠผ์œผ๋กœ ์„น์…˜์„ ์ˆ˜์ •/์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

6
Step 6
๊ณต๊ฐœ ํฌํŠธํด๋ฆฌ์˜ค ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •ํ•œ ํ…Œ๋งˆ์™€ ์„น์…˜ ์ˆœ์„œ์— ๋”ฐ๋ผ ํฌํŠธํด๋ฆฌ์˜ค๋ฅผ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

**๊ณต๊ฐœ ํŽ˜์ด์ง€** (`resources/js/Pages/Portfolio/Show.jsx`):

```jsx
import { Head } from '@inertiajs/react';

export default function Show({ portfolio }) {
    const themeClasses = {
        default: 'bg-white text-gray-900',
        dark: 'bg-gray-900 text-white',
        colorful: 'bg-gradient-to-br from-purple-500 to-pink-500 text-white',
    };

    return (
        <>
            <Head title={portfolio.title} />
            <div className={`min-h-screen ${themeClasses[portfolio.theme]}`}>
                {/* Header */}
                <header className="py-8 px-6 text-center border-b border-gray-200 dark:border-gray-700">
                    <h1 className="text-4xl font-bold">{portfolio.title}</h1>
                    <p className="mt-2 text-lg opacity-80">{portfolio.description}</p>
                </header>

                {/* Sections */}
                <main className="max-w-5xl mx-auto py-12 px-6 space-y-16">
                    {portfolio.sections.map((section) => (
                        <section key={section.id} className="animate-fade-in">
                            {section.type === 'hero' && (
                                <div className="text-center">
                                    <h2 className="text-5xl font-bold mb-4">{section.content.title}</h2>
                                    <p className="text-2xl opacity-80">{section.content.subtitle}</p>
                                </div>
                            )}

                            {section.type === 'about' && (
                                <div>
                                    <h2 className="text-3xl font-bold mb-4">About Me</h2>
                                    <p className="text-lg leading-relaxed">{section.content.text}</p>
                                </div>
                            )}

                            {section.type === 'projects' && (
                                <div>
                                    <h2 className="text-3xl font-bold mb-6">Projects</h2>
                                    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
                                        {section.content.items?.map((project, idx) => (
                                            <div key={idx} className="border border-gray-300 dark:border-gray-700 rounded-lg p-6 hover:shadow-lg transition">
                                                {project.image && (
                                                    <img src={project.image} alt={project.title} className="w-full h-48 object-cover rounded-md mb-4" />
                                                )}
                                                <h3 className="text-xl font-semibold mb-2">{project.title}</h3>
                                                <p className="opacity-80 mb-3">{project.description}</p>
                                                {project.link && (
                                                    <a href={project.link} target="_blank" className="text-blue-500 underline hover:text-blue-600">
                                                        View Project โ†’
                                                    </a>
                                                )}
                                            </div>
                                        ))}
                                    </div>
                                </div>
                            )}

                            {section.type === 'skills' && (
                                <div>
                                    <h2 className="text-3xl font-bold mb-6">Skills</h2>
                                    <div className="space-y-4">
                                        {section.content.items?.map((skill, idx) => (
                                            <div key={idx}>
                                                <div className="flex justify-between mb-1">
                                                    <span className="font-medium">{skill.name}</span>
                                                    <span className="opacity-80">{skill.level}%</span>
                                                </div>
                                                <div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-3">
                                                    <div
                                                        className="bg-blue-600 h-3 rounded-full transition-all duration-500"
                                                        style={{ width: `${skill.level}%` }}
                                                    />
                                                </div>
                                            </div>
                                        ))}
                                    </div>
                                </div>
                            )}

                            {section.type === 'contact' && (
                                <div className="text-center">
                                    <h2 className="text-3xl font-bold mb-6">Contact Me</h2>
                                    <div className="space-y-2">
                                        {section.content.email && (
                                            <p>
                                                <span className="font-medium">Email:</span>{' '}
                                                <a href={`mailto:${section.content.email}`} className="text-blue-500 underline hover:text-blue-600">
                                                    {section.content.email}
                                                </a>
                                            </p>
                                        )}
                                        {section.content.phone && (
                                            <p>
                                                <span className="font-medium">Phone:</span> {section.content.phone}
                                            </p>
                                        )}
                                    </div>
                                </div>
                            )}
                        </section>
                    ))}
                </main>

                {/* Footer */}
                <footer className="py-6 text-center border-t border-gray-200 dark:border-gray-700 opacity-60">
                    <p>ยฉ {new Date().getFullYear()} {portfolio.title}. Built with Portfolio Builder.</p>
                </footer>
            </div>
        </>
    );
}
```

**์• ๋‹ˆ๋ฉ”์ด์…˜ ์ถ”๊ฐ€** (`resources/css/app.css`):

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer utilities {
    .animate-fade-in {
        animation: fadeIn 0.8s ease-in-out;
    }
}

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: translateY(20px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}
```

์ด์ œ `/portfolio/{slug}` ๊ฒฝ๋กœ๋กœ ์ ‘์†ํ•˜๋ฉด ๊ณต๊ฐœ ํฌํŠธํด๋ฆฌ์˜ค๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค!

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Show.jsx ํŽ˜์ด์ง€๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , ํฌํŠธํด๋ฆฌ์˜ค์˜ ํ…Œ๋งˆ(default, dark, colorful)์— ๋”ฐ๋ผ ๋ฐฐ๊ฒฝ์ƒ‰๊ณผ ํ…์ŠคํŠธ ์ƒ‰์ƒ์ด ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค. ๊ฐ ์„น์…˜(hero, about, projects, skills, contact)์ด ์ˆœ์„œ๋Œ€๋กœ ๋ Œ๋”๋ง๋˜๊ณ , fade-in ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!