๐ฏ g5se ์จ๋ผ์ธ ๋ ์ํผ ๋ถ - ๋๋ง์ ์๋ฆฌ ์์นด์ด๋ธ & ์ฌ๋ฃ ๊ฒ์
g5se-recipe-book-archive
๋ฌธ์ ์ค๋ช
๐ ํ๋ก์ ํธ ๊ฐ์
์์ฃผ ๋ง๋๋ ์๋ฆฌ ๋ ์ํผ๋ฅผ ์ฒด๊ณ์ ์ผ๋ก ์ ๋ฆฌํ๊ณ , ๋์ฅ๊ณ ์ ์๋ ์ฌ๋ฃ๋ก ๋ง๋ค ์ ์๋ ์๋ฆฌ๋ฅผ ๊ฒ์ํ ์ ์๋ ๊ฐ์ธ ๋ ์ํผ ๋ถ์ ๋ง๋ญ๋๋ค. ์๋ฆฌ ์ฌ์ง, ์ฌ๋ฃ ๋ชฉ๋ก, ์กฐ๋ฆฌ ์์๋ฅผ ๊ธฐ๋กํ๊ณ , ํ๊ทธ์ ์ฌ๋ฃ ๊ธฐ๋ฐ ํํฐ๋ง์ผ๋ก ์ํ๋ ๋ ์ํผ๋ฅผ ๋น ๋ฅด๊ฒ ์ฐพ์ ์ ์์ต๋๋ค.
โจ ํ์ ๊ธฐ๋ฅ
๋ ์ํผ ๊ด๋ฆฌ
- ๋ ์ํผ ๋ฑ๋ก (์ ๋ชฉ, ์ค๋ช , ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋)
- ์ฌ๋ฃ ๋ชฉ๋ก ์ ๋ ฅ (์ฌ๋ฃ๋ช , ์๋, ๋จ์)
- ์กฐ๋ฆฌ ๋จ๊ณ๋ณ ์ค๋ช (์์๋๋ก ํ ์คํธ ์ ๋ ฅ)
- ์์ฑ ์ฌ์ง ์ ๋ก๋ (์ ํ)
- ํ๊ทธ ์ถ๊ฐ (ํ์, ์์, ๊ฐํธ์, ๋์ ํธ ๋ฑ)
๋ ์ํผ ๊ฒ์ & ํํฐ๋ง
- ์ฌ๋ฃ ๊ธฐ๋ฐ ๊ฒ์ ("๊ณ๋, ์ํ"๋ก ๊ฒ์ โ ๋ ์ฌ๋ฃ๊ฐ ๋ชจ๋ ํฌํจ๋ ๋ ์ํผ ํ์)
- ํ๊ทธ ํํฐ๋ง (ํ์๋ง ๋ณด๊ธฐ, ๋์ ํธ๋ง ๋ณด๊ธฐ)
- ๋์ด๋/์กฐ๋ฆฌ์๊ฐ ํํฐ
- ๋ ์ํผ ์นด๋ ๊ทธ๋ฆฌ๋ ๋ ์ด์์ (์ธ๋ค์ผ + ์ ๋ชฉ + ํ๊ทธ)
๋ ์ํผ ์์ธ ํ์ด์ง
- ์ฌ๋ฃ ์ฒดํฌ๋ฆฌ์คํธ (์ค๋นํ ์ฌ๋ฃ ์ฒดํฌ ๊ธฐ๋ฅ)
- ์กฐ๋ฆฌ ๋จ๊ณ ํผ์ณ๋ณด๊ธฐ
- ๋ฉ๋ชจ ๋จ๊ธฐ๊ธฐ ("๋ค์์ ์คํ ๋ ๋ฃ๊ธฐ" ๊ฐ์ ๊ฐ์ธ ๋ ธํธ)
- ์ฆ๊ฒจ์ฐพ๊ธฐ ํ์
๋์๋ณด๋
- ์ต๊ทผ ์ถ๊ฐํ ๋ ์ํผ 3๊ฐ
- ์ฆ๊ฒจ์ฐพ๊ธฐํ ๋ ์ํผ ๋ชฉ๋ก
- ์ ์ฒด ๋ ์ใ ์ ํต๊ณ
๐ ๋ณด๋์ค ๊ธฐ๋ฅ
- ๋ ์ํผ ๊ณต๊ฐ/๋น๊ณต๊ฐ ์ค์
- ๋ ์ํผ ์ธ์ ๋ชจ๋ (A4 ์ฉ์ง์ ์ต์ ํ๋ ๋ ์ด์์)
- ์ฌ๋ฃ ์๋์์ฑ (์ด์ ์ ์ ๋ ฅํ ์ฌ๋ฃ๋ช ์ ์)
- ์กฐ๋ฆฌ ํ์ด๋จธ (๋ ์ํผ ์์ธ ํ์ด์ง์์ ๋จ๊ณ๋ณ ํ์ด๋จธ ์คํ)
- ๋ ์ํผ ํ์ (๋ณ์ 5์ ๋ง์ )
๐จ ๋์์ธ ํฌ์ธํธ
- ๋ ์ํผ ์นด๋๋ Pinterest ์คํ์ผ ๊ทธ๋ฆฌ๋ (masonry layout)
- ์ฌ๋ฃ ์ ๋ ฅ ์ ๋์ ์ผ๋ก ํ ์ถ๊ฐ/์ญ์ ๊ฐ๋ฅํ ํผ
- ์กฐ๋ฆฌ ๋จ๊ณ๋ ๋ฒํธ ๋ฑ์ง์ ํจ๊ป ๊น๋ํ๊ฒ ์ ๋ ฌ
- ๋คํฌ๋ชจ๋์์๋ ์์ ์ฌ์ง์ด ์ ๋ณด์ด๋๋ก ์นด๋ ๋ฐฐ๊ฒฝ ๋๋น ์กฐ์
- ์ฌ๋ฃ ๊ฒ์ ์ ๋ ฅ์ฐฝ์ ์ผํ๋ก ๊ตฌ๋ถํ์ฌ ์ฌ๋ฌ ์ฌ๋ฃ ์ ๋ ฅ ๊ฐ๋ฅ
์ ์ฝ ์กฐ๊ฑด
- โข g5se ํ๋ ์์ํฌ ๊ธฐ๋ฐ (Laravel/React/Vue ์ฌ์ฉ ๊ธ์ง)
- โข ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ ์ฌ์ฉ (app/modules/recipes/index.php โ /recipes)
- โข modern shell (_head.inc.php + m-shell + _nav.inc.php + m-container + _footer.inc.php) ์ ์ฉ ํ์
- โข ๋์์ธ ํ ํฐ ์ฌ์ฉ (--m-surface, --m-text, --m-shadow, --m-radius-lg ๋ฑ)
- โข SQLite DB ์ฌ์ฉ (G5_DATA_PATH/modules/recipes.sqlite)
- โข ์ด๋ฏธ์ง ์ ๋ก๋๋ G5_DATA_PATH/modules/recipes/ ํด๋์ ์ ์ฅ
- โข prepared statement๋ก SQL ์ธ์ ์ ๋ฐฉ์ง
- โข htmlspecialchars()๋ก XSS ๋ฐฉ์ง
- โข ์ธ๋ถ ๋น๋ ๋๊ตฌ(npm/webpack) ์ฌ์ฉ ๊ธ์ง
- โข ์์ HTML/CSS/JS๋ก ๊ตฌํ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
**๋ชฉํ:** ๋ ์ํผ ๋ถ ๋ฉ์ธ ํ์ด์ง ๊ณจ๊ฒฉ ์์ฑ + DB ์ด๊ธฐํ
---
## 1) ํด๋ & ํ์ผ ์์ฑ
`app/modules/recipes/index.php` ํ์ผ์ ๋ง๋ค์ด์ฃผ์ธ์. ์ด ํ์ผ์ `/recipes` URL๋ก ์๋ ๋งคํ๋ฉ๋๋ค.
## 2) modern shell ๊ณจ๊ฒฉ ์ ์ฉ
์๋ ๊ตฌ์กฐ๋ก ํ์ด์ง๋ฅผ ๋ง๋ค์ด์ฃผ์ธ์:
```php
<?php
include_once('./_common.php');
// DB ์ด๊ธฐํ (์ฒซ ์ ๊ทผ ์ ํ
์ด๋ธ ์๋ ์์ฑ)
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
@mkdir(G5_DATA_PATH.'/modules/recipes', 0707, true);
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/recipes.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// recipes ํ
์ด๋ธ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
cooking_time INTEGER,
difficulty TEXT,
servings INTEGER,
image TEXT,
tags TEXT,
favorite INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
");
// ingredients ํ
์ด๋ธ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
name TEXT NOT NULL,
quantity TEXT,
unit TEXT,
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
)
");
// steps ํ
์ด๋ธ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
step_number INTEGER NOT NULL,
description TEXT NOT NULL,
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
)
");
// notes ํ
์ด๋ธ ์์ฑ
$db->exec("
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
)
");
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="recipe-header" style="text-align: center; margin-bottom: 40px;">
<h1 style="font-size: 2.5rem; font-weight: 700; color: var(--m-text); margin-bottom: 8px;">๐ณ ๋๋ง์ ๋ ์ํผ ๋ถ</h1>
<p style="font-size: 1.1rem; color: var(--m-text-muted);">๋ง์๋ ์๋ฆฌ๋ฅผ ๊ธฐ๋กํ๊ณ , ์ฌ๋ฃ๋ก ๊ฒ์ํ์ธ์</p>
</div>
<div class="dashboard-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 40px;">
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">๐</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);">0</div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ ์ฒด ๋ ์ํผ</div>
</div>
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">โญ</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);">0</div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ฆ๊ฒจ์ฐพ๊ธฐ</div>
</div>
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">๐ฅ</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);">0</div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ต๊ทผ 7์ผ</div>
</div>
</div>
<div style="text-align: center; padding: 60px 20px; background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 4rem; margin-bottom: 16px;">๐ฝ๏ธ</div>
<p style="font-size: 1.2rem; color: var(--m-text-muted); margin-bottom: 24px;">์์ง ๋ฑ๋ก๋ ๋ ์ํผ๊ฐ ์์ต๋๋ค</p>
<a href="/recipes/add" style="display: inline-block; background: #FF6B35; color: white; padding: 12px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">์ฒซ ๋ ์ํผ ์ถ๊ฐํ๊ธฐ</a>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
## 3) ํ์ธ
๋ธ๋ผ์ฐ์ ์์ `/recipes`์ ์ ์ํ๋ฉด ๋์๋ณด๋ ํ๋ฉด์ด ํ์๋์ด์ผ ํฉ๋๋ค. DB ํ์ผ(`G5_DATA_PATH/modules/recipes.sqlite`)์ด ์๋ ์์ฑ๋๊ณ , 4๊ฐ ํ
์ด๋ธ(recipes, ingredients, steps, notes)์ด ๋ง๋ค์ด์ง๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: - `/recipes` URL๋ก ์ ์ ๊ฐ๋ฅ - modern shell ์ ์ฉ (์๋จ ๋ค๋น๊ฒ์ด์ , ํธํฐ, ๋คํฌ๋ชจ๋ ํ ๊ธ ํฌํจ) - ๋์๋ณด๋์ ํต๊ณ ์นด๋ 3๊ฐ (์ ์ฒด ๋ ์ํผ, ์ฆ๊ฒจ์ฐพ๊ธฐ, ์ต๊ทผ 7์ผ) ํ์ - ๋น ์ํ ๋ฉ์์ง + "์ฒซ ๋ ์ํผ ์ถ๊ฐํ๊ธฐ" ๋ฒํผ - SQLite DB ํ์ผ ๋ฐ ํ ์ด๋ธ ์๋ ์์ฑ ์๋ฃ
**๋ชฉํ:** ๋ ์ํผ ์ถ๊ฐ ํ์ด์ง ๊ตฌํ (์ ๋ชฉ, ์ค๋ช
, ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋, ํ๊ทธ ์
๋ ฅ)
---
## 1) ํด๋ ์์ฑ
`app/modules/recipes/add/index.php` ํ์ผ์ ๋ง๋ค์ด์ฃผ์ธ์. ์ด ํ์ผ์ `/recipes/add` URL๋ก ์๋ ๋งคํ๋ฉ๋๋ค.
## 2) ๋ ์ํผ ์ถ๊ฐ ํผ ๊ตฌํ
์๋ ์ฝ๋๋ก ํ์ด์ง๋ฅผ ๋ง๋ค์ด์ฃผ์ธ์:
```php
<?php
include_once('./_common.php');
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
@mkdir(G5_DATA_PATH.'/modules/recipes', 0707, true);
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/recipes.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$cooking_time = intval($_POST['cooking_time'] ?? 0);
$difficulty = $_POST['difficulty'] ?? 'easy';
$servings = intval($_POST['servings'] ?? 1);
$tags = trim($_POST['tags'] ?? '');
if (empty($title)) {
$error = '๋ ์ํผ ์ ๋ชฉ์ ์
๋ ฅํด์ฃผ์ธ์.';
} else {
// ์ด๋ฏธ์ง ์
๋ก๋ ์ฒ๋ฆฌ
$image_path = '';
if (!empty($_FILES['image']['name'])) {
$upload_dir = G5_DATA_PATH.'/modules/recipes/';
$ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
if (in_array($ext, $allowed)) {
$filename = uniqid('recipe_').'.'.$ext;
if (move_uploaded_file($_FILES['image']['tmp_name'], $upload_dir.$filename)) {
$image_path = $filename;
}
}
}
$stmt = $db->prepare("
INSERT INTO recipes (title, description, cooking_time, difficulty, servings, image, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$title, $description, $cooking_time, $difficulty, $servings, $image_path, $tags]);
$recipe_id = $db->lastInsertId();
// ์ฌ๋ฃ ์ ์ฅ
if (!empty($_POST['ingredients'])) {
foreach ($_POST['ingredients'] as $ing) {
if (!empty($ing['name'])) {
$stmt = $db->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit) VALUES (?, ?, ?, ?)");
$stmt->execute([$recipe_id, $ing['name'], $ing['quantity'] ?? '', $ing['unit'] ?? '']);
}
}
}
// ์กฐ๋ฆฌ ๋จ๊ณ ์ ์ฅ
if (!empty($_POST['steps'])) {
foreach ($_POST['steps'] as $idx => $step_desc) {
if (!empty($step_desc)) {
$stmt = $db->prepare("INSERT INTO steps (recipe_id, step_number, description) VALUES (?, ?, ?)");
$stmt->execute([$recipe_id, $idx + 1, $step_desc]);
}
}
}
$success = '๋ ์ํผ๊ฐ ๋ฑ๋ก๋์์ต๋๋ค!';
header('Location: /recipes');
exit;
}
}
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div style="max-width: 800px; margin: 0 auto;">
<h1 style="font-size: 2rem; font-weight: 700; color: var(--m-text); margin-bottom: 24px;">์ ๋ ์ํผ ์ถ๊ฐ</h1>
<?php if ($error): ?>
<div style="background: #fee; color: #c33; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px;">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data" style="background: var(--m-surface); padding: 32px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๋ ์ํผ ์ ๋ชฉ *</label>
<input type="text" name="title" required style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">์ค๋ช
</label>
<textarea name="description" rows="3" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 20px;">
<div>
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">์กฐ๋ฆฌ์๊ฐ(๋ถ)</label>
<input type="number" name="cooking_time" min="1" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
</div>
<div>
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๋์ด๋</label>
<select name="difficulty" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
<option value="easy">์ฌ์</option>
<option value="medium">๋ณดํต</option>
<option value="hard">์ด๋ ค์</option>
</select>
</div>
<div>
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๋ถ๋(์ธ๋ถ)</label>
<input type="number" name="servings" min="1" value="2" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">์์ฑ ์ฌ์ง</label>
<input type="file" name="image" accept="image/*" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">ํ๊ทธ (์ผํ๋ก ๊ตฌ๋ถ)</label>
<input type="text" name="tags" placeholder="์: ํ์, ๊ฐํธ์, ์ ๋
" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 12px;">์ฌ๋ฃ</label>
<div id="ingredients-container"></div>
<button type="button" onclick="addIngredient()" style="margin-top: 8px; padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 6px; cursor: pointer;">+ ์ฌ๋ฃ ์ถ๊ฐ</button>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 12px;">์กฐ๋ฆฌ ์์</label>
<div id="steps-container"></div>
<button type="button" onclick="addStep()" style="margin-top: 8px; padding: 8px 16px; background: #2196F3; color: white; border: none; border-radius: 6px; cursor: pointer;">+ ๋จ๊ณ ์ถ๊ฐ</button>
</div>
<div style="display: flex; gap: 12px;">
<button type="submit" style="flex: 1; padding: 14px; background: #FF6B35; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;">๋ ์ํผ ์ ์ฅ</button>
<a href="/recipes" style="flex: 1; padding: 14px; background: #666; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; text-align: center; text-decoration: none; display: block;">์ทจ์</a>
</div>
</form>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<script>
let ingredientCount = 0;
let stepCount = 0;
function addIngredient() {
const container = document.getElementById('ingredients-container');
const div = document.createElement('div');
div.style.cssText = 'display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 8px; margin-bottom: 8px;';
div.innerHTML = `
<input type="text" name="ingredients[${ingredientCount}][name]" placeholder="์ฌ๋ฃ๋ช
" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; background: var(--m-surface); color: var(--m-text);">
<input type="text" name="ingredients[${ingredientCount}][quantity]" placeholder="์๋" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; background: var(--m-surface); color: var(--m-text);">
<input type="text" name="ingredients[${ingredientCount}][unit]" placeholder="๋จ์" style="padding: 10px; border: 1px solid #ddd; border-radius: 6px; background: var(--m-surface); color: var(--m-text);">
<button type="button" onclick="this.parentElement.remove()" style="padding: 10px 14px; background: #f44336; color: white; border: none; border-radius: 6px; cursor: pointer;">ร</button>
`;
container.appendChild(div);
ingredientCount++;
}
function addStep() {
const container = document.getElementById('steps-container');
const div = document.createElement('div');
div.style.cssText = 'display: flex; gap: 8px; margin-bottom: 8px;';
div.innerHTML = `
<div style="padding: 10px; background: #FF6B35; color: white; border-radius: 6px; font-weight: 600; min-width: 40px; text-align: center;">${stepCount + 1}</div>
<textarea name="steps[${stepCount}]" placeholder="์กฐ๋ฆฌ ๋ฐฉ๋ฒ์ ์
๋ ฅํ์ธ์" rows="2" style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; background: var(--m-surface); color: var(--m-text);"></textarea>
<button type="button" onclick="this.parentElement.remove(); renumberSteps()" style="padding: 10px 14px; background: #f44336; color: white; border: none; border-radius: 6px; cursor: pointer;">ร</button>
`;
container.appendChild(div);
stepCount++;
}
function renumberSteps() {
const steps = document.querySelectorAll('#steps-container > div');
steps.forEach((step, idx) => {
step.querySelector('div').textContent = idx + 1;
});
}
// ์ด๊ธฐ ์ฌ๋ฃ/๋จ๊ณ 1๊ฐ์ฉ ์ถ๊ฐ
addIngredient();
addStep();
</script>
<?php include_once('./tail.sub.php'); ?>
```
## 3) ํ์ธ
- `/recipes/add`์ ์ ์ํ๋ฉด ๋ ์ํผ ์ถ๊ฐ ํผ์ด ํ์๋ฉ๋๋ค
- ์ฌ๋ฃ/๋จ๊ณ ์ถ๊ฐ ๋ฒํผ์ ํด๋ฆญํ๋ฉด ๋์ ์ผ๋ก ์
๋ ฅ ํ๋๊ฐ ์ถ๊ฐ๋ฉ๋๋ค
- ํผ ์ ์ถ ์ DB์ ์ ์ฅ๋๊ณ `/recipes`๋ก ๋ฆฌ๋ค์ด๋ ํธ๋ฉ๋๋ค
๊ธฐ๋ ๊ฒฐ๊ณผ: - ๋ ์ํผ ์ถ๊ฐ ํผ ํ์ด์ง ๋์ - ์ ๋ชฉ, ์ค๋ช , ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋, ํ๊ทธ ์ ๋ ฅ ๊ฐ๋ฅ - ์ด๋ฏธ์ง ์ ๋ก๋ ๊ธฐ๋ฅ (ํ์ผ ์ ์ฅ) - ์ฌ๋ฃ ๋์ ์ถ๊ฐ/์ญ์ (JavaScript) - ์กฐ๋ฆฌ ๋จ๊ณ ๋์ ์ถ๊ฐ/์ญ์ (๋ฒํธ ์๋ ๋ถ์ฌ) - ํผ ์ ์ถ ์ DB์ ๋ ์ํผ, ์ฌ๋ฃ, ๋จ๊ณ ์ ์ฅ - ์ ์ฅ ํ ๋ฉ์ธ ํ์ด์ง๋ก ์ด๋
**๋ชฉํ:** ๋ฉ์ธ ํ์ด์ง์ ๋ ์ํผ ๋ชฉ๋ก ํ์ (์นด๋ ๊ทธ๋ฆฌ๋ ๋ ์ด์์ + ํต๊ณ ์
๋ฐ์ดํธ)
---
## 1) `app/modules/recipes/index.php` ์์
๊ธฐ์กด ํ์ผ์ ์๋ ์ฝ๋๋ก ๊ต์ฒดํด์ฃผ์ธ์:
```php
<?php
include_once('./_common.php');
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
@mkdir(G5_DATA_PATH.'/modules/recipes', 0707, true);
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/recipes.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํต๊ณ ๊ณ์ฐ
$total = $db->query("SELECT COUNT(*) FROM recipes")->fetchColumn();
$favorites = $db->query("SELECT COUNT(*) FROM recipes WHERE favorite = 1")->fetchColumn();
$recent = $db->query("SELECT COUNT(*) FROM recipes WHERE created_at >= datetime('now', '-7 days')")->fetchColumn();
// ๋ ์ํผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
$recipes = $db->query("
SELECT * FROM recipes ORDER BY created_at DESC
")->fetchAll(PDO::FETCH_ASSOC);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div class="recipe-header" style="text-align: center; margin-bottom: 40px;">
<h1 style="font-size: 2.5rem; font-weight: 700; color: var(--m-text); margin-bottom: 8px;">๐ณ ๋๋ง์ ๋ ์ํผ ๋ถ</h1>
<p style="font-size: 1.1rem; color: var(--m-text-muted);">๋ง์๋ ์๋ฆฌ๋ฅผ ๊ธฐ๋กํ๊ณ , ์ฌ๋ฃ๋ก ๊ฒ์ํ์ธ์</p>
</div>
<div class="dashboard-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 40px;">
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">๐</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);"><?= $total ?></div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ ์ฒด ๋ ์ํผ</div>
</div>
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">โญ</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);"><?= $favorites ?></div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ฆ๊ฒจ์ฐพ๊ธฐ</div>
</div>
<div class="stat-card" style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 2rem; margin-bottom: 8px;">๐ฅ</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--m-text);"><?= $recent ?></div>
<div style="font-size: 0.9rem; color: var(--m-text-muted);">์ต๊ทผ 7์ผ</div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="font-size: 1.5rem; font-weight: 700; color: var(--m-text);">๋ ์ํผ ๋ชฉ๋ก</h2>
<a href="/recipes/add" style="padding: 10px 20px; background: #FF6B35; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">+ ์ ๋ ์ํผ</a>
</div>
<?php if (empty($recipes)): ?>
<div style="text-align: center; padding: 60px 20px; background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 4rem; margin-bottom: 16px;">๐ฝ๏ธ</div>
<p style="font-size: 1.2rem; color: var(--m-text-muted); margin-bottom: 24px;">์์ง ๋ฑ๋ก๋ ๋ ์ํผ๊ฐ ์์ต๋๋ค</p>
<a href="/recipes/add" style="display: inline-block; background: #FF6B35; color: white; padding: 12px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">์ฒซ ๋ ์ํผ ์ถ๊ฐํ๊ธฐ</a>
</div>
<?php else: ?>
<div class="recipe-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px;">
<?php foreach ($recipes as $recipe): ?>
<a href="/recipes/view?id=<?= $recipe['id'] ?>" style="text-decoration: none; color: inherit;">
<div class="recipe-card" style="background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); overflow: hidden; transition: transform 0.2s;" onmouseover="this.style.transform='translateY(-4px)'" onmouseout="this.style.transform='translateY(0)'">
<?php if ($recipe['image']): ?>
<img src="<?= G5_DATA_URL ?>/modules/recipes/<?= htmlspecialchars($recipe['image']) ?>" alt="<?= htmlspecialchars($recipe['title']) ?>" style="width: 100%; height: 200px; object-fit: cover;">
<?php else: ?>
<div style="width: 100%; height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; font-size: 4rem;">๐ณ</div>
<?php endif; ?>
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--m-text); margin: 0;"><?= htmlspecialchars($recipe['title']) ?></h3>
<?php if ($recipe['favorite']): ?>
<span style="font-size: 1.2rem;">โญ</span>
<?php endif; ?>
</div>
<?php if ($recipe['description']): ?>
<p style="color: var(--m-text-muted); font-size: 0.9rem; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
<?= htmlspecialchars($recipe['description']) ?>
</p>
<?php endif; ?>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;">
<?php if ($recipe['cooking_time']): ?>
<span style="font-size: 0.85rem; color: var(--m-text-muted);">โฑ๏ธ <?= $recipe['cooking_time'] ?>๋ถ</span>
<?php endif; ?>
<?php if ($recipe['difficulty']): ?>
<span style="font-size: 0.85rem; color: var(--m-text-muted);">
<?php
$diff_emoji = ['easy' => '๐', 'medium' => '๐ค', 'hard' => '๐ฐ'];
echo $diff_emoji[$recipe['difficulty']] ?? '';
?>
<?= ['easy' => '์ฌ์', 'medium' => '๋ณดํต', 'hard' => '์ด๋ ค์'][$recipe['difficulty']] ?? '' ?>
</span>
<?php endif; ?>
<?php if ($recipe['servings']): ?>
<span style="font-size: 0.85rem; color: var(--m-text-muted);">๐ฅ <?= $recipe['servings'] ?>์ธ๋ถ</span>
<?php endif; ?>
</div>
<?php if ($recipe['tags']): ?>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<?php foreach (explode(',', $recipe['tags']) as $tag): ?>
<span style="font-size: 0.8rem; padding: 4px 10px; background: #FF6B35; color: white; border-radius: 12px;"><?= htmlspecialchars(trim($tag)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
## 2) ํ์ธ
- `/recipes`์ ์ ์ํ๋ฉด ๋ฑ๋ก๋ ๋ ์ํผ๊ฐ ์นด๋ ํํ๋ก ํ์๋ฉ๋๋ค
- ํต๊ณ ์นด๋์ ์ค์ ๋ฐ์ดํฐ๊ฐ ํ์๋ฉ๋๋ค
- ์นด๋์ ์ด๋ฏธ์ง(์์ผ๋ฉด), ์ ๋ชฉ, ์ค๋ช
, ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋, ํ๊ทธ๊ฐ ํ์๋ฉ๋๋ค
- ์นด๋ ํธ๋ฒ ์ ์ด์ง ์ฌ๋ผ๊ฐ๋ ์ ๋๋ฉ์ด์
์ด ์ ์ฉ๋ฉ๋๋ค
๊ธฐ๋ ๊ฒฐ๊ณผ: - ๋ ์ํผ ์นด๋ ๊ทธ๋ฆฌ๋ ๋ ์ด์์ ํ์ - ์ด๋ฏธ์ง๊ฐ ์์ผ๋ฉด ์ธ๋ค์ผ, ์์ผ๋ฉด ๊ทธ๋ผ๋์ธํธ ๋ฐฐ๊ฒฝ + ์ด๋ชจ์ง - ์ ๋ชฉ, ์ค๋ช (2์ค ๋ง์ค์), ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋, ํ๊ทธ ํ์ - ์ฆ๊ฒจ์ฐพ๊ธฐ ๋ ์ํผ๋ ๋ณ ์์ด์ฝ ํ์ - ํต๊ณ ์นด๋์ ์ค์ ๋ฐ์ดํฐ ๋ฐ์ - ์นด๋ ํด๋ฆญ ์ `/recipes/view?id=X`๋ก ์ด๋ (์์ง ๊ตฌํ ์๋จ)
**๋ชฉํ:** ๋ ์ํผ ์์ธ ํ์ด์ง ๊ตฌํ (์ฌ๋ฃ, ์กฐ๋ฆฌ ๋จ๊ณ, ๋ฉ๋ชจ, ์ฆ๊ฒจ์ฐพ๊ธฐ)
---
## 1) ํด๋ ์์ฑ
`app/modules/recipes/view/index.php` ํ์ผ์ ๋ง๋ค์ด์ฃผ์ธ์. ์ด ํ์ผ์ `/recipes/view` URL๋ก ์๋ ๋งคํ๋ฉ๋๋ค.
## 2) ๋ ์ํผ ์์ธ ํ์ด์ง ๊ตฌํ
์๋ ์ฝ๋๋ก ํ์ด์ง๋ฅผ ๋ง๋ค์ด์ฃผ์ธ์:
```php
<?php
include_once('./_common.php');
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
@mkdir(G5_DATA_PATH.'/modules/recipes', 0707, true);
$db = new PDO('sqlite:'.G5_DATA_PATH.'/modules/recipes.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$id = intval($_GET['id'] ?? 0);
if (!$id) {
header('Location: /recipes');
exit;
}
// AJAX ์์ฒญ ์ฒ๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$action = $_POST['action'] ?? '';
if ($action === 'toggle_favorite') {
$stmt = $db->prepare("UPDATE recipes SET favorite = 1 - favorite WHERE id = ?");
$stmt->execute([$id]);
$new_state = $db->query("SELECT favorite FROM recipes WHERE id = $id")->fetchColumn();
echo json_encode(['success' => true, 'favorite' => $new_state]);
exit;
}
if ($action === 'add_note') {
$content = trim($_POST['content'] ?? '');
if ($content) {
$stmt = $db->prepare("INSERT INTO notes (recipe_id, content) VALUES (?, ?)");
$stmt->execute([$id, $content]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '๋ฉ๋ชจ ๋ด์ฉ์ ์
๋ ฅํ์ธ์']);
}
exit;
}
if ($action === 'delete_note') {
$note_id = intval($_POST['note_id'] ?? 0);
$stmt = $db->prepare("DELETE FROM notes WHERE id = ? AND recipe_id = ?");
$stmt->execute([$note_id, $id]);
echo json_encode(['success' => true]);
exit;
}
}
// ๋ ์ํผ ์ ๋ณด
$stmt = $db->prepare("SELECT * FROM recipes WHERE id = ?");
$stmt->execute([$id]);
$recipe = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$recipe) {
header('Location: /recipes');
exit;
}
// ์ฌ๋ฃ
$stmt = $db->prepare("SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY id");
$stmt->execute([$id]);
$ingredients = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ์กฐ๋ฆฌ ๋จ๊ณ
$stmt = $db->prepare("SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number");
$stmt->execute([$id]);
$steps = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ๋ฉ๋ชจ
$stmt = $db->prepare("SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC");
$stmt->execute([$id]);
$notes = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div style="max-width: 900px; margin: 0 auto;">
<div style="margin-bottom: 24px;">
<a href="/recipes" style="color: var(--m-text-muted); text-decoration: none;">โ ๋ชฉ๋ก์ผ๋ก</a>
</div>
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); overflow: hidden; margin-bottom: 24px;">
<?php if ($recipe['image']): ?>
<img src="<?= G5_DATA_URL ?>/modules/recipes/<?= htmlspecialchars($recipe['image']) ?>" alt="<?= htmlspecialchars($recipe['title']) ?>" style="width: 100%; height: 400px; object-fit: cover;">
<?php endif; ?>
<div style="padding: 32px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px;">
<h1 style="font-size: 2rem; font-weight: 700; color: var(--m-text); margin: 0;"><?= htmlspecialchars($recipe['title']) ?></h1>
<button onclick="toggleFavorite()" id="favorite-btn" style="background: none; border: none; font-size: 2rem; cursor: pointer;">
<?= $recipe['favorite'] ? 'โญ' : 'โ' ?>
</button>
</div>
<?php if ($recipe['description']): ?>
<p style="color: var(--m-text-muted); font-size: 1.1rem; margin-bottom: 20px;"><?= htmlspecialchars($recipe['description']) ?></p>
<?php endif; ?>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<?php if ($recipe['cooking_time']): ?>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.5rem;">โฑ๏ธ</span>
<div>
<div style="font-size: 0.85rem; color: var(--m-text-muted);">์กฐ๋ฆฌ์๊ฐ</div>
<div style="font-weight: 600; color: var(--m-text);"><?= $recipe['cooking_time'] ?>๋ถ</div>
</div>
</div>
<?php endif; ?>
<?php if ($recipe['difficulty']): ?>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.5rem;"><?= ['easy' => '๐', 'medium' => '๐ค', 'hard' => '๐ฐ'][$recipe['difficulty']] ?? '' ?></span>
<div>
<div style="font-size: 0.85rem; color: var(--m-text-muted);">๋์ด๋</div>
<div style="font-weight: 600; color: var(--m-text);"><?= ['easy' => '์ฌ์', 'medium' => '๋ณดํต', 'hard' => '์ด๋ ค์'][$recipe['difficulty']] ?? '' ?></div>
</div>
</div>
<?php endif; ?>
<?php if ($recipe['servings']): ?>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.5rem;">๐ฅ</span>
<div>
<div style="font-size: 0.85rem; color: var(--m-text-muted);">๋ถ๋</div>
<div style="font-weight: 600; color: var(--m-text);"><?= $recipe['servings'] ?>์ธ๋ถ</div>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($recipe['tags']): ?>
<div style="margin-top: 20px; display: flex; gap: 8px; flex-wrap: wrap;">
<?php foreach (explode(',', $recipe['tags']) as $tag): ?>
<span style="font-size: 0.9rem; padding: 6px 14px; background: #FF6B35; color: white; border-radius: 16px;"><?= htmlspecialchars(trim($tag)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($ingredients)): ?>
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); padding: 32px; margin-bottom: 24px;">
<h2 style="font-size: 1.5rem; font-weight: 700; color: var(--m-text); margin-bottom: 20px;">๐ฅ ์ฌ๋ฃ</h2>
<div style="display: grid; gap: 12px;">
<?php foreach ($ingredients as $ing): ?>
<label style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--m-surface); border: 1px solid #ddd; border-radius: 8px; cursor: pointer;">
<input type="checkbox" style="width: 20px; height: 20px;">
<span style="flex: 1; color: var(--m-text); font-weight: 500;"><?= htmlspecialchars($ing['name']) ?></span>
<?php if ($ing['quantity'] || $ing['unit']): ?>
<span style="color: var(--m-text-muted);"><?= htmlspecialchars($ing['quantity']) ?> <?= htmlspecialchars($ing['unit']) ?></span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($steps)): ?>
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); padding: 32px; margin-bottom: 24px;">
<h2 style="font-size: 1.5rem; font-weight: 700; color: var(--m-text); margin-bottom: 20px;">๐จโ๐ณ ์กฐ๋ฆฌ ์์</h2>
<div style="display: grid; gap: 20px;">
<?php foreach ($steps as $step): ?>
<div style="display: flex; gap: 16px;">
<div style="min-width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: #FF6B35; color: white; border-radius: 50%; font-weight: 700; font-size: 1.2rem;"><?= $step['step_number'] ?></div>
<p style="flex: 1; color: var(--m-text); line-height: 1.6; margin: 0; padding-top: 12px;"><?= nl2br(htmlspecialchars($step['description'])) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); padding: 32px;">
<h2 style="font-size: 1.5rem; font-weight: 700; color: var(--m-text); margin-bottom: 20px;">๐ ๋๋ง์ ๋ฉ๋ชจ</h2>
<form onsubmit="addNote(event)" style="margin-bottom: 20px;">
<textarea id="note-input" placeholder="์กฐ๋ฆฌ ํ์ด๋ ๊ฐ์ ์ฌํญ์ ๋ฉ๋ชจํ์ธ์" rows="3" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: var(--m-surface); color: var(--m-text); margin-bottom: 12px;"></textarea>
<button type="submit" style="padding: 10px 24px; background: #4CAF50; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;">๋ฉ๋ชจ ์ถ๊ฐ</button>
</form>
<div id="notes-container">
<?php foreach ($notes as $note): ?>
<div class="note-item" data-id="<?= $note['id'] ?>" style="padding: 16px; background: var(--m-surface); border: 1px solid #ddd; border-radius: 8px; margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: start;">
<p style="flex: 1; color: var(--m-text); margin: 0;"><?= nl2br(htmlspecialchars($note['content'])) ?></p>
<button onclick="deleteNote(<?= $note['id'] ?>)" style="background: none; border: none; color: #f44336; cursor: pointer; font-size: 1.2rem;">ร</button>
</div>
<div style="font-size: 0.85rem; color: var(--m-text-muted); margin-top: 8px;"><?= date('Y-m-d H:i', strtotime($note['created_at'])) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<script>
function toggleFavorite() {
fetch('/recipes/view?id=<?= $id ?>', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'action=toggle_favorite'
})
.then(r => r.json())
.then(data => {
document.getElementById('favorite-btn').textContent = data.favorite ? 'โญ' : 'โ';
});
}
function addNote(e) {
e.preventDefault();
const input = document.getElementById('note-input');
const content = input.value.trim();
if (!content) return;
fetch('/recipes/view?id=<?= $id ?>', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'action=add_note&content=' + encodeURIComponent(content)
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
}
});
}
function deleteNote(id) {
if (!confirm('๋ฉ๋ชจ๋ฅผ ์ญ์ ํ์๊ฒ ์ต๋๊น?')) return;
fetch('/recipes/view?id=<?= $id ?>', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'action=delete_note¬e_id=' + id
})
.then(r => r.json())
.then(data => {
if (data.success) {
document.querySelector(`.note-item[data-id="${id}"]`).remove();
}
});
}
</script>
<?php include_once('./tail.sub.php'); ?>
```
## 3) ํ์ธ
- ๋ ์ํผ ์นด๋ ํด๋ฆญ ์ ์์ธ ํ์ด์ง๋ก ์ด๋ํฉ๋๋ค
- ์ด๋ฏธ์ง, ์ ๋ชฉ, ์ค๋ช
, ์กฐ๋ฆฌ์ ๋ณด๊ฐ ํ์๋ฉ๋๋ค
- ์ฌ๋ฃ ์ฒดํฌ๋ฆฌ์คํธ๊ฐ ํ์๋๊ณ ์ฒดํฌ ๊ฐ๋ฅํฉ๋๋ค
- ์กฐ๋ฆฌ ๋จ๊ณ๊ฐ ๋ฒํธ์ ํจ๊ป ํ์๋ฉ๋๋ค
- ๋ฉ๋ชจ๋ฅผ ์ถ๊ฐ/์ญ์ ํ ์ ์์ต๋๋ค
- ์ฆ๊ฒจ์ฐพ๊ธฐ ๋ฒํผ์ ํด๋ฆญํ๋ฉด ๋ณ ์์ด์ฝ์ด ํ ๊ธ๋ฉ๋๋ค
๊ธฐ๋ ๊ฒฐ๊ณผ: - ๋ ์ํผ ์์ธ ์ ๋ณด ํ์ (์ด๋ฏธ์ง, ์ ๋ชฉ, ์ค๋ช , ์กฐ๋ฆฌ์๊ฐ, ๋์ด๋, ๋ถ๋, ํ๊ทธ) - ์ฌ๋ฃ ์ฒดํฌ๋ฆฌ์คํธ (์ฒดํฌ๋ฐ์ค๋ก ์ค๋นํ ์ฌ๋ฃ ํ์ ๊ฐ๋ฅ) - ์กฐ๋ฆฌ ๋จ๊ณ ๋ฒํธ ๋ฑ์ง์ ํจ๊ป ํ์ - ์ฆ๊ฒจ์ฐพ๊ธฐ ํ ๊ธ (AJAX๋ก ์ค์๊ฐ ์ ๋ฐ์ดํธ) - ๋ฉ๋ชจ ์ถ๊ฐ/์ญ์ (AJAX) - ๋คํฌ๋ชจ๋ ๋์ ๋์์ธ
**๋ชฉํ:** ์ฌ๋ฃ ๊ธฐ๋ฐ ๊ฒ์ ๊ธฐ๋ฅ ๊ตฌํ (์ผํ๋ก ๊ตฌ๋ถ๋ ์ฌ๋ฃ ์
๋ ฅ โ ํด๋น ์ฌ๋ฃ๊ฐ ๋ชจ๋ ํฌํจ๋ ๋ ์ํผ ํํฐ๋ง)
---
## 1) `app/modules/recipes/index.php` ์์
๊ธฐ์กด ํ์ผ์ ๋ ์ํผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ ๋ถ๋ถ์ ์๋ ์ฝ๋๋ก ๊ต์ฒดํด์ฃผ์ธ์ (DB ์ฐ๊ฒฐ ์๋, `require_once(G5_THEME_PATH...` ์์):
```php
// ๊ฒ์ ์ฒ๋ฆฌ
$search_ingredients = [];
if (!empty($_GET['search'])) {
$search_ingredients = array_map('trim', explode(',', $_GET['search']));
$search_ingredients = array_filter($search_ingredients);
}
// ๋ ์ํผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
if (!empty($search_ingredients)) {
// ์ฌ๋ฃ ๊ธฐ๋ฐ ๊ฒ์
$placeholders = implode(',', array_fill(0, count($search_ingredients), '?'));
$sql = "
SELECT DISTINCT r.*
FROM recipes r
INNER JOIN ingredients i ON r.id = i.recipe_id
WHERE LOWER(i.name) IN ($placeholders)
GROUP BY r.id
HAVING COUNT(DISTINCT LOWER(i.name)) = ?
ORDER BY r.created_at DESC
";
$stmt = $db->prepare($sql);
$params = array_map('strtolower', $search_ingredients);
$params[] = count($search_ingredients);
$stmt->execute($params);
$recipes = $stmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$recipes = $db->query("
SELECT * FROM recipes ORDER BY created_at DESC
")->fetchAll(PDO::FETCH_ASSOC);
}
```
## 2) ๊ฒ์ UI ์ถ๊ฐ
`<div class="dashboard-grid">` ์๋, `<div style="display: flex; justify-content: space-between...` ์์ ์๋ ์ฝ๋๋ฅผ ์ถ๊ฐํด์ฃผ์ธ์:
```php
<div style="margin-bottom: 32px;">
<form method="get" style="max-width: 600px; margin: 0 auto;">
<div style="position: relative;">
<input type="text" name="search" value="<?= htmlspecialchars($_GET['search'] ?? '') ?>" placeholder="์ฌ๋ฃ๋ก ๊ฒ์ (์: ๊ณ๋, ์ํ, ๋ํ)" style="width: 100%; padding: 16px 120px 16px 20px; border: 2px solid #FF6B35; border-radius: 50px; font-size: 1rem; background: var(--m-surface); color: var(--m-text);">
<button type="submit" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); padding: 10px 24px; background: #FF6B35; color: white; border: none; border-radius: 50px; font-weight: 600; cursor: pointer;">๊ฒ์</button>
</div>
<?php if (!empty($search_ingredients)): ?>
<div style="margin-top: 12px; text-align: center;">
<span style="color: var(--m-text-muted);">๊ฒ์ ์ค: </span>
<?php foreach ($search_ingredients as $ing): ?>
<span style="display: inline-block; padding: 4px 12px; background: #FF6B35; color: white; border-radius: 12px; margin: 0 4px; font-size: 0.9rem;"><?= htmlspecialchars($ing) ?></span>
<?php endforeach; ?>
<a href="/recipes" style="margin-left: 12px; color: var(--m-text-muted); text-decoration: none;">ร ๊ฒ์ ์ด๊ธฐํ</a>
</div>
<?php endif; ?>
</form>
</div>
```
## 3) ๊ฒ์ ๊ฒฐ๊ณผ ์์ ๋ ๋ฉ์์ง ์ถ๊ฐ
`<?php if (empty($recipes)): ?>` ๋ธ๋ก์ ์๋๋ก ๊ต์ฒดํด์ฃผ์ธ์:
```php
<?php if (empty($recipes)): ?>
<div style="text-align: center; padding: 60px 20px; background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="font-size: 4rem; margin-bottom: 16px;">๐</div>
<?php if (!empty($search_ingredients)): ?>
<p style="font-size: 1.2rem; color: var(--m-text-muted); margin-bottom: 12px;">"<?= implode(', ', array_map('htmlspecialchars', $search_ingredients)) ?>"์(๋ฅผ) ๋ชจ๋ ํฌํจํ๋ ๋ ์ํผ๊ฐ ์์ต๋๋ค</p>
<a href="/recipes" style="display: inline-block; background: #666; color: white; padding: 12px 32px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 12px;">์ ์ฒด ๋ ์ํผ ๋ณด๊ธฐ</a>
<?php else: ?>
<p style="font-size: 1.2rem; color: var(--m-text-muted); margin-bottom: 24px;">์์ง ๋ฑ๋ก๋ ๋ ์ํผ๊ฐ ์์ต๋๋ค</p>
<a href="/recipes/add" style="display: inline-block; background: #FF6B35; color: white; padding: 12px 32px; border-radius: 8px; text-decoration: none; font-weight: 600;">์ฒซ ๋ ์ํผ ์ถ๊ฐํ๊ธฐ</a>
<?php endif; ?>
</div>
```
## 4) ํ์ธ
- ๊ฒ์์ฐฝ์ "๊ณ๋, ์ํ"์ฒ๋ผ ์ผํ๋ก ๊ตฌ๋ถ๋ ์ฌ๋ฃ๋ฅผ ์
๋ ฅํ๊ณ ๊ฒ์ํ๋ฉด ๋ ์ฌ๋ฃ๊ฐ ๋ชจ๋ ํฌํจ๋ ๋ ์ํผ๋ง ํ์๋ฉ๋๋ค
- ๊ฒ์ ์ค์ธ ์ฌ๋ฃ๊ฐ ํ๊ทธ ํํ๋ก ํ์๋ฉ๋๋ค
- ๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ผ๋ฉด ์๋ด ๋ฉ์์ง๊ฐ ํ์๋ฉ๋๋ค
- "๊ฒ์ ์ด๊ธฐํ" ํด๋ฆญ ์ ์ ์ฒด ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๋๋ค
๊ธฐ๋ ๊ฒฐ๊ณผ: - ์ฌ๋ฃ ๊ฒ์์ฐฝ ํ์ (์ผํ๋ก ๊ตฌ๋ถ) - ๊ฒ์ ์ ์ ๋ ฅํ ์ฌ๋ฃ๊ฐ ๋ชจ๋ ํฌํจ๋ ๋ ์ํผ๋ง ํํฐ๋ง - ๊ฒ์ ์ค์ธ ์ฌ๋ฃ ํ๊ทธ ํ์ - ๊ฒ์ ๊ฒฐ๊ณผ ์์ ๋ ์๋ด ๋ฉ์์ง - ๊ฒ์ ์ด๊ธฐํ ๋งํฌ
**๋ชฉํ:** ํ๊ทธ ํํฐ๋ง & ๋์ด๋/์กฐ๋ฆฌ์๊ฐ ํํฐ ์ถ๊ฐ (URL ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ๊ธฐ๋ฐ)
---
## 1) `app/modules/recipes/index.php` ์์
๊ธฐ์กด ๋ ์ํผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ ์ฝ๋๋ฅผ ์๋๋ก ๊ต์ฒดํด์ฃผ์ธ์:
```php
// ํํฐ ์ฒ๋ฆฌ
$search_ingredients = [];
if (!empty($_GET['search'])) {
$search_ingredients = array_map('trim', explode(',', $_GET['search']));
$search_ingredients = array_filter($search_ingredients);
}
$filter_tag = $_GET['tag'] ?? '';
$filter_difficulty = $_GET['difficulty'] ?? '';
$filter_time = $_GET['time'] ?? '';
// ๋ ์ํผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
$where_conditions = [];
$params = [];
if (!empty($search_ingredients)) {
// ์ฌ๋ฃ ๊ธฐ๋ฐ ๊ฒ์ (๋ชจ๋ ์ฌ๋ฃ ํฌํจ)
$placeholders = implode(',', array_fill(0, count($search_ingredients), '?'));
$where_conditions[] = "
id IN (
SELECT recipe_id FROM ingredients
WHERE LOWER(name) IN ($placeholders)
GROUP BY recipe_id
HAVING COUNT(DISTINCT LOWER(name)) = ?
)
";
$params = array_merge($params, array_map('strtolower', $search_ingredients));
$params[] = count($search_ingredients);
}
if ($filter_tag) {
$where_conditions[] = "tags LIKE ?";
$params[] = '%'.$filter_tag.'%';
}
if ($filter_difficulty) {
$where_conditions[] = "difficulty = ?";
$params[] = $filter_difficulty;
}
if ($filter_time) {
if ($filter_time === '30') {
$where_conditions[] = "cooking_time <= 30";
} elseif ($filter_time === '60') {
$where_conditions[] = "cooking_time > 30 AND cooking_time <= 60";
} else {
$where_conditions[] = "cooking_time > 60";
}
}
$sql = "SELECT * FROM recipes";
if (!empty($where_conditions)) {
$sql .= " WHERE " . implode(' AND ', $where_conditions);
}
$sql .= " ORDER BY created_at DESC";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$recipes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ์ ์ฒด ํ๊ทธ ๋ชฉ๋ก (ํํฐ UI์ฉ)
$all_tags = [];
$tag_rows = $db->query("SELECT DISTINCT tags FROM recipes WHERE tags IS NOT NULL AND tags != ''")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tag_rows as $tags_str) {
foreach (explode(',', $tags_str) as $tag) {
$tag = trim($tag);
if ($tag && !in_array($tag, $all_tags)) {
$all_tags[] = $tag;
}
}
}
sort($all_tags);
```
## 2) ํํฐ UI ์ถ๊ฐ
๊ฒ์์ฐฝ ์๋, `<div style="display: flex; justify-content: space-between...` ์์ ์๋ ์ฝ๋๋ฅผ ์ถ๊ฐํด์ฃผ์ธ์:
```php
<?php if (!empty($all_tags) || !empty($recipes)): ?>
<div style="margin-bottom: 24px; padding: 20px; background: var(--m-surface); border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="display: grid; gap: 20px;">
<?php if (!empty($all_tags)): ?>
<div>
<div style="font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๐ท๏ธ ํ๊ทธ</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="?<?= http_build_query(array_merge($_GET, ['tag' => ''])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= empty($filter_tag) ? 'background: #FF6B35; color: white;' : 'background: #eee; color: #666;' ?>">์ ์ฒด</a>
<?php foreach ($all_tags as $tag): ?>
<a href="?<?= http_build_query(array_merge($_GET, ['tag' => $tag])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_tag === $tag ? 'background: #FF6B35; color: white;' : 'background: #eee; color: #666;' ?>"><?= htmlspecialchars($tag) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div>
<div style="font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๐ ๋์ด๋</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="?<?= http_build_query(array_merge($_GET, ['difficulty' => ''])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= empty($filter_difficulty) ? 'background: #4CAF50; color: white;' : 'background: #eee; color: #666;' ?>">์ ์ฒด</a>
<a href="?<?= http_build_query(array_merge($_GET, ['difficulty' => 'easy'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_difficulty === 'easy' ? 'background: #4CAF50; color: white;' : 'background: #eee; color: #666;' ?>">๐ ์ฌ์</a>
<a href="?<?= http_build_query(array_merge($_GET, ['difficulty' => 'medium'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_difficulty === 'medium' ? 'background: #4CAF50; color: white;' : 'background: #eee; color: #666;' ?>">๐ค ๋ณดํต</a>
<a href="?<?= http_build_query(array_merge($_GET, ['difficulty' => 'hard'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_difficulty === 'hard' ? 'background: #4CAF50; color: white;' : 'background: #eee; color: #666;' ?>">๐ฐ ์ด๋ ค์</a>
</div>
</div>
<div>
<div style="font-weight: 600; color: var(--m-text); margin-bottom: 8px;">โฑ๏ธ ์กฐ๋ฆฌ์๊ฐ</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="?<?= http_build_query(array_merge($_GET, ['time' => ''])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= empty($filter_time) ? 'background: #2196F3; color: white;' : 'background: #eee; color: #666;' ?>">์ ์ฒด</a>
<a href="?<?= http_build_query(array_merge($_GET, ['time' => '30'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_time === '30' ? 'background: #2196F3; color: white;' : 'background: #eee; color: #666;' ?>">30๋ถ ์ดํ</a>
<a href="?<?= http_build_query(array_merge($_GET, ['time' => '60'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_time === '60' ? 'background: #2196F3; color: white;' : 'background: #eee; color: #666;' ?>">30~60๋ถ</a>
<a href="?<?= http_build_query(array_merge($_GET, ['time' => '61'])) ?>" style="padding: 6px 14px; border-radius: 16px; text-decoration: none; <?= $filter_time === '61' ? 'background: #2196F3; color: white;' : 'background: #eee; color: #666;' ?>">60๋ถ ์ด์</a>
</div>
</div>
</div>
<?php if ($filter_tag || $filter_difficulty || $filter_time || !empty($search_ingredients)): ?>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
<a href="/recipes" style="color: var(--m-text-muted); text-decoration: none; font-size: 0.9rem;">๐ ๋ชจ๋ ํํฐ ์ด๊ธฐํ</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
```
## 3) ํ์ธ
- ํ๊ทธ ํํฐ๋ฅผ ํด๋ฆญํ๋ฉด ํด๋น ํ๊ทธ๊ฐ ํฌํจ๋ ๋ ์ํผ๋ง ํ์๋ฉ๋๋ค
- ๋์ด๋ ํํฐ๋ฅผ ํด๋ฆญํ๋ฉด ํด๋น ๋์ด๋์ ๋ ์ํผ๋ง ํ์๋ฉ๋๋ค
- ์กฐ๋ฆฌ์๊ฐ ํํฐ๋ฅผ ํด๋ฆญํ๋ฉด ํด๋น ์๊ฐ๋์ ๋ ์ํผ๋ง ํ์๋ฉ๋๋ค
- ์ฌ๋ฌ ํํฐ๋ฅผ ๋์์ ์ ์ฉํ ์ ์์ต๋๋ค (AND ์กฐ๊ฑด)
- "๋ชจ๋ ํํฐ ์ด๊ธฐํ" ํด๋ฆญ ์ ์ ์ฒด ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๋๋ค
- ํ์ฑํ๋ ํํฐ๋ ์์์ด ๋ณ๊ฒฝ๋ฉ๋๋ค
๊ธฐ๋ ๊ฒฐ๊ณผ: - ํ๊ทธ ํํฐ ๋ฒํผ ํ์ (๋ฑ๋ก๋ ํ๊ทธ ์๋ ์์ง) - ๋์ด๋ ํํฐ (์ฌ์/๋ณดํต/์ด๋ ค์) - ์กฐ๋ฆฌ์๊ฐ ํํฐ (30๋ถ ์ดํ/30~60๋ถ/60๋ถ ์ด์) - ํํฐ ์กฐํฉ ๊ฐ๋ฅ (์ฌ๋ฃ ๊ฒ์ + ํ๊ทธ + ๋์ด๋ + ์๊ฐ) - ํ์ฑ ํํฐ ์๊ฐ์ ํ์ (๋ฐฐ๊ฒฝ์ ๋ณ๊ฒฝ) - ์ ์ฒด ํํฐ ์ด๊ธฐํ ๋ฒํผ - URL ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ํํฐ ์ํ ์ ์ง
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!