라우팅 추가 및 CSS 구성
- 개인 블로그 - 로또 - 여행 로고 이미지 추가 및 변경
This commit is contained in:
@@ -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
45
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
BIN
public/main_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 953 KiB |
3620
public/main_logo.svg
Normal file
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 |
278
src/App.css
278
src/App.css
@@ -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;
|
||||||
.topbar {
|
margin: 0 auto;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
padding: 40px 20px 80px;
|
||||||
padding: 14px 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(18,26,42,0.7);
|
|
||||||
border-radius: 16px;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand { display: flex; gap: 12px; align-items: center; }
|
@keyframes fadeUp {
|
||||||
.logo { width: 40px; height: 40px; display: grid; place-items: center; font-size: 22px;
|
from {
|
||||||
background: rgba(125,211,252,0.12); border: 1px solid var(--line); border-radius: 12px;
|
opacity: 0;
|
||||||
}
|
transform: translateY(16px);
|
||||||
.title { font-weight: 800; letter-spacing: 0.2px; }
|
}
|
||||||
.sub { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
to {
|
||||||
|
opacity: 1;
|
||||||
.topRight { display: flex; gap: 10px; align-items: center; }
|
transform: translateY(0);
|
||||||
.latestChip {
|
}
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.site-main > * {
|
||||||
margin-top: 12px;
|
animation: fadeUp 0.6s ease both;
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.button {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: rgba(18,26,42,0.35);
|
padding: 10px 18px;
|
||||||
color: var(--text);
|
border-radius: 999px;
|
||||||
padding: 10px 12px;
|
text-decoration: none;
|
||||||
border-radius: 12px;
|
color: var(--text);
|
||||||
cursor: pointer;
|
font-size: 14px;
|
||||||
}
|
letter-spacing: 0.08em;
|
||||||
.tab.on {
|
text-transform: uppercase;
|
||||||
background: rgba(125,211,252,0.14);
|
transition: all 0.2s ease;
|
||||||
border-color: rgba(125,211,252,0.35);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main { margin-top: 14px; display: grid; gap: 14px; }
|
.button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
.card {
|
transform: translateY(-2px);
|
||||||
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 {
|
.button.primary {
|
||||||
width: 100%;
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
min-height: 64px;
|
color: #1a1414;
|
||||||
border: 1px solid var(--line);
|
border-color: transparent;
|
||||||
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)); }
|
.button.ghost {
|
||||||
.paramLabel { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
background: transparent;
|
||||||
.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);
|
|
||||||
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; }
|
|
||||||
.pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
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 {
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px dashed rgba(255,255,255,0.12);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.batchGrid {
|
|
||||||
display: grid;
|
|
||||||
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; }
|
|
||||||
|
|||||||
317
src/App.jsx
317
src/App.jsx
@@ -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 (
|
||||||
// 그냥 표시용으로만 사용
|
<div className="app-shell">
|
||||||
return iso?.replace("T", " ") ?? "";
|
<Navbar />
|
||||||
|
<main className="site-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Ball({ n }) {
|
export default App;
|
||||||
// 범위별 톤만 다르게(색 직접 지정 안 하고, 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 (
|
|
||||||
<div className="row numbers">
|
|
||||||
{nums.map((n) => (
|
|
||||||
<Ball key={n} n={n} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function 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
14
src/Router.jsx
Normal 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
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
49
src/assets/main_logo.svg
Normal 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
93
src/components/Navbar.css
Normal 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
36
src/components/Navbar.jsx
Normal 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;
|
||||||
17
src/content/blog/2026-01-lotto-lab.md
Normal file
17
src/content/blog/2026-01-lotto-lab.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
title: 로또 실험실을 조금 더 재미있게
|
||||||
|
date: 2026-01-12
|
||||||
|
tags: product, lotto
|
||||||
|
excerpt: 작은 실험으로 시작한 로또 페이지를 앞으로 어떻게 발전시키려는지 정리했습니다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 로또 실험실을 조금 더 재미있게
|
||||||
|
|
||||||
|
처음에는 숫자를 뽑는 기능만 있었지만, 데이터 기록과 패턴 시각화를 더해보고 싶었습니다.
|
||||||
|
지금은 간단한 기능만 있지만, 앞으로는 아래 방향으로 확장하려 합니다.
|
||||||
|
|
||||||
|
- 추첨 기록을 캘린더 뷰로 보기
|
||||||
|
- 자주 등장하는 숫자 조합 시각화
|
||||||
|
- 개인별 기록을 비교할 수 있는 리포트
|
||||||
|
|
||||||
|
이 블로그에 중간 과정과 고민들을 계속 기록해 보려고 합니다.
|
||||||
18
src/content/blog/2026-01-welcome.md
Normal file
18
src/content/blog/2026-01-welcome.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: 새 블로그를 열었습니다
|
||||||
|
date: 2026-01-18
|
||||||
|
tags: intro, blog
|
||||||
|
excerpt: 이제부터 개발 기록과 여행 기록을 이곳에 차곡차곡 쌓아갑니다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 새 블로그를 열었습니다
|
||||||
|
|
||||||
|
처음엔 로또 페이지로 시작했지만, 이 공간을 개인 아카이브로 확장하려고 합니다.
|
||||||
|
작은 실험, 긴 이야기, 그리고 여행에서 얻은 감정까지 모두 모아둘 예정입니다.
|
||||||
|
|
||||||
|
앞으로 이곳에서 하고 싶은 것들:
|
||||||
|
- 틈틈이 쓰는 개발 메모
|
||||||
|
- 사진과 함께 기록하는 여행기
|
||||||
|
- 나만의 프로젝트 회고
|
||||||
|
|
||||||
|
첫 페이지를 열어둡니다. 천천히 채워나갈게요.
|
||||||
79
src/data/blog.js
Normal file
79
src/data/blog.js
Normal 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
58
src/data/travel.js
Normal 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
150
src/index.css
150
src/index.css
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
html,
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
height: 100%;
|
||||||
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(1200px 800px at 20% 0%, rgba(99,102,241,0.22), transparent 60%),
|
|
||||||
radial-gradient(1200px 800px at 80% 10%, rgba(16,185,129,0.18), transparent 55%),
|
|
||||||
var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: inherit; }
|
body {
|
||||||
|
margin: 0;
|
||||||
.page { max-width: 1100px; margin: 0 auto; padding: 28px 16px 60px; }
|
background: radial-gradient(1000px 600px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 60%),
|
||||||
|
radial-gradient(800px 600px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 60%),
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
#0f0d12;
|
||||||
.header h1 { margin: 0; font-size: 28px; letter-spacing: -0.02em; }
|
color: var(--text);
|
||||||
.sub { margin: 6px 0 0; color: var(--muted); }
|
font-family: var(--font-body);
|
||||||
|
|
||||||
.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; }
|
a {
|
||||||
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
color: inherit;
|
||||||
|
|
||||||
.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; }
|
img {
|
||||||
label { display: grid; gap: 6px; font-size: 13px; }
|
max-width: 100%;
|
||||||
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; }
|
|
||||||
|
|||||||
@@ -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
191
src/pages/blog/Blog.css
Normal 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
190
src/pages/blog/Blog.jsx
Normal 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
203
src/pages/home/Home.css
Normal 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
89
src/pages/home/Home.jsx
Normal 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;
|
||||||
367
src/pages/lotto/Functions.jsx
Normal file
367
src/pages/lotto/Functions.jsx
Normal 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
339
src/pages/lotto/Lotto.css
Normal 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
32
src/pages/lotto/Lotto.jsx
Normal 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
109
src/pages/travel/Travel.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/pages/travel/Travel.jsx
Normal file
44
src/pages/travel/Travel.jsx
Normal 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
51
src/routes.jsx
Normal 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 />,
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user