라우팅 추가 및 CSS 구성

- 개인 블로그
 - 로또
 - 여행

로고 이미지 추가 및 변경
This commit is contained in:
2026-01-18 10:50:45 +09:00
parent cb4978fe4a
commit 8462557ee3
28 changed files with 5727 additions and 674 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/main_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-ui</title> <title>가후습 개인기록</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

45
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^6.30.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1031,6 +1032,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53", "version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -2660,6 +2670,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -2677,6 +2688,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View File

@@ -14,7 +14,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^6.30.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

BIN
public/main_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

3620
public/main_logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,250 +1,64 @@
:root { :root {
--bg: #0b0f17; --bg: #0f0d12;
--card: #121a2a; --surface: rgba(26, 23, 32, 0.88);
--card2: #0f1626; --text: #f4efe9;
--text: #e8edf6; --muted: #b6b1a9;
--muted: #a7b2c7; --line: rgba(255, 255, 255, 0.12);
--line: rgba(255,255,255,0.08); --accent: #f7a8a5;
--accent: #7dd3fc; --accent-strong: #fdd4b1;
--good: #34d399; --font-display: "DM Serif Display", "Noto Serif KR", serif;
--warn: #fbbf24; --font-body: "Manrope", "Noto Sans KR", sans-serif;
--danger: #fb7185;
} }
* { box-sizing: border-box; } .app-shell {
html, body { height: 100%; } min-height: 100vh;
body {
margin: 0;
background: radial-gradient(1200px 800px at 20% 0%, rgba(125,211,252,0.13), transparent 60%),
radial-gradient(900px 650px at 80% 20%, rgba(52,211,153,0.10), transparent 55%),
var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans KR", sans-serif;
} }
.page { max-width: 1100px; margin: 0 auto; padding: 20px; } .site-main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px 80px;
}
.topbar { @keyframes fadeUp {
display: flex; align-items: center; justify-content: space-between; from {
padding: 14px 14px; opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.site-main > * {
animation: fadeUp 0.6s ease both;
}
.button {
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(18,26,42,0.7); padding: 10px 18px;
border-radius: 16px;
backdrop-filter: blur(8px);
}
.brand { display: flex; gap: 12px; align-items: center; }
.logo { width: 40px; height: 40px; display: grid; place-items: center; font-size: 22px;
background: rgba(125,211,252,0.12); border: 1px solid var(--line); border-radius: 12px;
}
.title { font-weight: 800; letter-spacing: 0.2px; }
.sub { color: var(--muted); font-size: 13px; margin-top: 2px; }
.topRight { display: flex; gap: 10px; align-items: center; }
.latestChip {
border: 1px solid var(--line);
background: rgba(0,0,0,0.25);
padding: 8px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 13px; text-decoration: none;
}
.tabs {
margin-top: 12px;
display: flex;
gap: 8px;
}
.tab {
border: 1px solid var(--line);
background: rgba(18,26,42,0.35);
color: var(--text); color: var(--text);
padding: 10px 12px; font-size: 14px;
border-radius: 12px; letter-spacing: 0.08em;
cursor: pointer; text-transform: uppercase;
} transition: all 0.2s ease;
.tab.on {
background: rgba(125,211,252,0.14);
border-color: rgba(125,211,252,0.35);
}
.main { margin-top: 14px; display: grid; gap: 14px; }
.card {
border: 1px solid var(--line);
background: rgba(18,26,42,0.5);
border-radius: 18px;
overflow: hidden;
}
.cardHead {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 14px 14px 10px 14px;
border-bottom: 1px solid var(--line);
background: rgba(15,22,38,0.35);
}
.cardTitle { font-size: 15px; font-weight: 800; }
.cardSub { color: var(--muted); font-size: 13px; margin-top: 4px; }
.cardBody { padding: 14px; }
.muted { color: var(--muted); }
.btn {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
color: var(--text);
cursor: pointer;
background: rgba(125,211,252,0.12);
}
.btn.secondary { background: rgba(255,255,255,0.06); }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.input, .select {
border: 1px solid var(--line);
background: rgba(0,0,0,0.25);
color: var(--text);
border-radius: 12px;
padding: 10px 10px;
min-width: 120px;
}
.textarea {
width: 100%;
min-height: 64px;
border: 1px solid var(--line);
background: rgba(0,0,0,0.25);
color: var(--text);
border-radius: 12px;
padding: 10px;
resize: vertical;
}
.params { display: grid; gap: 10px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.paramLabel { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.param { display: flex; flex-direction: column; }
.rowInline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chip {
border: 1px solid var(--line);
background: rgba(255,255,255,0.05);
color: var(--text);
border-radius: 999px;
padding: 8px 10px;
cursor: pointer;
}
.chip.on { border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.12); }
.pillRow { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.pill {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
}
.pill.ok { border-color: rgba(52,211,153,0.45); background: rgba(52,211,153,0.12); }
.pill.warn { border-color: rgba(251,191,36,0.45); background: rgba(251,191,36,0.12); }
.bigNums {
font-size: 26px;
font-weight: 900;
letter-spacing: 1px;
padding: 12px 14px;
border: 1px dashed rgba(125,211,252,0.25);
border-radius: 16px;
background: rgba(0,0,0,0.20);
} }
.details { margin-top: 12px; } .button:hover {
.pre { border-color: rgba(255, 255, 255, 0.3);
white-space: pre-wrap; transform: translateY(-2px);
word-break: break-word;
border: 1px solid var(--line);
background: rgba(0,0,0,0.25);
padding: 10px;
border-radius: 12px;
margin-top: 10px;
} }
.empty { .button.primary {
color: var(--muted); background: linear-gradient(135deg, var(--accent), var(--accent-strong));
padding: 16px; color: #1a1414;
border: 1px dashed rgba(255,255,255,0.12); border-color: transparent;
border-radius: 14px;
background: rgba(0,0,0,0.15);
} }
.batchGrid { .button.ghost {
display: grid; background: transparent;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
} }
.batchItem {
text-align: left;
border: 1px solid var(--line);
background: rgba(0,0,0,0.22);
border-radius: 16px;
padding: 12px;
cursor: pointer;
}
.batchItem.on {
border-color: rgba(52,211,153,0.55);
background: rgba(52,211,153,0.10);
}
.batchIdx { color: var(--muted); font-size: 12px; }
.batchNums { font-weight: 900; font-size: 18px; margin-top: 8px; }
.batchHint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.pager { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.pagerText { color: var(--muted); }
.historyList { display: grid; gap: 10px; }
.row {
border: 1px solid var(--line);
background: rgba(0,0,0,0.18);
border-radius: 16px;
padding: 12px;
}
.rowTop { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.rowMeta { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.rowId { font-weight: 900; }
.rowDate, .rowBased { color: var(--muted); font-size: 12px; }
.rowActions { display: flex; gap: 8px; }
.iconBtn {
border: 1px solid var(--line);
background: rgba(255,255,255,0.06);
color: var(--text);
border-radius: 12px;
padding: 8px 10px;
cursor: pointer;
}
.iconBtn.on { border-color: rgba(251,191,36,0.6); background: rgba(251,191,36,0.14); }
.iconBtn.danger { border-color: rgba(251,113,133,0.45); background: rgba(251,113,133,0.12); }
.rowNums { font-size: 18px; font-weight: 900; margin-top: 10px; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.tag {
border: 1px solid rgba(125,211,252,0.25);
background: rgba(125,211,252,0.10);
border-radius: 999px;
padding: 5px 9px;
font-size: 12px;
color: var(--text);
}
.rowEdit {
margin-top: 10px;
display: grid;
grid-template-columns: 2fr 2fr 1fr;
gap: 10px;
align-items: end;
}
.editLabel { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.editButtons { display: flex; justify-content: flex-end; }
.tips { margin: 0; padding-left: 18px; color: var(--muted); }
.footer { margin-top: 14px; display: flex; justify-content: center; }

View File

@@ -1,306 +1,17 @@
// src/App.jsx import React from 'react';
import React, { useEffect, useMemo, useState } from "react"; import { Outlet } from 'react-router-dom';
import { deleteHistory, getHistory, getLatest, recommend } from "./api"; import Navbar from './components/Navbar';
import './App.css';
function fmtKST(iso) { function App() {
// sqlite datetime('now') -> "YYYY-MM-DD HH:MM:SS" (UTC 로 저장될 수도)
// 그냥 표시용으로만 사용
return iso?.replace("T", " ") ?? "";
}
function Ball({ n }) {
// 범위별 톤만 다르게(색 직접 지정 안 하고, hue를 계산해도 되지만 여기선 단순)
const cls =
n <= 10 ? "ball ball-a" : n <= 20 ? "ball ball-b" : n <= 30 ? "ball ball-c" : n <= 40 ? "ball ball-d" : "ball ball-e";
return <span className={cls}>{n}</span>;
}
function NumberRow({ nums }) {
return ( return (
<div className="row numbers"> <div className="app-shell">
{nums.map((n) => ( <Navbar />
<Ball key={n} n={n} /> <main className="site-main">
))} <Outlet />
</main>
</div> </div>
); );
} }
export default function App() { export default App;
const [latest, setLatest] = useState(null);
const [params, setParams] = useState({
recent_window: 200,
recent_weight: 2.0,
avoid_recent_k: 5,
});
const presets = useMemo(
() => [
{ name: "기본", recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
{ name: "최근 가중↑", recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
{ name: "안전(분산)", recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
{ name: "공격(최근)", recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
],
[]
);
const [result, setResult] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState({ latest: false, recommend: false, history: false });
const [error, setError] = useState("");
const refreshLatest = async () => {
setLoading((s) => ({ ...s, latest: true }));
setError("");
try {
const data = await getLatest();
setLatest(data);
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, latest: false }));
}
};
const refreshHistory = async () => {
setLoading((s) => ({ ...s, history: true }));
setError("");
try {
const data = await getHistory(30);
setHistory(data.items ?? []);
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, history: false }));
}
};
const onRecommend = async () => {
setLoading((s) => ({ ...s, recommend: true }));
setError("");
try {
const data = await recommend(params);
setResult(data);
// 추천 생성 시 DB에 저장되므로 히스토리도 바로 갱신
await refreshHistory();
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, recommend: false }));
}
};
const onDelete = async (id) => {
const ok = confirm(`히스토리 #${id} 삭제할까?`);
if (!ok) return;
setError("");
try {
await deleteHistory(id);
setHistory((prev) => prev.filter((x) => x.id !== id));
} catch (e) {
setError(e?.message ?? String(e));
}
};
const copyNumbers = async (nums) => {
const text = nums.join(", ");
try {
await navigator.clipboard.writeText(text);
alert(`복사됨: ${text}`);
} catch {
// fallback
prompt("복사해서 사용하세요:", text);
}
};
useEffect(() => {
// 첫 로드
refreshLatest();
refreshHistory();
}, []);
return (
<div className="page">
<header className="header">
<div>
<h1>로또 추천기</h1>
<p className="sub">
최신 회차 기준 추천 + 히스토리 저장/삭제
</p>
</div>
<div className="headerActions">
<button className="btn ghost" onClick={refreshLatest} disabled={loading.latest}>
최신 불러오기
</button>
<button className="btn ghost" onClick={refreshHistory} disabled={loading.history}>
히스토리 새로고침
</button>
</div>
</header>
{error ? (
<div className="card error">
<div className="row between">
<strong>에러</strong>
<button className="btn tiny" onClick={() => setError("")}>닫기</button>
</div>
<pre className="pre">{error}</pre>
</div>
) : null}
<div className="grid">
{/* LEFT */}
<section className="card">
<div className="row between">
<h2>최신 회차</h2>
{loading.latest ? <span className="pill">로딩...</span> : null}
</div>
{latest ? (
<>
<div className="row between">
<div className="meta">
<div><strong>{latest.drawNo}</strong></div>
<div className="muted">{latest.date}</div>
</div>
<button className="btn" onClick={() => copyNumbers(latest.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={latest.numbers} />
<div className="muted small">보너스: <strong>{latest.bonus}</strong></div>
</>
) : (
<div className="muted">최신 데이터 없음</div>
)}
</section>
{/* RIGHT */}
<section className="card">
<div className="row between">
<h2>추천 생성</h2>
{loading.recommend ? <span className="pill">계산중...</span> : null}
</div>
<div className="row wrap">
{presets.map((p) => (
<button
key={p.name}
className="btn ghost"
onClick={() => setParams({ recent_window: p.recent_window, recent_weight: p.recent_weight, avoid_recent_k: p.avoid_recent_k })}
>
{p.name}
</button>
))}
</div>
<div className="form">
<label>
recent_window <span className="muted">(최근 N회 가중)</span>
<input
type="number"
min={20}
max={1000}
value={params.recent_window}
onChange={(e) => setParams((s) => ({ ...s, recent_window: Number(e.target.value) }))}
/>
</label>
<label>
recent_weight <span className="muted">(최근 가중치)</span>
<input
type="number"
step="0.1"
min={0.5}
max={10}
value={params.recent_weight}
onChange={(e) => setParams((s) => ({ ...s, recent_weight: Number(e.target.value) }))}
/>
</label>
<label>
avoid_recent_k <span className="muted">(최근 K회 번호 회피)</span>
<input
type="number"
min={0}
max={50}
value={params.avoid_recent_k}
onChange={(e) => setParams((s) => ({ ...s, avoid_recent_k: Number(e.target.value) }))}
/>
</label>
<button className="btn primary" onClick={onRecommend} disabled={loading.recommend}>
추천 받기
</button>
</div>
{result ? (
<div className="result">
<div className="row between">
<div>
<div className="muted small">추천 ID: #{result.id}</div>
<div className="muted small">기준 회차: {result.based_on_latest_draw ?? "-"}</div>
</div>
<button className="btn" onClick={() => copyNumbers(result.numbers)}>
번호 복사
</button>
</div>
<NumberRow nums={result.numbers} />
<details className="details">
<summary>설명 보기</summary>
<pre className="pre">{JSON.stringify(result.explain, null, 2)}</pre>
</details>
</div>
) : (
<div className="muted">아직 추천 결과 없음</div>
)}
</section>
</div>
<section className="card">
<div className="row between">
<h2>히스토리</h2>
<div className="muted small">{history.length}</div>
</div>
{loading.history ? <div className="muted">불러오는 ...</div> : null}
{history.length === 0 ? (
<div className="muted">저장된 히스토리가 없습니다.</div>
) : (
<div className="table">
{history.map((h) => (
<div key={h.id} className="tr">
<div className="td">
<div className="muted small">#{h.id}</div>
<div className="muted small">{fmtKST(h.created_at)}</div>
<div className="muted small">기준: {h.based_on_draw ?? "-"}</div>
</div>
<div className="td grow">
<NumberRow nums={h.numbers} />
<div className="muted small">
window={h.params?.recent_window}, weight={h.params?.recent_weight}, avoid_k={h.params?.avoid_recent_k}
</div>
</div>
<div className="td actions">
<button className="btn" onClick={() => copyNumbers(h.numbers)}>복사</button>
<button className="btn danger" onClick={() => onDelete(h.id)}>삭제</button>
</div>
</div>
))}
</div>
)}
</section>
<footer className="footer muted small">
backend: FastAPI / nginx proxy / DB: sqlite
</footer>
</div>
);
}

14
src/Router.jsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react';
import { createBrowserRouter } from 'react-router-dom';
import App from './App';
import { appRoutes } from './routes.jsx';
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: appRoutes,
},
]);
export default router;

BIN
src/assets/main_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

49
src/assets/main_logo.svg Normal file
View File

@@ -0,0 +1,49 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="500.000000pt" height="500.000000pt" viewBox="0 0 500.000000 500.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,500.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2165 3790 c-187 -99 -347 -184 -355 -189 -13 -7 -15 -94 -17 -669
-2 -614 -1 -662 15 -680 9 -11 138 -87 285 -168 229 -127 265 -150 251 -161
-9 -7 -135 -79 -280 -160 l-264 -148 0 -51 0 -52 108 -62 c118 -70 545 -325
577 -346 23 -15 45 -3 515 279 l230 138 0 294 0 294 -70 -40 -70 -41 0 -164
c0 -93 -4 -164 -9 -164 -11 0 -400 215 -413 228 -4 4 98 69 227 143 129 73
235 140 235 146 0 13 -38 82 -145 268 -83 144 -145 255 -145 260 0 9 143 180
151 180 4 0 22 -18 39 -40 17 -22 36 -40 41 -40 5 0 40 40 77 89 37 49 72 91
78 93 6 2 59 -55 118 -127 l107 -130 -31 -54 c-16 -30 -30 -58 -30 -61 0 -3
93 -4 207 -3 l208 3 27 95 c36 129 38 260 4 380 -24 85 -61 180 -70 180 -3 0
-14 -17 -24 -37 -11 -21 -43 -78 -72 -128 -29 -49 -82 -141 -118 -202 -35 -62
-67 -113 -71 -112 -3 0 -32 33 -63 72 -144 181 -179 222 -188 225 -5 2 -41
-40 -81 -92 -39 -53 -75 -96 -79 -96 -4 0 -23 20 -43 45 l-35 46 -59 -72 c-79
-98 -124 -149 -132 -149 -3 0 -15 16 -27 36 l-20 36 -31 -48 c-46 -75 -118
-200 -149 -259 l-28 -53 39 -47 c57 -71 139 -132 243 -182 51 -25 92 -47 90
-49 -2 -2 -80 -47 -174 -100 -93 -53 -184 -105 -203 -116 -33 -19 -33 -19 -70
3 -68 41 -106 62 -311 177 -113 63 -210 121 -216 128 -16 20 -15 1149 0 1169
6 7 40 29 76 48 36 19 160 84 276 146 116 61 214 111 218 111 4 0 65 -32 137
-71 l131 -72 46 21 c26 11 64 26 85 32 20 6 37 13 37 15 0 5 -433 236 -442
235 -2 -1 -156 -82 -343 -180z m524 -2039 c97 -54 210 -117 251 -139 41 -22
76 -44 77 -49 1 -4 -48 -37 -109 -73 -61 -36 -129 -77 -152 -91 -93 -58 -234
-139 -244 -139 -6 0 -124 68 -262 151 l-252 152 74 42 c107 62 432 245 437
245 2 0 83 -44 180 -99z"/>
<path d="M3146 3671 c-3 -4 12 -37 33 -72 21 -35 79 -134 128 -219 50 -85 117
-201 150 -258 l61 -103 26 48 c15 26 31 55 36 63 6 8 19 33 31 55 12 22 41 72
65 112 24 39 44 76 44 82 0 21 -148 159 -207 194 -83 48 -184 83 -277 96 -93
13 -84 13 -90 2z"/>
<path d="M2930 3646 c-151 -45 -283 -136 -374 -257 l-48 -64 38 -7 c22 -3 167
-4 324 -3 157 2 309 4 338 5 l54 0 -29 53 c-16 28 -52 90 -79 137 -27 47 -58
102 -69 123 -24 45 -44 47 -155 13z"/>
<path d="M1520 3198 c-63 -55 -149 -131 -190 -168 -41 -37 -101 -89 -132 -116
-55 -46 -57 -49 -40 -67 9 -11 124 -113 255 -228 l237 -210 0 100 0 99 -37 28
c-29 22 -197 169 -248 216 -11 10 -7 13 178 177 l107 95 0 88 c0 48 -3 88 -7
87 -5 0 -60 -45 -123 -101z"/>
<path d="M2452 3198 c-63 -171 -48 -440 33 -574 21 -35 24 -37 31 -19 4 11 27
52 49 90 98 164 315 543 315 549 0 3 -92 6 -204 6 l-205 0 -19 -52z"/>
<path d="M3012 2568 c8 -13 37 -61 63 -108 39 -68 133 -229 139 -237 1 -1 32
5 69 13 133 28 249 90 353 191 60 57 124 139 124 158 0 3 -172 5 -381 5 l-381
0 14 -22z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

93
src/components/Navbar.css Normal file
View File

@@ -0,0 +1,93 @@
.site-nav {
position: sticky;
top: 0;
z-index: 10;
background: rgba(16, 16, 24, 0.82);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.site-nav__inner {
max-width: 1200px;
margin: 0 auto;
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.site-nav__brand {
display: flex;
align-items: center;
gap: 14px;
}
.site-nav__logo-image {
width: 42px;
height: 42px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__logo {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: var(--font-display);
font-size: 20px;
color: #1b1a24;
background: linear-gradient(135deg, #fdd4b1, #f7a8a5);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__title {
margin: 0;
font-weight: 600;
letter-spacing: 0.02em;
}
.site-nav__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.site-nav__links {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.site-nav__link {
text-decoration: none;
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
padding: 8px 12px;
border-radius: 999px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.site-nav__link:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.site-nav__link.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.16);
color: #ffe9e2;
}
@media (max-width: 800px) {
.site-nav__inner {
flex-direction: column;
align-items: flex-start;
}
}

36
src/components/Navbar.jsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import mainLogo from '../assets/main_logo.png';
import './Navbar.css';
const Navbar = () => {
return (
<header className="site-nav">
<div className="site-nav__inner">
<div className="site-nav__brand">
<img src={mainLogo} alt="Logo" className="site-nav__logo-image" />
<div>
<p className="site-nav__title">Jaeoh Archive</p>
<p className="site-nav__subtitle">Stories, notes, and snapshots</p>
</div>
</div>
<nav className="site-nav__links">
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`site-nav__link${isActive ? ' is-active' : ''}`
}
>
{link.label}
</NavLink>
))}
</nav>
</div>
</header>
);
};
export default Navbar;

View File

@@ -0,0 +1,17 @@
---
title: 로또 실험실을 조금 더 재미있게
date: 2026-01-12
tags: product, lotto
excerpt: 작은 실험으로 시작한 로또 페이지를 앞으로 어떻게 발전시키려는지 정리했습니다.
---
# 로또 실험실을 조금 더 재미있게
처음에는 숫자를 뽑는 기능만 있었지만, 데이터 기록과 패턴 시각화를 더해보고 싶었습니다.
지금은 간단한 기능만 있지만, 앞으로는 아래 방향으로 확장하려 합니다.
- 추첨 기록을 캘린더 뷰로 보기
- 자주 등장하는 숫자 조합 시각화
- 개인별 기록을 비교할 수 있는 리포트
이 블로그에 중간 과정과 고민들을 계속 기록해 보려고 합니다.

View File

@@ -0,0 +1,18 @@
---
title: 새 블로그를 열었습니다
date: 2026-01-18
tags: intro, blog
excerpt: 이제부터 개발 기록과 여행 기록을 이곳에 차곡차곡 쌓아갑니다.
---
# 새 블로그를 열었습니다
처음엔 로또 페이지로 시작했지만, 이 공간을 개인 아카이브로 확장하려고 합니다.
작은 실험, 긴 이야기, 그리고 여행에서 얻은 감정까지 모두 모아둘 예정입니다.
앞으로 이곳에서 하고 싶은 것들:
- 틈틈이 쓰는 개발 메모
- 사진과 함께 기록하는 여행기
- 나만의 프로젝트 회고
첫 페이지를 열어둡니다. 천천히 채워나갈게요.

79
src/data/blog.js Normal file
View File

@@ -0,0 +1,79 @@
const collectFrontmatter = (raw) => {
const lines = raw.split(/\r?\n/);
const meta = {};
let cursor = 0;
if (lines[0]?.trim() === '---') {
cursor = 1;
for (; cursor < lines.length; cursor += 1) {
const line = lines[cursor].trim();
if (line === '---') {
cursor += 1;
break;
}
if (!line) continue;
const [key, ...rest] = line.split(':');
meta[key.trim()] = rest.join(':').trim();
}
}
const body = lines.slice(cursor).join('\n').trim();
return { meta, body };
};
const extractTitle = (body) => {
const titleLine = body.split(/\r?\n/).find((line) => line.startsWith('# '));
return titleLine ? titleLine.replace(/^#\s+/, '').trim() : '';
};
const extractExcerpt = (body) => {
const lines = body.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
if (line.startsWith('#')) continue;
return line.trim();
}
return '';
};
const normalizeTags = (value) => {
if (!value) return [];
return value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
};
const normalizeTitle = (slug) =>
slug
.replace(/-/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
export const getBlogPosts = () => {
const modules = import.meta.glob('/src/content/blog/*.md', {
as: 'raw',
eager: true,
});
const posts = Object.entries(modules).map(([path, raw]) => {
const slug = path.split('/').pop().replace(/\.md$/, '');
const { meta, body } = collectFrontmatter(raw);
const title = meta.title || extractTitle(body) || normalizeTitle(slug);
const excerpt = meta.excerpt || extractExcerpt(body);
const date = meta.date || '';
return {
slug,
title,
excerpt,
date,
tags: normalizeTags(meta.tags),
body,
};
});
return posts.sort((a, b) => {
const aDate = Date.parse(a.date || '') || 0;
const bDate = Date.parse(b.date || '') || 0;
return bDate - aDate;
});
};

58
src/data/travel.js Normal file
View File

@@ -0,0 +1,58 @@
export const travelGallery = [
{
id: 'jeju-dawn',
title: 'Jeju Dawn Ride',
location: 'Jeju Island',
month: '2025.04',
image:
'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=900&q=80',
},
{
id: 'osaka-night',
title: 'Osaka Night Walk',
location: 'Osaka, Japan',
month: '2024.11',
image:
'https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=900&q=80',
},
{
id: 'taipei-rain',
title: 'Taipei in the Rain',
location: 'Taipei, Taiwan',
month: '2024.08',
image:
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=900&q=80',
},
{
id: 'berlin-museum',
title: 'Berlin Museum Day',
location: 'Berlin, Germany',
month: '2024.03',
image:
'https://images.unsplash.com/photo-1441716844725-09cedc13a4e7?auto=format&fit=crop&w=900&q=80',
},
{
id: 'busan-coast',
title: 'Busan Coastline',
location: 'Busan, Korea',
month: '2023.12',
image:
'https://images.unsplash.com/photo-1470770903676-69b98201ea1c?auto=format&fit=crop&w=900&q=80',
},
{
id: 'vietnam-market',
title: 'Hoi An Market',
location: 'Hoi An, Vietnam',
month: '2023.09',
image:
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=80',
},
{
id: 'chiangmai-temple',
title: 'Chiang Mai Temple',
location: 'Chiang Mai, Thailand',
month: '2023.06',
image:
'https://images.unsplash.com/photo-1489515217757-5fd1be406fef?auto=format&fit=crop&w=900&q=80',
},
];

View File

@@ -1,143 +1,27 @@
:root { @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap');
--bg: #0b0f19;
--card: #111827; * {
--text: #e5e7eb; box-sizing: border-box;
--muted: #9ca3af; }
--border: rgba(255,255,255,0.08);
--shadow: 0 20px 50px rgba(0,0,0,0.35); html,
body {
height: 100%;
} }
* { box-sizing: border-box; }
html, body { height: 100%; }
body { body {
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Apple SD Gothic Neo, "Noto Sans KR", "Malgun Gothic", sans-serif; background: radial-gradient(1000px 600px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 60%),
background: radial-gradient(1200px 800px at 20% 0%, rgba(99,102,241,0.22), transparent 60%), radial-gradient(800px 600px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 60%),
radial-gradient(1200px 800px at 80% 10%, rgba(16,185,129,0.18), transparent 55%), #0f0d12;
var(--bg);
color: var(--text); color: var(--text);
font-family: var(--font-body);
} }
a { color: inherit; } a {
color: inherit;
.page { max-width: 1100px; margin: 0 auto; padding: 28px 16px 60px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.header h1 { margin: 0; font-size: 28px; letter-spacing: -0.02em; }
.sub { margin: 6px 0 0; color: var(--muted); }
.card {
background: rgba(17,24,39,0.9);
border: 1px solid var(--border);
border-radius: 18px;
padding: 16px;
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
} }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; } img {
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } } max-width: 100%;
.row { display: flex; align-items: center; gap: 10px; }
.row.between { justify-content: space-between; }
.row.wrap { flex-wrap: wrap; }
.numbers { gap: 8px; flex-wrap: wrap; }
.meta { display: grid; gap: 2px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.btn {
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
transition: transform .05s ease, background .15s ease;
} }
.btn:hover { background: rgba(255,255,255,0.10); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn.primary {
border-color: rgba(99,102,241,0.35);
background: rgba(99,102,241,0.25);
}
.btn.primary:hover { background: rgba(99,102,241,0.33); }
.btn.ghost { background: transparent; }
.btn.tiny { padding: 6px 10px; border-radius: 10px; font-size: 12px; }
.btn.danger { border-color: rgba(239,68,68,0.35); background: rgba(239,68,68,0.18); }
.btn.danger:hover { background: rgba(239,68,68,0.25); }
.pill {
font-size: 12px;
color: var(--muted);
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 999px;
}
.form { display: grid; gap: 10px; margin-top: 10px; }
label { display: grid; gap: 6px; font-size: 13px; }
input {
border: 1px solid var(--border);
background: rgba(0,0,0,0.25);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
outline: none;
}
input:focus { border-color: rgba(99,102,241,0.55); }
.result { margin-top: 12px; display: grid; gap: 10px; }
.details summary { cursor: pointer; color: var(--muted); }
.pre {
white-space: pre-wrap;
overflow: auto;
background: rgba(0,0,0,0.28);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
font-size: 12px;
}
.table { display: grid; gap: 10px; margin-top: 10px; }
.tr {
display: grid;
grid-template-columns: 170px 1fr 160px;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(255,255,255,0.03);
}
@media (max-width: 900px) { .tr { grid-template-columns: 1fr; } }
.td.actions { display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
.td.grow { min-width: 0; }
.ball {
width: 40px; height: 40px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
}
.ball-a { box-shadow: 0 0 0 6px rgba(59,130,246,0.10) inset; }
.ball-b { box-shadow: 0 0 0 6px rgba(16,185,129,0.10) inset; }
.ball-c { box-shadow: 0 0 0 6px rgba(245,158,11,0.10) inset; }
.ball-d { box-shadow: 0 0 0 6px rgba(168,85,247,0.10) inset; }
.ball-e { box-shadow: 0 0 0 6px rgba(239,68,68,0.10) inset; }
.card.error {
border-color: rgba(239,68,68,0.35);
background: rgba(239,68,68,0.10);
margin-bottom: 14px;
}
.footer { margin-top: 14px; text-align: center; }

View File

@@ -1,10 +1,11 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import { RouterProvider } from "react-router-dom";
import router from "./Router.jsx";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<App /> <RouterProvider router={router} />
</React.StrictMode> </React.StrictMode>
); );

191
src/pages/blog/Blog.css Normal file
View File

@@ -0,0 +1,191 @@
.blog {
display: grid;
gap: 28px;
}
.blog-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 24px;
align-items: center;
}
.blog-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
margin: 0 0 10px;
}
.blog-header h1 {
font-family: var(--font-display);
margin: 0 0 12px;
font-size: clamp(30px, 4vw, 40px);
}
.blog-sub {
margin: 0;
color: var(--muted);
}
.blog-status {
border: 1px solid var(--line);
border-radius: 20px;
padding: 20px;
background: var(--surface);
}
.blog-status__title {
margin: 0 0 8px;
font-weight: 600;
}
.blog-status__desc {
margin: 0;
color: var(--muted);
}
.blog-grid {
display: grid;
grid-template-columns: minmax(0, 0.45fr) minmax(0, 0.55fr);
gap: 22px;
align-items: start;
}
.blog-list {
display: grid;
gap: 12px;
}
.blog-list__item {
border: 1px solid var(--line);
background: var(--surface);
padding: 16px;
border-radius: 18px;
text-align: left;
cursor: pointer;
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
}
.blog-list__item:hover {
border-color: rgba(255, 255, 255, 0.25);
}
.blog-list__item.is-active {
border-color: rgba(247, 168, 165, 0.6);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.blog-list__title {
margin: 0;
font-weight: 600;
font-size: 16px;
}
.blog-list__excerpt {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.blog-list__meta {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
}
.blog-article {
border: 1px solid var(--line);
border-radius: 24px;
background: rgba(9, 10, 16, 0.65);
padding: 24px;
}
.blog-article__meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.blog-tags {
display: flex;
gap: 6px;
}
.blog-tag {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11px;
text-transform: none;
letter-spacing: 0.06em;
}
.blog-article__body h1,
.blog-article__body h2,
.blog-article__body h3 {
font-family: var(--font-display);
margin: 22px 0 10px;
}
.blog-article__body h1 {
font-size: 30px;
}
.blog-article__body h2 {
font-size: 24px;
}
.blog-article__body h3 {
font-size: 20px;
}
.md-paragraph {
margin: 0 0 14px;
color: var(--muted);
line-height: 1.8;
}
.blog-article__body ul {
margin: 0 0 16px;
padding-left: 18px;
color: var(--muted);
}
.blog-article__body code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 6px;
font-size: 0.85em;
}
.md-code {
padding: 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.4);
font-size: 13px;
overflow-x: auto;
}
.blog-empty {
color: var(--muted);
}
@media (max-width: 900px) {
.blog-header,
.blog-grid {
grid-template-columns: 1fr;
}
}

190
src/pages/blog/Blog.jsx Normal file
View File

@@ -0,0 +1,190 @@
import React, { useMemo, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import './Blog.css';
const renderInline = (text) => {
const pattern = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
const parts = text.split(pattern).filter(Boolean);
return parts.map((part, index) => {
if (part.startsWith('**')) {
return (
<strong key={`${part}-${index}`}>{part.replace(/\*\*/g, '')}</strong>
);
}
if (part.startsWith('*')) {
return <em key={`${part}-${index}`}>{part.replace(/\*/g, '')}</em>;
}
if (part.startsWith('`')) {
return <code key={`${part}-${index}`}>{part.replace(/`/g, '')}</code>;
}
return <span key={`${part}-${index}`}>{part}</span>;
});
};
const renderMarkdown = (body) => {
const lines = body.split(/\r?\n/);
const blocks = [];
let list = [];
let code = [];
let inCode = false;
const flushList = () => {
if (list.length) {
blocks.push({ type: 'list', items: list });
list = [];
}
};
const flushCode = () => {
if (code.length) {
blocks.push({ type: 'code', value: code.join('\n') });
code = [];
}
};
lines.forEach((line) => {
if (line.startsWith('```')) {
if (inCode) {
flushCode();
inCode = false;
} else {
flushList();
inCode = true;
}
return;
}
if (inCode) {
code.push(line);
return;
}
if (/^[-*]\s+/.test(line)) {
list.push(line.replace(/^[-*]\s+/, ''));
return;
}
flushList();
if (!line.trim()) {
return;
}
if (line.startsWith('### ')) {
blocks.push({ type: 'h3', value: line.replace(/^###\s+/, '') });
return;
}
if (line.startsWith('## ')) {
blocks.push({ type: 'h2', value: line.replace(/^##\s+/, '') });
return;
}
if (line.startsWith('# ')) {
blocks.push({ type: 'h1', value: line.replace(/^#\s+/, '') });
return;
}
blocks.push({ type: 'p', value: line });
});
flushList();
flushCode();
return blocks.map((block, index) => {
if (block.type === 'h1') return <h1 key={index}>{block.value}</h1>;
if (block.type === 'h2') return <h2 key={index}>{block.value}</h2>;
if (block.type === 'h3') return <h3 key={index}>{block.value}</h3>;
if (block.type === 'list')
return (
<ul key={index}>
{block.items.map((item, itemIndex) => (
<li key={itemIndex}>{renderInline(item)}</li>
))}
</ul>
);
if (block.type === 'code')
return (
<pre key={index} className="md-code">
<code>{block.value}</code>
</pre>
);
return (
<p key={index} className="md-paragraph">
{renderInline(block.value)}
</p>
);
});
};
const Blog = () => {
const posts = useMemo(() => getBlogPosts(), []);
const [activeSlug, setActiveSlug] = useState(posts[0]?.slug);
const activePost = posts.find((post) => post.slug === activeSlug) || posts[0];
return (
<div className="blog">
<header className="blog-header">
<div>
<p className="blog-kicker">Journal</p>
<h1>개인 블로그</h1>
<p className="blog-sub">
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
</p>
</div>
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
</div>
</header>
<div className="blog-grid">
<aside className="blog-list">
{posts.map((post) => (
<button
key={post.slug}
type="button"
className={`blog-list__item${
post.slug === activeSlug ? ' is-active' : ''
}`}
onClick={() => setActiveSlug(post.slug)}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
))}
</aside>
<article className="blog-article">
{activePost ? (
<>
<div className="blog-article__meta">
<span>{activePost.date || '작성일 미정'}</span>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</span>
)}
</div>
<div className="blog-article__body">
{renderMarkdown(activePost.body)}
</div>
</>
) : (
<p className="blog-empty">
아직 작성된 글이 없습니다. `src/content/blog` 마크다운 파일을
추가해 주세요.
</p>
)}
</article>
</div>
</div>
);
};
export default Blog;

203
src/pages/home/Home.css Normal file
View File

@@ -0,0 +1,203 @@
.home {
display: grid;
gap: 60px;
}
.home > section {
animation: fadeUp 0.7s ease both;
}
.home > section:nth-child(1) {
animation-delay: 0.05s;
}
.home > section:nth-child(2) {
animation-delay: 0.12s;
}
.home > section:nth-child(3) {
animation-delay: 0.18s;
}
.home-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 32px;
align-items: center;
}
.home-hero__kicker {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--accent);
margin: 0 0 12px;
}
.home-hero h1 {
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 46px);
margin: 0 0 16px;
}
.home-hero__lead {
color: var(--muted);
line-height: 1.7;
margin: 0 0 24px;
}
.home-hero__actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.home-hero__card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
}
.home-hero__card-title {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.home-hero__card-body h2 {
font-family: var(--font-display);
font-size: 24px;
margin: 0 0 12px;
}
.home-hero__stats {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.stat-label {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.stat-value {
margin: 6px 0 0;
font-weight: 600;
font-size: 18px;
}
.home-section__header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
}
.home-section__header h2 {
margin: 0;
font-size: 26px;
font-family: var(--font-display);
}
.home-section__header p {
margin: 0;
color: var(--muted);
}
.home-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.home-card {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
text-decoration: none;
color: inherit;
padding: 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
transition: transform 0.2s ease, border-color 0.2s ease;
}
.home-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.22);
}
.home-card__title {
font-weight: 600;
font-size: 18px;
margin: 0 0 8px;
}
.home-card__desc {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.home-card__cta {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
}
.home-posts {
display: grid;
gap: 12px;
}
.home-post {
text-decoration: none;
color: inherit;
border: 1px solid var(--line);
padding: 16px 18px;
border-radius: 16px;
background: var(--surface);
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
}
.home-post:hover {
border-color: rgba(255, 255, 255, 0.25);
}
.home-post__title {
margin: 0;
font-weight: 600;
font-size: 18px;
}
.home-post__excerpt {
margin: 0;
color: var(--muted);
}
.home-post__meta {
font-size: 12px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.14em;
}
@media (max-width: 900px) {
.home-hero {
grid-template-columns: 1fr;
}
}

89
src/pages/home/Home.jsx Normal file
View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { navLinks } from '../../routes.jsx';
import { getBlogPosts } from '../../data/blog';
import './Home.css';
const Home = () => {
const posts = getBlogPosts().slice(0, 3);
const highlights = navLinks.filter((link) => link.id !== 'home');
return (
<div className="home">
<section className="home-hero">
<div className="home-hero__text">
<p className="home-hero__kicker">Personal Archive</p>
<h1>기록을 모으고, 이야기를 이어붙이는 작은 .</h1>
<p className="home-hero__lead">
개발 실험, 여행 스냅, 그리고 생각을 모아두는 공간입니다. 블로그 글은
마크다운으로 작성해 계속 추가할 있어요.
</p>
<div className="home-hero__actions">
<Link className="button primary" to="/blog">
블로그 둘러보기
</Link>
<Link className="button ghost" to="/travel">
여행 갤러리 열기
</Link>
</div>
</div>
<div className="home-hero__card">
<p className="home-hero__card-title">이번 집중 테마</p>
<div className="home-hero__card-body">
<h2>느린 기록, 깊은 회고</h2>
<p>
빠르게 업데이트하는 대신, 번쯤 되돌아보며 기록하는 목표로
합니다. 글은 매주 편씩 추가될 예정이에요.
</p>
</div>
<div className="home-hero__stats">
<div>
<p className="stat-label">게시 </p>
<p className="stat-value">{posts.length}</p>
</div>
<div>
<p className="stat-label">다음 업데이트</p>
<p className="stat-value">이번 주말</p>
</div>
</div>
</div>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>공간 둘러보기</h2>
<p>확장 가능한 구조로 구성해 이후에도 쉽게 페이지를 추가할 있습니다.</p>
</div>
<div className="home-grid">
{highlights.map((item) => (
<Link key={item.id} to={item.path} className="home-card">
<div>
<p className="home-card__title">{item.label}</p>
<p className="home-card__desc">{item.description}</p>
</div>
<span className="home-card__cta">열기</span>
</Link>
))}
</div>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>최근 블로그</h2>
<p>마크다운 파일을 추가하면 자동으로 목록에 반영됩니다.</p>
</div>
<div className="home-posts">
{posts.map((post) => (
<Link key={post.slug} to="/blog" className="home-post">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
</Link>
))}
</div>
</section>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,367 @@
import React, { useEffect, useMemo, useState } from 'react';
import { deleteHistory, getHistory, getLatest, recommend } from '../../api';
const fmtKST = (value) => value?.replace('T', ' ') ?? '';
const ballClass = (n) => {
if (n <= 10) return 'lotto-ball range-a';
if (n <= 20) return 'lotto-ball range-b';
if (n <= 30) return 'lotto-ball range-c';
if (n <= 40) return 'lotto-ball range-d';
return 'lotto-ball range-e';
};
const Ball = ({ n }) => <span className={ballClass(n)}>{n}</span>;
const NumberRow = ({ nums }) => (
<div className="lotto-row">
{nums.map((n) => (
<Ball key={n} n={n} />
))}
</div>
);
export default function Functions() {
const [latest, setLatest] = useState(null);
const [params, setParams] = useState({
recent_window: 200,
recent_weight: 2.0,
avoid_recent_k: 5,
});
const presets = useMemo(
() => [
{ name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
{ name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
{ name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
{ name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
],
[]
);
const [result, setResult] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState({
latest: false,
recommend: false,
history: false,
});
const [error, setError] = useState('');
const refreshLatest = async () => {
setLoading((s) => ({ ...s, latest: true }));
setError('');
try {
const data = await getLatest();
setLatest(data);
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, latest: false }));
}
};
const refreshHistory = async () => {
setLoading((s) => ({ ...s, history: true }));
setError('');
try {
const data = await getHistory(30);
setHistory(data.items ?? []);
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, history: false }));
}
};
const onRecommend = async () => {
setLoading((s) => ({ ...s, recommend: true }));
setError('');
try {
const data = await recommend(params);
setResult(data);
await refreshHistory();
} catch (e) {
setError(e?.message ?? String(e));
} finally {
setLoading((s) => ({ ...s, recommend: false }));
}
};
const onDelete = async (id) => {
const ok = confirm(`히스토리 #${id}를 삭제할까요?`);
if (!ok) return;
setError('');
try {
await deleteHistory(id);
setHistory((prev) => prev.filter((item) => item.id !== id));
} catch (e) {
setError(e?.message ?? String(e));
}
};
const copyNumbers = async (nums) => {
const text = nums.join(', ');
try {
await navigator.clipboard.writeText(text);
alert(`복사 완료: ${text}`);
} catch {
prompt('복사해서 사용하세요:', text);
}
};
useEffect(() => {
refreshLatest();
refreshHistory();
}, []);
return (
<div className="lotto-functions">
{error ? (
<div className="lotto-alert">
<div>
<p className="lotto-alert__title">오류</p>
<p className="lotto-alert__message">{error}</p>
</div>
<button className="button ghost small" onClick={() => setError('')}>
닫기
</button>
</div>
) : null}
<div className="lotto-grid">
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Latest Draw</p>
<h3>최신 회차</h3>
<p className="lotto-panel__sub">
최신 회차와 번호를 빠르게 확인할 있습니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading.latest ? <span className="lotto-chip">로딩 </span> : null}
<button
className="button ghost small"
onClick={refreshLatest}
disabled={loading.latest}
>
새로고침
</button>
</div>
</div>
{latest ? (
<>
<div className="lotto-meta">
<div>
<p className="lotto-meta__title">{latest.drawNo}</p>
<p className="lotto-meta__date">{latest.date}</p>
</div>
<button
className="button small"
onClick={() => copyNumbers(latest.numbers)}
>
번호 복사
</button>
</div>
<NumberRow nums={latest.numbers} />
<p className="lotto-bonus">
보너스 <strong>{latest.bonus}</strong>
</p>
</>
) : (
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Recommendation</p>
<h3>추천 생성</h3>
<p className="lotto-panel__sub">
파라미터를 조정해 다른 추천 전략을 만들 있습니다.
</p>
</div>
<div className="lotto-panel__actions">
{loading.recommend ? <span className="lotto-chip">계산 </span> : null}
</div>
</div>
<div className="lotto-presets">
{presets.map((preset) => (
<button
key={preset.name}
className="button ghost small"
onClick={() =>
setParams({
recent_window: preset.recent_window,
recent_weight: preset.recent_weight,
avoid_recent_k: preset.avoid_recent_k,
})
}
>
{preset.name}
</button>
))}
</div>
<div className="lotto-form">
<label className="lotto-field">
recent_window
<span>최근 N회차 가중치 범위</span>
<input
type="number"
min={20}
max={1000}
value={params.recent_window}
onChange={(e) =>
setParams((s) => ({
...s,
recent_window: Number(e.target.value),
}))
}
/>
</label>
<label className="lotto-field">
recent_weight
<span>최근 회차 가중치</span>
<input
type="number"
step="0.1"
min={0.5}
max={10}
value={params.recent_weight}
onChange={(e) =>
setParams((s) => ({
...s,
recent_weight: Number(e.target.value),
}))
}
/>
</label>
<label className="lotto-field">
avoid_recent_k
<span>최근 K회차 중복 회피</span>
<input
type="number"
min={0}
max={50}
value={params.avoid_recent_k}
onChange={(e) =>
setParams((s) => ({
...s,
avoid_recent_k: Number(e.target.value),
}))
}
/>
</label>
</div>
<button
className="button primary"
onClick={onRecommend}
disabled={loading.recommend}
>
추천 받기
</button>
{result ? (
<div className="lotto-result">
<div className="lotto-result__meta">
<div>
<p className="lotto-result__id">추천 ID #{result.id}</p>
<p className="lotto-result__based">
기준 회차 {result.based_on_latest_draw ?? '-'}
</p>
</div>
<button
className="button small"
onClick={() => copyNumbers(result.numbers)}
>
번호 복사
</button>
</div>
<NumberRow nums={result.numbers} />
<details className="lotto-details">
<summary>설명 보기</summary>
<pre>{JSON.stringify(result.explain, null, 2)}</pre>
</details>
</div>
) : (
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
</div>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
<h3>추천 히스토리</h3>
<p className="lotto-panel__sub">
최근 추천 결과를 모아서 확인할 있습니다.
</p>
</div>
<div className="lotto-panel__actions">
<span className="lotto-chip">{history.length}</span>
<button
className="button ghost small"
onClick={refreshHistory}
disabled={loading.history}
>
새로고침
</button>
</div>
</div>
{loading.history ? <p className="lotto-empty">불러오는 ...</p> : null}
{history.length === 0 ? (
<p className="lotto-empty">저장된 히스토리가 없습니다.</p>
) : (
<div className="lotto-history">
{history.map((item) => (
<div key={item.id} className="lotto-history__item">
<div className="lotto-history__meta">
<p>#{item.id}</p>
<p>{fmtKST(item.created_at)}</p>
<p>기준 회차 {item.based_on_draw ?? '-'}</p>
</div>
<div className="lotto-history__body">
<NumberRow nums={item.numbers} />
<p className="lotto-history__params">
window={item.params?.recent_window}, weight=
{item.params?.recent_weight}, avoid_k=
{item.params?.avoid_recent_k}
</p>
</div>
<div className="lotto-history__actions">
<button
className="button ghost small"
onClick={() => copyNumbers(item.numbers)}
>
복사
</button>
<button
className="button danger small"
onClick={() => onDelete(item.id)}
>
삭제
</button>
</div>
</div>
))}
</div>
)}
</section>
<footer className="lotto-foot">
backend: FastAPI / nginx proxy / DB: sqlite
</footer>
</div>
);
}

339
src/pages/lotto/Lotto.css Normal file
View File

@@ -0,0 +1,339 @@
.lotto {
display: grid;
gap: 24px;
}
.lotto-header {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 22px;
align-items: center;
}
.lotto-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
margin: 0 0 10px;
}
.lotto-header h1 {
margin: 0 0 12px;
font-family: var(--font-display);
font-size: clamp(30px, 4vw, 40px);
}
.lotto-sub {
margin: 0;
color: var(--muted);
}
.lotto-card {
border: 1px solid var(--line);
border-radius: 20px;
padding: 20px;
background: var(--surface);
}
.lotto-card__title {
margin: 0 0 12px;
font-weight: 600;
}
.lotto-card ul {
margin: 0;
padding-left: 18px;
color: var(--muted);
display: grid;
gap: 6px;
}
.lotto-functions {
display: grid;
gap: 24px;
}
.lotto-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.lotto-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
padding: 20px;
display: grid;
gap: 16px;
}
.lotto-panel__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.lotto-panel__eyebrow {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
}
.lotto-panel__sub {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.lotto-panel__actions {
display: flex;
gap: 8px;
align-items: center;
}
.lotto-chip {
font-size: 11px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.lotto-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.lotto-ball {
width: 40px;
height: 40px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
}
.lotto-ball.range-a {
background: rgba(247, 168, 165, 0.22);
}
.lotto-ball.range-b {
background: rgba(253, 212, 177, 0.22);
}
.lotto-ball.range-c {
background: rgba(151, 201, 170, 0.22);
}
.lotto-ball.range-d {
background: rgba(133, 165, 216, 0.22);
}
.lotto-ball.range-e {
background: rgba(196, 170, 220, 0.22);
}
.lotto-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.lotto-meta__title {
margin: 0;
font-weight: 600;
font-size: 18px;
}
.lotto-meta__date {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.lotto-bonus {
margin: 0;
color: var(--muted);
}
.lotto-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.lotto-form {
display: grid;
gap: 12px;
}
.lotto-field {
display: grid;
gap: 6px;
font-size: 13px;
}
.lotto-field span {
color: var(--muted);
font-size: 12px;
}
.lotto-field input {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
border-radius: 12px;
padding: 10px 12px;
outline: none;
}
.lotto-field input:focus {
border-color: rgba(247, 168, 165, 0.6);
}
.lotto-result {
border-top: 1px solid var(--line);
padding-top: 16px;
display: grid;
gap: 12px;
}
.lotto-result__meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.lotto-result__id {
margin: 0;
font-weight: 600;
}
.lotto-result__based {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.lotto-details summary {
cursor: pointer;
color: var(--muted);
}
.lotto-details pre {
background: rgba(0, 0, 0, 0.4);
border-radius: 12px;
padding: 12px;
font-size: 12px;
overflow-x: auto;
border: 1px solid var(--line);
}
.lotto-empty {
margin: 0;
color: var(--muted);
}
.lotto-alert {
border: 1px solid rgba(247, 116, 125, 0.4);
background: rgba(247, 116, 125, 0.12);
border-radius: 18px;
padding: 16px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.lotto-alert__title {
margin: 0 0 6px;
font-weight: 600;
}
.lotto-alert__message {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.lotto-history {
display: grid;
gap: 12px;
}
.lotto-history__item {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
display: grid;
grid-template-columns: minmax(0, 0.4fr) minmax(0, 0.4fr) minmax(0, 0.2fr);
gap: 14px;
background: rgba(255, 255, 255, 0.02);
}
.lotto-history__meta {
color: var(--muted);
font-size: 12px;
display: grid;
gap: 6px;
}
.lotto-history__body {
display: grid;
gap: 8px;
}
.lotto-history__params {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.lotto-history__actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.lotto-foot {
text-align: center;
color: var(--muted);
font-size: 12px;
}
.button.small {
padding: 8px 12px;
font-size: 12px;
}
.button.danger {
border-color: rgba(247, 116, 125, 0.5);
color: #fbc4c8;
background: rgba(247, 116, 125, 0.15);
}
@media (max-width: 900px) {
.lotto-header {
grid-template-columns: 1fr;
}
.lotto-history__item {
grid-template-columns: 1fr;
}
}

32
src/pages/lotto/Lotto.jsx Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import Functions from './Functions';
import './Lotto.css';
const Lotto = () => {
return (
<div className="lotto">
<header className="lotto-header">
<div>
<p className="lotto-kicker">Playground</p>
<h1>Lotto Lab</h1>
<p className="lotto-sub">
기존 로또 추천 기능을 그대로 유지하면서 새로운 블로그 스타일에 맞게
레이아웃을 정리했습니다.
</p>
</div>
<div className="lotto-card">
<p className="lotto-card__title">다음 업데이트 아이디어</p>
<ul>
<li>로또 기록을 캘린더 형태로 정리</li>
<li>자주 등장하는 번호 조합 분석</li>
<li>그래프로 추첨 추세 확인</li>
</ul>
</div>
</header>
<Functions />
</div>
);
};
export default Lotto;

109
src/pages/travel/Travel.css Normal file
View File

@@ -0,0 +1,109 @@
.travel {
display: grid;
gap: 28px;
}
.travel-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 24px;
align-items: center;
}
.travel-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
margin: 0 0 10px;
}
.travel-header h1 {
font-family: var(--font-display);
margin: 0 0 12px;
font-size: clamp(30px, 4vw, 40px);
}
.travel-sub {
margin: 0;
color: var(--muted);
}
.travel-note {
border: 1px solid var(--line);
border-radius: 20px;
padding: 20px;
background: var(--surface);
}
.travel-note__title {
margin: 0 0 8px;
font-weight: 600;
}
.travel-note__desc {
margin: 0;
color: var(--muted);
}
.travel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
.travel-card {
position: relative;
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
min-height: 220px;
}
.travel-card.is-wide {
grid-column: span 2;
}
.travel-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: saturate(1.05);
}
.travel-card__overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.7));
color: #f8f4f0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
padding: 18px;
}
.travel-card__title {
margin: 0;
font-weight: 600;
font-size: 18px;
}
.travel-card__meta {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: rgba(248, 244, 240, 0.8);
}
@media (max-width: 900px) {
.travel-header {
grid-template-columns: 1fr;
}
.travel-card.is-wide {
grid-column: span 1;
}
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { travelGallery } from '../../data/travel';
import './Travel.css';
const Travel = () => {
return (
<div className="travel">
<header className="travel-header">
<div>
<p className="travel-kicker">Visual Diary</p>
<h1>Travel Archive</h1>
<p className="travel-sub">
여행에서 색감과 분위기를 모아 전시하는 페이지입니다.
</p>
</div>
<div className="travel-note">
<p className="travel-note__title">렌더링 포인트</p>
<p className="travel-note__desc">
사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다.
</p>
</div>
</header>
<section className="travel-grid">
{travelGallery.map((photo, index) => (
<article
key={photo.id}
className={`travel-card ${index % 3 === 0 ? 'is-wide' : ''}`}
>
<img src={photo.image} alt={photo.title} loading="lazy" />
<div className="travel-card__overlay">
<p className="travel-card__title">{photo.title}</p>
<p className="travel-card__meta">
{photo.location} · {photo.month}
</p>
</div>
</article>
))}
</section>
</div>
);
};
export default Travel;

51
src/routes.jsx Normal file
View File

@@ -0,0 +1,51 @@
import React from 'react';
import Home from './pages/home/Home';
import Blog from './pages/blog/Blog';
import Lotto from './pages/lotto/Lotto';
import Travel from './pages/travel/Travel';
export const navLinks = [
{
id: 'home',
label: 'Home',
path: '/',
description: '첫 인상과 최신 업데이트를 모아둔 허브',
},
{
id: 'blog',
label: 'Blog',
path: '/blog',
description: '생각과 기록, 코드 스니펫을 모으는 공간',
},
{
id: 'lotto',
label: 'Lotto',
path: '/lotto',
description: '숫자를 뽑고 통계를 확인하는 실험실',
},
{
id: 'travel',
label: 'Travel',
path: '/travel',
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
},
];
export const appRoutes = [
{
index: true,
element: <Home />,
},
{
path: 'blog',
element: <Blog />,
},
{
path: 'lotto',
element: <Lotto />,
},
{
path: 'travel',
element: <Travel />,
},
];