๐ฏ g5se ๋์ ๋ฒํท๋ฆฌ์คํธ ๋ณด๋ - ๋ชฉํ ์๊ฐํ & ๋ฌ์ฑ ๊ธฐ๋ก
g5se-bucket-list-board
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
๊ทธ๋๋ณด๋5 SE ์์์ ๋์ํ๋ ๊ฐ์ธ ๋ฒํท๋ฆฌ์คํธ ์๊ฐํ ๋ณด๋๋ฅผ ๋ง๋ญ๋๋ค. ์ฌ์ฉ์๊ฐ ์ธ์ ๋ชฉํ๋ฅผ ์นดํ ๊ณ ๋ฆฌ๋ณ๋ก ๋ฑ๋กํ๊ณ , ์งํ ์ํ๋ฅผ ์๊ฐ์ ์ผ๋ก ์ถ์ ํ๋ฉฐ, ๋ฌ์ฑ ์ ์ถํ ์ ๋๋ฉ์ด์ ๊ณผ ํจ๊ป ๊ธฐ๋ก์ ๋จ๊ธฐ๋ ์๋น์ค์ ๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
-
๋ฒํท๋ฆฌ์คํธ CRUD
- ์ ๋ชฉ, ์นดํ ๊ณ ๋ฆฌ(์ฌํ/ํ์ต/๊ฑด๊ฐ/๊ฒฝ๋ ฅ/์ทจ๋ฏธ/๊ธฐํ), ์ค๋ช , ๋ชฉํ ๋ ์ง ์ ๋ ฅ
- ์ํ: ๊ณํ์ค(gray) / ์งํ์ค(blue) / ์๋ฃ(green) / ๋ณด๋ฅ(orange)
- ์นด๋ ํํ๋ก ๋ณด๊ธฐ ์ข๊ฒ ๋ฐฐ์น (grid layout)
-
์นดํ ๊ณ ๋ฆฌ๋ณ ํํฐ๋ง & ํต๊ณ
- ์๋จ์ ์นดํ ๊ณ ๋ฆฌ ํญ (์ ์ฒด/์ฌํ/ํ์ต/๊ฑด๊ฐ/๊ฒฝ๋ ฅ/์ทจ๋ฏธ/๊ธฐํ)
- ๊ฐ ์นดํ ๊ณ ๋ฆฌ๋ณ ์๋ฃ์จ ํ๋ก๊ทธ๋ ์ค ๋ฐ
- ์ ์ฒด ๋ฌ์ฑ๋ฅ ์ํ ์ฐจํธ (Chart.js ๋๋ ์์ CSS๋ก ๊ตฌํ)
-
๋ฌ์ฑ ์ถํ ๋ชจ๋ฌ
- ๋ฒํท๋ฆฌ์คํธ๋ฅผ "์๋ฃ"๋ก ๋ณ๊ฒฝ ์ confetti ์ ๋๋ฉ์ด์ + ์ถํ ๋ฉ์์ง
- ๋ฌ์ฑ ๋ ์ง ์๋ ๊ธฐ๋ก
- SNS ๊ณต์ ๋ฒํผ (ํ ์คํธ ๋ณต์ฌ)
-
ํ์๋ผ์ธ ๋ทฐ
- ๋ฌ์ฑํ ๋ฒํท๋ฆฌ์คํธ๋ฅผ ์๊ฐ ์์ผ๋ก ๋์ด
- ๊ฐ ํญ๋ชฉ์ ๋ฌ์ฑ ๋ ์ง + ์์ ๊ธฐ๊ฐ ํ์
-
๋ชจ๋ ์ ธ ํตํฉ
- g5se์ modern shell ์ฌ์ฉ์ผ๋ก nav/footer/๋คํฌ๋ชจ๋ ์๋ ์ ์ฉ
- ๋์์ธ ํ ํฐ ํ์ฉ (--m-surface, --m-text, --m-shadow ๋ฑ)
๋ณด๋์ค ๊ธฐ๋ฅ
- ๋๋๊ทธ ์ค ๋๋กญ์ผ๋ก ์ฐ์ ์์ ์ฌ์ ๋ ฌ
- ๋ฒํท๋ฆฌ์คํธ์ ์ด๋ฏธ์ง ์ฒจ๋ถ (๋ชฉํ ๋ฌ์ฑ ์ ์ธ์ฆ์ท)
- ์๋ณ ๋ฌ์ฑ ๋ชฉํ ์ค์ & ์๋ฆผ
- ์น๊ตฌ์ ๋ฒํท๋ฆฌ์คํธ ๊ณต์ (์ฝ๊ธฐ ์ ์ฉ ๋งํฌ)
๐จ ๋์์ธ ์๊ตฌ์ฌํญ
- ์นด๋ ๋ ์ด์์: grid 3์ด (๋ชจ๋ฐ์ผ 1์ด)
- ์นดํ ๊ณ ๋ฆฌ๋ณ ์์ด์ฝ & ์์ ๊ตฌ๋ถ
- ์๋ฃ ํญ๋ชฉ์ ์ฒดํฌ ์์ด์ฝ + ํ๋ฆฐ ํ ์คํธ
- ํธ๋ฒ ์ ์นด๋ ์ฝ๊ฐ ๋ ์ค๋ฅด๋ ํจ๊ณผ (transform: translateY(-4px))
- ๋คํฌ๋ชจ๋ ์๋ฒฝ ๋์ (ํ ํฐ ์ฌ์ฉ)
๐ ๊ธฐ์ ์คํ
- ํ๋ ์์ํฌ: ๊ทธ๋๋ณด๋5 SE (g5se)
- DB: ๋ชจ๋ ์ ์ฉ SQLite (
G5_DATA_PATH/modules/bucket-list.sqlite) - UI: modern shell + ์์ CSS (grid, flexbox)
- ์ธํฐ๋์ : Vanilla JavaScript (confetti๋ canvas-confetti CDN ์ฌ์ฉ ๊ฐ๋ฅ)
๐ฆ ๋ฐ์ดํฐ ๊ตฌ์กฐ
buckets ํ ์ด๋ธ
CREATE TABLE buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mb_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL, -- travel/learning/health/career/hobby/other
status TEXT DEFAULT 'planned', -- planned/progress/done/paused
target_date TEXT, -- YYYY-MM-DD
completed_at TEXT, -- YYYY-MM-DD (๋ฌ์ฑ ๋ ์ง)
display_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
๐ฏ ํ์ต ๋ชฉํ
- g5se ํด๋ ๊ธฐ๋ฐ ๋ผ์ฐํ ์ฒด๊ณ ์ดํด
- modern shell ํตํฉ์ผ๋ก ์ฌ์ดํธ ์ผ๊ด์ฑ ํ๋ณด
- ๋ชจ๋ ์ ์ฉ SQLite DB ์ค๊ณ & CRUD ๊ตฌํ
- ์์ JS๋ก ์ธํฐ๋ํฐ๋ธ UI ๊ตฌํ (์ํ ๋ณ๊ฒฝ, ํํฐ๋ง, ์ ๋๋ฉ์ด์ )
- ๋์์ธ ํ ํฐ ํ์ฉ์ผ๋ก ๋คํฌ๋ชจ๋ ์๋ ๋์
์ ์ฝ ์กฐ๊ฑด
- โข ๊ทธ๋๋ณด๋5 SE (g5se) ๊ธฐ๋ฐ โ Laravel/Composer/npm/React/Vue ์ฌ์ฉ ๊ธ์ง
- โข ํด๋ ์๋ ๋ผ์ฐํ ์ฌ์ฉ: app/modules/bucket-list/index.php โ /bucket-list
- โข modern shell ํ์ ์ ์ฉ (_head.inc.php + m-shell + _nav.inc.php + m-container + _footer.inc.php)
- โข ๋์์ธ ํ ํฐ ์ฌ์ฉ (--m-surface, --m-text, --m-text-muted, --m-shadow, --m-radius-lg ๋ฑ)
- โข ๋ชจ๋ ์ ์ฉ SQLite DB ์ฌ์ฉ: G5_DATA_PATH/modules/bucket-list.sqlite
- โข prepared statement๋ก SQL ์ธ์ ์ ๋ฐฉ์ง
- โข ์ถ๋ ฅ ์ด์ค์ผ์ดํ: htmlspecialchars() ํ์
- โข ํ์ ์ ์ฉ ๊ธฐ๋ฅ โ ๋ก๊ทธ์ธํ์ง ์์ ์ฌ์ฉ์๋ ์๋ด ๋ฉ์์ง + ๋ก๊ทธ์ธ ๋งํฌ
- โข ์ธ๋ถ ๋น๋ ๋๊ตฌ(npm/webpack) ์ฌ์ฉ ๊ธ์ง
- โข CDN ์ฌ์ฉ ์ต์ํ โ confetti ์ ๋๋ฉ์ด์ ๋ง canvas-confetti CDN ํ์ฉ
- โข ๋ฐ์ํ ๋์์ธ: ๋ชจ๋ฐ์ผ(1์ด) / ํ๋ธ๋ฆฟ(2์ด) / ๋ฐ์คํฌํฑ(3์ด)
- โข ๋คํฌ๋ชจ๋ ์๋ฒฝ ๋์ (ํ ํฐ ์ฌ์ฉ์ผ๋ก ์๋ ์ฒ๋ฆฌ)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
์ด๋ฒ ๋จ๊ณ์์๋ **๋ฒํท๋ฆฌ์คํธ ๋ณด๋์ ๊ธฐ๋ณธ ๊ณจ๊ฒฉ**์ ๋ง๋ญ๋๋ค.
1. `app/modules/bucket-list/index.php` ํ์ผ์ ์์ฑํ์ธ์.
2. ํ์ผ ์ต์๋จ์ ๋ค์ ์ฝ๋๋ฅผ ์์ฑํ์ธ์:
```php
<?php
include_once('./_common.php');
// ๋ก๊ทธ์ธ ์ฒดํฌ
if (!$is_member) {
alert('๋ก๊ทธ์ธ์ด ํ์ํ ์๋น์ค์
๋๋ค.', G5_BBS_URL.'/login.php?url='.urlencode('/bucket-list'));
}
// DB ์ฐ๊ฒฐ
@mkdir(G5_DATA_PATH.'/modules', 0707, true);
$db_path = G5_DATA_PATH.'/modules/bucket-list.sqlite';
$pdo = new PDO('sqlite:'.$db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํ
์ด๋ธ ์์ฑ (์ฒซ ์ ๊ทผ ์)
$pdo->exec("
CREATE TABLE IF NOT EXISTS buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mb_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL,
status TEXT DEFAULT 'planned',
target_date TEXT,
completed_at TEXT,
display_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)");
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: 1200px; margin: 0 auto;">
<header style="margin-bottom: 32px;">
<h1 style="font-size: 28px; font-weight: 700; color: var(--m-text); margin-bottom: 8px;">๐ฏ ๋์ ๋ฒํท๋ฆฌ์คํธ</h1>
<p style="color: var(--m-text-muted); font-size: 15px;">์ธ์ ๋ชฉํ๋ฅผ ์๊ฐํํ๊ณ ๋ฌ์ฑ ์ฌ์ ์ ๊ธฐ๋กํ์ธ์</p>
</header>
<!-- ์นดํ
๊ณ ๋ฆฌ ํญ -->
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
<button class="cat-tab active" data-cat="all" style="padding: 8px 16px; border: none; border-radius: var(--m-radius-lg); background: var(--m-primary); color: white; cursor: pointer; font-weight: 600;">์ ์ฒด</button>
<button class="cat-tab" data-cat="travel" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">โ๏ธ ์ฌํ</button>
<button class="cat-tab" data-cat="learning" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">๐ ํ์ต</button>
<button class="cat-tab" data-cat="health" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">๐ช ๊ฑด๊ฐ</button>
<button class="cat-tab" data-cat="career" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">๐ผ ๊ฒฝ๋ ฅ</button>
<button class="cat-tab" data-cat="hobby" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">๐จ ์ทจ๋ฏธ</button>
<button class="cat-tab" data-cat="other" style="padding: 8px 16px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">๐ ๊ธฐํ</button>
</div>
<!-- ํต๊ณ ์์ญ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ) -->
<div id="stats-area" style="margin-bottom: 32px;"></div>
<!-- ๋ฒํท๋ฆฌ์คํธ ๊ทธ๋ฆฌ๋ -->
<div id="bucket-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
<!-- ์นด๋๋ค์ด ์ฌ๊ธฐ ๋ ๋๋ง๋จ -->
</div>
<!-- ์ ๋ฒํท๋ฆฌ์คํธ ์ถ๊ฐ ๋ฒํผ -->
<button id="add-btn" style="position: fixed; bottom: 32px; right: 32px; width: 60px; height: 60px; border-radius: 50%; border: none; background: var(--m-primary); color: white; font-size: 28px; cursor: pointer; box-shadow: var(--m-shadow); z-index: 100;">+</button>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<script>
// ์นดํ
๊ณ ๋ฆฌ ํญ ์ ํ ๋ก์ง
document.querySelectorAll('.cat-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.cat-tab').forEach(t => {
t.classList.remove('active');
t.style.background = 'transparent';
t.style.color = 'var(--m-text)';
t.style.border = '1px solid var(--m-border)';
});
tab.classList.add('active');
tab.style.background = 'var(--m-primary)';
tab.style.color = 'white';
tab.style.border = 'none';
// ํํฐ๋ง ๋ก์ง (๋ค์ ๋จ๊ณ์์ ๊ตฌํ)
const cat = tab.dataset.cat;
console.log('Selected category:', cat);
});
});
// ์ถ๊ฐ ๋ฒํผ ํด๋ฆญ (๋ค์ ๋จ๊ณ์์ ๋ชจ๋ฌ ๊ตฌํ)
document.getElementById('add-btn').addEventListener('click', () => {
alert('๋ค์ ๋จ๊ณ์์ ์ถ๊ฐ ๋ชจ๋ฌ์ ๊ตฌํํฉ๋๋ค');
});
</script>
<?php include_once('./tail.sub.php'); ?>
```
3. ๋ธ๋ผ์ฐ์ ์์ `/bucket-list`์ ์ ์ํ์ฌ ๊ธฐ๋ณธ ๋ ์ด์์์ด ํ์๋๋์ง ํ์ธํ์ธ์.
- modern shell์ nav/footer๊ฐ ๋ณด์ฌ์ผ ํจ
- ์นดํ
๊ณ ๋ฆฌ ํญ ํด๋ฆญ ์ ์คํ์ผ ๋ณ๊ฒฝ ํ์ธ
- ์ฐ์ธก ํ๋จ + ๋ฒํผ ํ์ธ
๊ธฐ๋ ๊ฒฐ๊ณผ: - `/bucket-list` ๊ฒฝ๋ก๋ก ์ ์ ์ modern shell์ด ์ ์ฉ๋ ํ์ด์ง ํ์ - ๋ก๊ทธ์ธํ์ง ์์ ์ฌ์ฉ์๋ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ - ์นดํ ๊ณ ๋ฆฌ ํญ 7๊ฐ(์ ์ฒด/์ฌํ/ํ์ต/๊ฑด๊ฐ/๊ฒฝ๋ ฅ/์ทจ๋ฏธ/๊ธฐํ) ํ์ - ํญ ํด๋ฆญ ์ ์คํ์ผ ์ ํ (ํ๋ ๋ฐฐ๊ฒฝ + ํฐ ๊ธ์) - ์ฐ์ธก ํ๋จ์ + ๋ฒํผ (fixed position) - SQLite DB ์๋ ์์ฑ (`data/modules/bucket-list.sqlite`) - ๋คํฌ๋ชจ๋ ์ ํ ์ ์์ ์๋ ๋ณ๊ฒฝ ํ์ธ
์ด๋ฒ ๋จ๊ณ์์๋ **๋ฒํท๋ฆฌ์คํธ ์ถ๊ฐ ๋ชจ๋ฌ & CRUD API**๋ฅผ ๊ตฌํํฉ๋๋ค.
1. `app/modules/bucket-list/index.php`์ `<?php` ๋ธ๋ก ์์ AJAX ๋ถ๊ธฐ๋ฅผ ์ถ๊ฐํ์ธ์ (DB ์ฐ๊ฒฐ ์ฝ๋ ๋ฐ๋ก ์๋):
```php
// AJAX ์ฒ๋ฆฌ
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$category = $_POST['category'] ?? 'other';
$target_date = $_POST['target_date'] ?? null;
if (empty($title)) {
echo json_encode(['success' => false, 'message' => '์ ๋ชฉ์ ์
๋ ฅํ์ธ์']);
exit;
}
$stmt = $pdo->prepare("
INSERT INTO buckets (mb_id, title, description, category, target_date)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([$member['mb_id'], $title, $description, $category, $target_date]);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
exit;
}
if ($action === 'list') {
$category = $_POST['category'] ?? 'all';
$sql = "SELECT * FROM buckets WHERE mb_id = ?";
$params = [$member['mb_id']];
if ($category !== 'all') {
$sql .= " AND category = ?";
$params[] = $category;
}
$sql .= " ORDER BY display_order ASC, created_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$buckets = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'buckets' => $buckets]);
exit;
}
echo json_encode(['success' => false, 'message' => '์๋ชป๋ ์์ฒญ']);
exit;
}
```
2. `<div id="bucket-grid">` ๋ฐ๋ก ์์ ๋ชจ๋ฌ HTML์ ์ถ๊ฐํ์ธ์:
```html
<!-- ์ถ๊ฐ/์์ ๋ชจ๋ฌ -->
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); padding: 32px; width: 90%; max-width: 500px; box-shadow: var(--m-shadow);">
<h2 style="font-size: 22px; font-weight: 700; color: var(--m-text); margin-bottom: 24px;">์ ๋ฒํท๋ฆฌ์คํธ ์ถ๊ฐ</h2>
<form id="bucket-form">
<div style="margin-bottom: 16px;">
<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 var(--m-border); border-radius: var(--m-radius-lg); background: var(--m-surface); color: var(--m-text);">
</div>
<div style="margin-bottom: 16px;">
<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 var(--m-border); border-radius: var(--m-radius-lg); background: var(--m-surface); color: var(--m-text); resize: vertical;"></textarea>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">์นดํ
๊ณ ๋ฆฌ</label>
<select name="category" style="width: 100%; padding: 12px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: var(--m-surface); color: var(--m-text);">
<option value="travel">โ๏ธ ์ฌํ</option>
<option value="learning">๐ ํ์ต</option>
<option value="health">๐ช ๊ฑด๊ฐ</option>
<option value="career">๐ผ ๊ฒฝ๋ ฅ</option>
<option value="hobby">๐จ ์ทจ๋ฏธ</option>
<option value="other">๐ ๊ธฐํ</option>
</select>
</div>
<div style="margin-bottom: 24px;">
<label style="display: block; font-weight: 600; color: var(--m-text); margin-bottom: 8px;">๋ชฉํ ๋ ์ง</label>
<input type="date" name="target_date" style="width: 100%; padding: 12px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: var(--m-surface); color: var(--m-text);">
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button type="button" id="cancel-btn" style="padding: 12px 24px; border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); background: transparent; color: var(--m-text); cursor: pointer;">์ทจ์</button>
<button type="submit" style="padding: 12px 24px; border: none; border-radius: var(--m-radius-lg); background: var(--m-primary); color: white; cursor: pointer; font-weight: 600;">์ถ๊ฐํ๊ธฐ</button>
</div>
</form>
</div>
</div>
```
3. `<script>` ๋ธ๋ก์ ๋ค์์ผ๋ก ๊ต์ฒดํ์ธ์:
```javascript
const modal = document.getElementById('modal');
const form = document.getElementById('bucket-form');
const grid = document.getElementById('bucket-grid');
let currentCategory = 'all';
// ๋ชจ๋ฌ ์ด๊ธฐ/๋ซ๊ธฐ
document.getElementById('add-btn').addEventListener('click', () => {
modal.style.display = 'flex';
});
document.getElementById('cancel-btn').addEventListener('click', () => {
modal.style.display = 'none';
form.reset();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.style.display = 'none';
form.reset();
}
});
// ๋ฒํท๋ฆฌ์คํธ ์ถ๊ฐ
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
formData.append('action', 'create');
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
modal.style.display = 'none';
form.reset();
loadBuckets();
} else {
alert(data.message);
}
});
// ๋ฒํท๋ฆฌ์คํธ ๋ก๋
async function loadBuckets() {
const formData = new FormData();
formData.append('action', 'list');
formData.append('category', currentCategory);
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
renderBuckets(data.buckets);
}
}
// ๋ฒํท๋ฆฌ์คํธ ๋ ๋๋ง
function renderBuckets(buckets) {
if (buckets.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--m-text-muted); padding: 48px 0;">์์ง ๋ฒํท๋ฆฌ์คํธ๊ฐ ์์ต๋๋ค. + ๋ฒํผ์ ๋๋ฌ ์ถ๊ฐํด๋ณด์ธ์!</p>';
return;
}
const statusColors = {
planned: '#6b7280',
progress: '#3b82f6',
done: '#10b981',
paused: '#f59e0b'
};
const statusLabels = {
planned: '๊ณํ์ค',
progress: '์งํ์ค',
done: '์๋ฃ',
paused: '๋ณด๋ฅ'
};
const categoryIcons = {
travel: 'โ๏ธ',
learning: '๐',
health: '๐ช',
career: '๐ผ',
hobby: '๐จ',
other: '๐'
};
grid.innerHTML = buckets.map(b => `
<div class="bucket-card" style="background: var(--m-surface); border-radius: var(--m-radius-lg); padding: 20px; box-shadow: var(--m-shadow); transition: transform 0.2s; cursor: pointer;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<span style="font-size: 24px;">${categoryIcons[b.category]}</span>
<span style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; color: white; background: ${statusColors[b.status]};">${statusLabels[b.status]}</span>
</div>
<h3 style="font-size: 18px; font-weight: 700; color: var(--m-text); margin-bottom: 8px; ${b.status === 'done' ? 'text-decoration: line-through; opacity: 0.7;' : ''}">${htmlspecialchars(b.title)}</h3>
${b.description ? `<p style="font-size: 14px; color: var(--m-text-muted); margin-bottom: 12px;">${htmlspecialchars(b.description)}</p>` : ''}
${b.target_date ? `<p style="font-size: 13px; color: var(--m-text-muted);">๐ฏ ๋ชฉํ: ${b.target_date}</p>` : ''}
</div>
`).join('');
// ํธ๋ฒ ํจ๊ณผ
document.querySelectorAll('.bucket-card').forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-4px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'translateY(0)';
});
});
}
function htmlspecialchars(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ์นดํ
๊ณ ๋ฆฌ ํญ ์ ํ
document.querySelectorAll('.cat-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.cat-tab').forEach(t => {
t.classList.remove('active');
t.style.background = 'transparent';
t.style.color = 'var(--m-text)';
t.style.border = '1px solid var(--m-border)';
});
tab.classList.add('active');
tab.style.background = 'var(--m-primary)';
tab.style.color = 'white';
tab.style.border = 'none';
currentCategory = tab.dataset.cat;
loadBuckets();
});
});
// ์ด๊ธฐ ๋ก๋
loadBuckets();
```
4. ๋ธ๋ผ์ฐ์ ์์ ํ
์คํธ:
- + ๋ฒํผ ํด๋ฆญ โ ๋ชจ๋ฌ ์ด๋ฆผ
- ๋ฒํท๋ฆฌ์คํธ ์ถ๊ฐ โ ์นด๋ ํ์
- ์นดํ
๊ณ ๋ฆฌ ํญ ์ ํ โ ํํฐ๋ง ๋์
๊ธฐ๋ ๊ฒฐ๊ณผ: - + ๋ฒํผ ํด๋ฆญ ์ ๋ชจ๋ฌ ์คํ (๋ฐฐ๊ฒฝ ์ด๋ก๊ฒ) - ํผ ์ ๋ ฅ ํ "์ถ๊ฐํ๊ธฐ" ํด๋ฆญ ์ DB ์ ์ฅ & ์นด๋ ๋ ๋๋ง - ์นด๋์ ์นดํ ๊ณ ๋ฆฌ ์์ด์ฝ, ์ํ ๋ฐฐ์ง, ์ ๋ชฉ, ์ค๋ช , ๋ชฉํ ๋ ์ง ํ์ - ์นดํ ๊ณ ๋ฆฌ ํญ ํด๋ฆญ ์ ํํฐ๋ง๋ ๋ชฉ๋ก๋ง ํ์ - ๋น ์ํ์ผ ๋ ์๋ด ๋ฌธ๊ตฌ ํ์ - ํธ๋ฒ ์ ์นด๋ ์ฝ๊ฐ ๋ ์ค๋ฆ (translateY(-4px)) - ๋คํฌ๋ชจ๋์์๋ ๋ชจ๋ฌ/์นด๋ ์ ์ ํ์
์ด๋ฒ ๋จ๊ณ์์๋ **์ํ ๋ณ๊ฒฝ & ํต๊ณ ํ์**๋ฅผ ๊ตฌํํฉ๋๋ค.
1. `app/modules/bucket-list/index.php`์ AJAX ๋ถ๊ธฐ์ ์ํ ๋ณ๊ฒฝ ์ก์
์ ์ถ๊ฐํ์ธ์ (`if ($action === 'list')` ๋ธ๋ก ์๋):
```php
if ($action === 'update_status') {
$id = (int)$_POST['id'];
$status = $_POST['status'] ?? 'planned';
// ์๋ฃ ์ฒ๋ฆฌ ์ completed_at ๊ธฐ๋ก
$completed_at = ($status === 'done') ? date('Y-m-d') : null;
$stmt = $pdo->prepare("
UPDATE buckets SET status = ?, completed_at = ? WHERE id = ? AND mb_id = ?
");
$stmt->execute([$status, $completed_at, $id, $member['mb_id']]);
echo json_encode(['success' => true, 'completed' => ($status === 'done')]);
exit;
}
if ($action === 'stats') {
$stmt = $pdo->prepare("
SELECT category, status, COUNT(*) as count
FROM buckets
WHERE mb_id = ?
GROUP BY category, status
");
$stmt->execute([$member['mb_id']]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stats = [];
foreach ($rows as $row) {
if (!isset($stats[$row['category']])) {
$stats[$row['category']] = ['total' => 0, 'done' => 0];
}
$stats[$row['category']]['total'] += $row['count'];
if ($row['status'] === 'done') {
$stats[$row['category']]['done'] += $row['count'];
}
}
echo json_encode(['success' => true, 'stats' => $stats]);
exit;
}
```
2. `<script>` ๋ธ๋ก์ `renderBuckets` ํจ์๋ฅผ ์์ ํ์ฌ ์นด๋์ ์ํ ๋ณ๊ฒฝ ๋๋กญ๋ค์ด์ ์ถ๊ฐํ์ธ์:
```javascript
function renderBuckets(buckets) {
if (buckets.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--m-text-muted); padding: 48px 0;">์์ง ๋ฒํท๋ฆฌ์คํธ๊ฐ ์์ต๋๋ค. + ๋ฒํผ์ ๋๋ฌ ์ถ๊ฐํด๋ณด์ธ์!</p>';
return;
}
const statusColors = {
planned: '#6b7280',
progress: '#3b82f6',
done: '#10b981',
paused: '#f59e0b'
};
const statusLabels = {
planned: '๊ณํ์ค',
progress: '์งํ์ค',
done: '์๋ฃ',
paused: '๋ณด๋ฅ'
};
const categoryIcons = {
travel: 'โ๏ธ',
learning: '๐',
health: '๐ช',
career: '๐ผ',
hobby: '๐จ',
other: '๐'
};
grid.innerHTML = buckets.map(b => `
<div class="bucket-card" data-id="${b.id}" style="background: var(--m-surface); border-radius: var(--m-radius-lg); padding: 20px; box-shadow: var(--m-shadow); transition: transform 0.2s;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<span style="font-size: 24px;">${categoryIcons[b.category]}</span>
<select class="status-select" data-id="${b.id}" style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; color: white; background: ${statusColors[b.status]}; border: none; cursor: pointer;">
<option value="planned" ${b.status === 'planned' ? 'selected' : ''}>๊ณํ์ค</option>
<option value="progress" ${b.status === 'progress' ? 'selected' : ''}>์งํ์ค</option>
<option value="done" ${b.status === 'done' ? 'selected' : ''}>์๋ฃ</option>
<option value="paused" ${b.status === 'paused' ? 'selected' : ''}>๋ณด๋ฅ</option>
</select>
</div>
<h3 style="font-size: 18px; font-weight: 700; color: var(--m-text); margin-bottom: 8px; ${b.status === 'done' ? 'text-decoration: line-through; opacity: 0.7;' : ''}">${htmlspecialchars(b.title)}</h3>
${b.description ? `<p style="font-size: 14px; color: var(--m-text-muted); margin-bottom: 12px;">${htmlspecialchars(b.description)}</p>` : ''}
${b.target_date ? `<p style="font-size: 13px; color: var(--m-text-muted);">๐ฏ ๋ชฉํ: ${b.target_date}</p>` : ''}
${b.completed_at ? `<p style="font-size: 13px; color: #10b981; font-weight: 600; margin-top: 8px;">โ
๋ฌ์ฑ: ${b.completed_at}</p>` : ''}
</div>
`).join('');
// ํธ๋ฒ ํจ๊ณผ
document.querySelectorAll('.bucket-card').forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-4px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'translateY(0)';
});
});
// ์ํ ๋ณ๊ฒฝ ์ด๋ฒคํธ
document.querySelectorAll('.status-select').forEach(select => {
select.addEventListener('change', async (e) => {
const id = e.target.dataset.id;
const status = e.target.value;
const formData = new FormData();
formData.append('action', 'update_status');
formData.append('id', id);
formData.append('status', status);
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
if (data.completed) {
showConfetti();
}
loadBuckets();
loadStats();
}
});
});
}
```
3. ํต๊ณ ๋ก๋ & ๋ ๋๋ง ํจ์๋ฅผ ์ถ๊ฐํ์ธ์ (`<script>` ๋ธ๋ก ๋์):
```javascript
// ํต๊ณ ๋ก๋
async function loadStats() {
const formData = new FormData();
formData.append('action', 'stats');
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
renderStats(data.stats);
}
}
// ํต๊ณ ๋ ๋๋ง
function renderStats(stats) {
const categories = ['travel', 'learning', 'health', 'career', 'hobby', 'other'];
const categoryLabels = {
travel: 'โ๏ธ ์ฌํ',
learning: '๐ ํ์ต',
health: '๐ช ๊ฑด๊ฐ',
career: '๐ผ ๊ฒฝ๋ ฅ',
hobby: '๐จ ์ทจ๋ฏธ',
other: '๐ ๊ธฐํ'
};
let totalCount = 0;
let totalDone = 0;
const statsHTML = categories.map(cat => {
const s = stats[cat] || {total: 0, done: 0};
totalCount += s.total;
totalDone += s.done;
const percent = s.total > 0 ? Math.round((s.done / s.total) * 100) : 0;
if (s.total === 0) return '';
return `
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="font-size: 14px; color: var(--m-text);">${categoryLabels[cat]}</span>
<span style="font-size: 14px; color: var(--m-text-muted);">${s.done}/${s.total} (${percent}%)</span>
</div>
<div style="width: 100%; height: 8px; background: var(--m-border); border-radius: 4px; overflow: hidden;">
<div style="width: ${percent}%; height: 100%; background: var(--m-primary); transition: width 0.3s;"></div>
</div>
</div>
`;
}).join('');
const overallPercent = totalCount > 0 ? Math.round((totalDone / totalCount) * 100) : 0;
document.getElementById('stats-area').innerHTML = `
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); padding: 24px; box-shadow: var(--m-shadow);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="font-size: 18px; font-weight: 700; color: var(--m-text);">๐ ๋ฌ์ฑ ํํฉ</h2>
<div style="font-size: 32px; font-weight: 700; color: var(--m-primary);">${overallPercent}%</div>
</div>
${statsHTML}
</div>
`;
}
// ์ถํ ์ ๋๋ฉ์ด์
(๊ฐ๋จํ ์๋ฆผ์ผ๋ก ๋์ฒด, ๋ค์ ๋จ๊ณ์์ confetti ์ถ๊ฐ)
function showConfetti() {
alert('๐ ์ถํํฉ๋๋ค! ๋ฒํท๋ฆฌ์คํธ๋ฅผ ๋ฌ์ฑํ์
จ์ต๋๋ค!');
}
// ์ด๊ธฐ ๋ก๋ ์์
loadBuckets();
loadStats();
```
4. ๋ธ๋ผ์ฐ์ ์์ ํ
์คํธ:
- ์๋จ์ ํต๊ณ ์์ญ ํ์
- ์นด๋์ ์ํ ๋๋กญ๋ค์ด ๋ณ๊ฒฝ โ DB ์
๋ฐ์ดํธ & ์ฌ๋ ๋๋ง
- ์๋ฃ๋ก ๋ณ๊ฒฝ ์ ๋ฌ์ฑ ๋ ์ง ํ์ & ์๋ฆผ
๊ธฐ๋ ๊ฒฐ๊ณผ: - ํ์ด์ง ์๋จ์ ์นดํ ๊ณ ๋ฆฌ๋ณ ๋ฌ์ฑ๋ฅ ํ๋ก๊ทธ๋ ์ค ๋ฐ ํ์ - ์ ์ฒด ๋ฌ์ฑ๋ฅ ํผ์ผํธ ํฌ๊ฒ ํ์ - ์นด๋์ ์ํ ๋๋กญ๋ค์ด ๋ณ๊ฒฝ ์ ์ฆ์ DB ์ ๋ฐ์ดํธ - ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝ ์ ์ ๋ชฉ์ ์ทจ์์ , ๋ฌ์ฑ ๋ ์ง ํ์, ์๋ฆผ ํ์ - ํต๊ณ ์์ญ๋ ์ค์๊ฐ ๊ฐฑ์ - ์นดํ ๊ณ ๋ฆฌ ํํฐ๋ง ์ ํต๊ณ๋ ์ ์ฒด ๊ธฐ์ค ์ ์ง
์ด๋ฒ ๋จ๊ณ์์๋ **confetti ์ถํ ์ ๋๋ฉ์ด์
& ์ญ์ ๊ธฐ๋ฅ**์ ์ถ๊ฐํฉ๋๋ค.
1. `app/modules/bucket-list/index.php`์ `<head>` ์น์
(`_head.inc.php` ์งํ)์ canvas-confetti CDN์ ์ถ๊ฐํ์ธ์:
```php
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>
```
2. AJAX ๋ถ๊ธฐ์ ์ญ์ ์ก์
์ ์ถ๊ฐํ์ธ์ (`if ($action === 'stats')` ๋ธ๋ก ์๋):
```php
if ($action === 'delete') {
$id = (int)$_POST['id'];
$stmt = $pdo->prepare("DELETE FROM buckets WHERE id = ? AND mb_id = ?");
$stmt->execute([$id, $member['mb_id']]);
echo json_encode(['success' => true]);
exit;
}
```
3. `renderBuckets` ํจ์์ ์นด๋ HTML์ ์ญ์ ๋ฒํผ์ ์ถ๊ฐํ์ธ์ (์ํ select ์):
```javascript
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<span style="font-size: 24px;">${categoryIcons[b.category]}</span>
<div style="display: flex; gap: 8px; align-items: center;">
<select class="status-select" data-id="${b.id}" style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; color: white; background: ${statusColors[b.status]}; border: none; cursor: pointer;">
<option value="planned" ${b.status === 'planned' ? 'selected' : ''}>๊ณํ์ค</option>
<option value="progress" ${b.status === 'progress' ? 'selected' : ''}>์งํ์ค</option>
<option value="done" ${b.status === 'done' ? 'selected' : ''}>์๋ฃ</option>
<option value="paused" ${b.status === 'paused' ? 'selected' : ''}>๋ณด๋ฅ</option>
</select>
<button class="delete-btn" data-id="${b.id}" style="background: transparent; border: none; color: var(--m-text-muted); cursor: pointer; font-size: 18px; padding: 4px;" title="์ญ์ ">๐๏ธ</button>
</div>
</div>
```
4. ์ญ์ ๋ฒํผ ์ด๋ฒคํธ๋ฅผ ์ถ๊ฐํ์ธ์ (์ํ ๋ณ๊ฒฝ ์ด๋ฒคํธ ์๋):
```javascript
// ์ญ์ ์ด๋ฒคํธ
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?')) return;
const id = e.target.dataset.id;
const formData = new FormData();
formData.append('action', 'delete');
formData.append('id', id);
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
loadBuckets();
loadStats();
}
});
});
```
5. `showConfetti` ํจ์๋ฅผ canvas-confetti๋ก ๊ต์ฒดํ์ธ์:
```javascript
function showConfetti() {
const duration = 3000;
const end = Date.now() + duration;
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
(function frame() {
confetti({
particleCount: 3,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: colors
});
confetti({
particleCount: 3,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: colors
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
})();
// ์ถํ ๋ฉ์์ง ํ ์คํธ
const toast = document.createElement('div');
toast.textContent = '๐ ์ถํํฉ๋๋ค! ๋ฒํท๋ฆฌ์คํธ๋ฅผ ๋ฌ์ฑํ์
จ์ต๋๋ค!';
toast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--m-primary);
color: white;
padding: 16px 32px;
border-radius: var(--m-radius-lg);
box-shadow: var(--m-shadow);
z-index: 2000;
font-weight: 600;
animation: slideDown 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideUp 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
```
6. ์ ๋๋ฉ์ด์
keyframes๋ฅผ ์ถ๊ฐํ์ธ์ (`<style>` ๋ธ๋ก์ `</head>` ์ ์ ์ถ๊ฐ):
```html
<style>
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
</style>
```
7. ๋ธ๋ผ์ฐ์ ์์ ํ
์คํธ:
- ์ํ๋ฅผ "์๋ฃ"๋ก ๋ณ๊ฒฝ โ confetti ์ ๋๋ฉ์ด์
& ํ ์คํธ ๋ฉ์์ง
- ์ญ์ ๋ฒํผ ํด๋ฆญ โ ํ์ธ ํ ์นด๋ ์ ๊ฑฐ
๊ธฐ๋ ๊ฒฐ๊ณผ: - ๋ฒํท๋ฆฌ์คํธ๋ฅผ ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝ ์ ํ๋ฉด ์์ชฝ์์ confetti ํฐ์ง (3์ด๊ฐ) - ๋์์ ์๋จ ์ค์์ ์ถํ ํ ์คํธ ๋ฉ์์ง ํ์ (3์ด ํ ์ฌ๋ผ์ง) - ์นด๋ ์ฐ์ธก ์๋จ์ ์ฐ๋ ๊ธฐํต ์์ด์ฝ ํ์ - ์ญ์ ๋ฒํผ ํด๋ฆญ ์ ํ์ธ ๋ค์ด์ผ๋ก๊ทธ โ ํ์ธ ์ DB์์ ์ญ์ & UI ๊ฐฑ์ - ํต๊ณ๋ ํจ๊ป ์ ๋ฐ์ดํธ - ๋คํฌ๋ชจ๋์์๋ ํ ์คํธ ์ ์ ํ์
๋ง์ง๋ง ๋จ๊ณ์์๋ **ํ์๋ผ์ธ ๋ทฐ & ๋ฐ์ํ ๋์์ธ ์์ฑ**์ ๊ตฌํํฉ๋๋ค.
1. `app/modules/bucket-list/index.php`์ ์นดํ
๊ณ ๋ฆฌ ํญ ์๋์ ๋ทฐ ์ ํ ํ ๊ธ์ ์ถ๊ฐํ์ธ์:
```html
<!-- ๋ทฐ ์ ํ ํ ๊ธ -->
<div style="display: flex; justify-content: flex-end; margin-bottom: 24px;">
<div style="display: flex; gap: 4px; background: var(--m-surface); border: 1px solid var(--m-border); border-radius: var(--m-radius-lg); padding: 4px;">
<button id="grid-view-btn" class="view-toggle active" style="padding: 8px 16px; border: none; border-radius: 8px; background: var(--m-primary); color: white; cursor: pointer; font-size: 14px;">๐ ๊ทธ๋ฆฌ๋</button>
<button id="timeline-view-btn" class="view-toggle" style="padding: 8px 16px; border: none; border-radius: 8px; background: transparent; color: var(--m-text); cursor: pointer; font-size: 14px;">๐
ํ์๋ผ์ธ</button>
</div>
</div>
```
2. `<div id="bucket-grid">` ์๋์ ํ์๋ผ์ธ ์์ญ์ ์ถ๊ฐํ์ธ์:
```html
<!-- ํ์๋ผ์ธ ๋ทฐ -->
<div id="timeline-view" style="display: none; max-width: 800px; margin: 0 auto;">
<!-- ํ์๋ผ์ธ ํญ๋ชฉ๋ค์ด ์ฌ๊ธฐ ๋ ๋๋ง๋จ -->
</div>
```
3. `<script>` ๋ธ๋ก์ ํ์๋ผ์ธ ๋ ๋๋ง ํจ์๋ฅผ ์ถ๊ฐํ์ธ์:
```javascript
// ํ์๋ผ์ธ ๋ ๋๋ง
function renderTimeline(buckets) {
const completed = buckets.filter(b => b.status === 'done').sort((a, b) => {
return new Date(b.completed_at) - new Date(a.completed_at);
});
if (completed.length === 0) {
document.getElementById('timeline-view').innerHTML = '<p style="text-align: center; color: var(--m-text-muted); padding: 48px 0;">์์ง ๋ฌ์ฑํ ๋ฒํท๋ฆฌ์คํธ๊ฐ ์์ต๋๋ค.</p>';
return;
}
const categoryIcons = {
travel: 'โ๏ธ',
learning: '๐',
health: '๐ช',
career: '๐ผ',
hobby: '๐จ',
other: '๐'
};
document.getElementById('timeline-view').innerHTML = completed.map((b, i) => {
const createdDate = new Date(b.created_at);
const completedDate = new Date(b.completed_at);
const daysTaken = Math.floor((completedDate - createdDate) / (1000 * 60 * 60 * 24));
return `
<div style="position: relative; padding-left: 40px; padding-bottom: 32px; ${i === completed.length - 1 ? '' : 'border-left: 2px solid var(--m-border);'}">
<div style="position: absolute; left: -12px; top: 0; width: 24px; height: 24px; border-radius: 50%; background: var(--m-primary); display: flex; align-items: center; justify-content: center; font-size: 12px;">โ</div>
<div style="background: var(--m-surface); border-radius: var(--m-radius-lg); padding: 20px; box-shadow: var(--m-shadow);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<div>
<span style="font-size: 20px; margin-right: 8px;">${categoryIcons[b.category]}</span>
<span style="font-size: 14px; font-weight: 600; color: var(--m-primary);">${b.completed_at}</span>
</div>
<span style="font-size: 13px; color: var(--m-text-muted);">๐ ${daysTaken}์ผ ์์</span>
</div>
<h3 style="font-size: 18px; font-weight: 700; color: var(--m-text); margin-bottom: 8px;">${htmlspecialchars(b.title)}</h3>
${b.description ? `<p style="font-size: 14px; color: var(--m-text-muted);">${htmlspecialchars(b.description)}</p>` : ''}
</div>
</div>
`;
}).join('');
}
```
4. ๋ทฐ ์ ํ ๋ก์ง์ ์ถ๊ฐํ์ธ์:
```javascript
// ๋ทฐ ์ ํ
let currentView = 'grid';
document.getElementById('grid-view-btn').addEventListener('click', () => {
currentView = 'grid';
document.getElementById('bucket-grid').style.display = 'grid';
document.getElementById('timeline-view').style.display = 'none';
document.querySelectorAll('.view-toggle').forEach(btn => {
btn.style.background = 'transparent';
btn.style.color = 'var(--m-text)';
});
document.getElementById('grid-view-btn').style.background = 'var(--m-primary)';
document.getElementById('grid-view-btn').style.color = 'white';
});
document.getElementById('timeline-view-btn').addEventListener('click', () => {
currentView = 'timeline';
document.getElementById('bucket-grid').style.display = 'none';
document.getElementById('timeline-view').style.display = 'block';
document.querySelectorAll('.view-toggle').forEach(btn => {
btn.style.background = 'transparent';
btn.style.color = 'var(--m-text)';
});
document.getElementById('timeline-view-btn').style.background = 'var(--m-primary)';
document.getElementById('timeline-view-btn').style.color = 'white';
// ํ์๋ผ์ธ ๋ฐ์ดํฐ ๋ก๋
loadTimelineData();
});
async function loadTimelineData() {
const formData = new FormData();
formData.append('action', 'list');
formData.append('category', 'all'); // ํ์๋ผ์ธ์ ์ ์ฒด ํ์
const res = await fetch('', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
renderTimeline(data.buckets);
}
}
```
5. ๋ฐ์ํ CSS๋ฅผ ์ถ๊ฐํ์ธ์ (`<style>` ๋ธ๋ก์):
```css
@media (max-width: 768px) {
#bucket-grid {
grid-template-columns: 1fr !important;
}
.cat-tab {
font-size: 13px !important;
padding: 6px 12px !important;
}
#add-btn {
bottom: 20px !important;
right: 20px !important;
width: 56px !important;
height: 56px !important;
font-size: 24px !important;
}
#modal > div {
width: 95% !important;
padding: 24px !important;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
#bucket-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
```
6. ๋ธ๋ผ์ฐ์ ์์ ์ต์ข
ํ
์คํธ:
- ๊ทธ๋ฆฌ๋/ํ์๋ผ์ธ ๋ทฐ ์ ํ
- ๋ชจ๋ฐ์ผ(375px), ํ๋ธ๋ฆฟ(768px), ๋ฐ์คํฌํฑ(1200px) ๋ฐ์ํ ํ์ธ
- ๋คํฌ๋ชจ๋ ์ ํ ํ
์คํธ
- ์ ์ฒด ๊ธฐ๋ฅ ํตํฉ ํ
์คํธ (์ถ๊ฐ/์์ /์ญ์ /์๋ฃ/ํต๊ณ)
๊ธฐ๋ ๊ฒฐ๊ณผ: - ์ฐ์ธก ์๋จ์ ๊ทธ๋ฆฌ๋/ํ์๋ผ์ธ ๋ทฐ ํ ๊ธ ๋ฒํผ ํ์ - ํ์๋ผ์ธ ๋ทฐ ํด๋ฆญ ์ ์๋ฃ๋ ํญ๋ชฉ๋ง ์๊ฐ ์์ผ๋ก ํ์ - ๊ฐ ํญ๋ชฉ์ ๋ฌ์ฑ ๋ ์ง, ์์ ์ผ์, ์นดํ ๊ณ ๋ฆฌ ์์ด์ฝ ํ์ - ์ธ๋ก ํ์๋ผ์ธ ํํ (์ผ์ชฝ ์ + ์ฒดํฌ ์์ด์ฝ) - ๋ชจ๋ฐ์ผ: 1์ด ๊ทธ๋ฆฌ๋, ์์ ๋ฒํผ/ํญ - ํ๋ธ๋ฆฟ: 2์ด ๊ทธ๋ฆฌ๋ - ๋ฐ์คํฌํฑ: 3์ด ๊ทธ๋ฆฌ๋ - ๋ชจ๋ ๊ธฐ๋ฅ์ด ๋ชจ๋ฐ์ผ์์๋ ์ ์ ๋์ - ๋คํฌ๋ชจ๋์์ ๋ชจ๋ ์์ ๊ฐ๋ ์ฑ ํ๋ณด - ์์ฑ๋ ๋ฒํท๋ฆฌ์คํธ ๋ณด๋ ์๋น์ค!
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!