๐จ ์จ๋ผ์ธ ํฌํธํด๋ฆฌ์ค ๋น๋ - ๋๋๊ทธ ์ค ๋๋กญ ์น์ ํธ์ง๊ธฐ
portfolio-builder-drag-drop
๋ฌธ์ ์ค๋ช
๐จ ์จ๋ผ์ธ ํฌํธํด๋ฆฌ์ค ๋น๋ - ๋๋๊ทธ ์ค ๋๋กญ ์น์ ํธ์ง๊ธฐ
ํ๋ฆฌ๋์, ๋์์ด๋, ๊ฐ๋ฐ์๋ฅผ ์ํ ๋น์ฃผ์ผ ํฌํธํด๋ฆฌ์ค ๋น๋์ ๋๋ค. ์ฌ์ฉ์๊ฐ ๋๋๊ทธ ์ค ๋๋กญ์ผ๋ก ์น์ ์์๋ฅผ ์กฐ์ ํ๊ณ , ์ค์๊ฐ์ผ๋ก ํ๋ฆฌ๋ทฐ๋ฅผ ํ์ธํ๋ฉฐ, ์์ฑ๋ ํฌํธํด๋ฆฌ์ค๋ฅผ ๊ณต๊ฐ 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์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
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๊ฐ ์คํ ์ค์ ๋๋ค.
ํฌํธํด๋ฆฌ์ค ๋น๋๋ฅผ ์ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ตฌ์กฐ๋ฅผ ์ค๊ณํฉ๋๋ค.
๋ค์ ๋ ๊ฐ์ ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์ ์์ฑํ์ธ์:
```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 ํ์ ์ผ๋ก ์บ์คํ ๋ฉ๋๋ค.
ํฌํธํด๋ฆฌ์ค ํธ์ง๊ธฐ์ ํต์ฌ ์ปจํธ๋กค๋ฌ์ ๋ผ์ฐํธ๋ฅผ ์ค์ ํฉ๋๋ค.
**์ปจํธ๋กค๋ฌ ์์ฑ**:
```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 ์ปดํฌ๋ํธ์ ๋ฐ์ดํฐ๋ฅผ ์ ๋ฌํฉ๋๋ค.
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 ํจ์๊ฐ ์์ ๋ณ๊ฒฝ์ ์ฒ๋ฆฌํฉ๋๋ค.
๋๋๊ทธ ๊ฐ๋ฅํ ์น์
์ปดํฌ๋ํธ์ ์น์
๋ณ ํธ์ง ํผ์ ๊ตฌํํฉ๋๋ค.
**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 ๋ฒํผ์ผ๋ก ์น์ ์ ์์ /์ญ์ ํ ์ ์์ต๋๋ค.
๊ณต๊ฐ ํฌํธํด๋ฆฌ์ค ํ์ด์ง๋ฅผ ๊ตฌํํฉ๋๋ค. ์ฌ์ฉ์๊ฐ ์ค์ ํ ํ
๋ง์ ์น์
์์์ ๋ฐ๋ผ ํฌํธํด๋ฆฌ์ค๋ฅผ ๋ ๋๋งํฉ๋๋ค.
**๊ณต๊ฐ ํ์ด์ง** (`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)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!