diff --git a/index.html b/index.html index cf6390c..6940a72 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - web-ui + 가후습 개인기록
diff --git a/package-lock.json b/package-lock.json index aa174a8..7606e1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^6.30.3" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1031,6 +1032,15 @@ "@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": { "version": "1.0.0-beta.53", "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", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2677,6 +2688,38 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index 9ba75e4..a4678d2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^6.30.3" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/public/main_logo.png b/public/main_logo.png new file mode 100644 index 0000000..1fffd54 Binary files /dev/null and b/public/main_logo.png differ diff --git a/public/main_logo.svg b/public/main_logo.svg new file mode 100644 index 0000000..96d33a2 --- /dev/null +++ b/public/main_logo.svg @@ -0,0 +1,3620 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.css b/src/App.css index cf0e2cb..6002b7c 100644 --- a/src/App.css +++ b/src/App.css @@ -1,250 +1,64 @@ :root { - --bg: #0b0f17; - --card: #121a2a; - --card2: #0f1626; - --text: #e8edf6; - --muted: #a7b2c7; - --line: rgba(255,255,255,0.08); - --accent: #7dd3fc; - --good: #34d399; - --warn: #fbbf24; - --danger: #fb7185; + --bg: #0f0d12; + --surface: rgba(26, 23, 32, 0.88); + --text: #f4efe9; + --muted: #b6b1a9; + --line: rgba(255, 255, 255, 0.12); + --accent: #f7a8a5; + --accent-strong: #fdd4b1; + --font-display: "DM Serif Display", "Noto Serif KR", serif; + --font-body: "Manrope", "Noto Sans KR", sans-serif; } -* { box-sizing: border-box; } -html, body { height: 100%; } -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; +.app-shell { + min-height: 100vh; } -.page { max-width: 1100px; margin: 0 auto; padding: 20px; } - -.topbar { - display: flex; align-items: center; justify-content: space-between; - padding: 14px 14px; - border: 1px solid var(--line); - background: rgba(18,26,42,0.7); - border-radius: 16px; - backdrop-filter: blur(8px); +.site-main { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px 80px; } -.brand { display: flex; gap: 12px; align-items: center; } -.logo { width: 40px; height: 40px; display: grid; place-items: center; font-size: 22px; - background: rgba(125,211,252,0.12); border: 1px solid var(--line); border-radius: 12px; -} -.title { font-weight: 800; letter-spacing: 0.2px; } -.sub { color: var(--muted); font-size: 13px; margin-top: 2px; } - -.topRight { display: flex; gap: 10px; align-items: center; } -.latestChip { - border: 1px solid var(--line); - background: rgba(0,0,0,0.25); - padding: 8px 10px; - border-radius: 999px; - font-size: 13px; +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.tabs { - margin-top: 12px; - display: flex; - gap: 8px; +.site-main > * { + animation: fadeUp 0.6s ease both; } -.tab { - border: 1px solid var(--line); - background: rgba(18,26,42,0.35); - color: var(--text); - padding: 10px 12px; - border-radius: 12px; - cursor: pointer; -} -.tab.on { - background: rgba(125,211,252,0.14); - border-color: rgba(125,211,252,0.35); +.button { + border: 1px solid var(--line); + padding: 10px 18px; + border-radius: 999px; + text-decoration: none; + color: var(--text); + font-size: 14px; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.06); } -.main { margin-top: 14px; display: grid; gap: 14px; } - -.card { - border: 1px solid var(--line); - background: rgba(18,26,42,0.5); - border-radius: 18px; - overflow: hidden; -} -.cardHead { - display: flex; justify-content: space-between; align-items: flex-start; - padding: 14px 14px 10px 14px; - border-bottom: 1px solid var(--line); - background: rgba(15,22,38,0.35); -} -.cardTitle { font-size: 15px; font-weight: 800; } -.cardSub { color: var(--muted); font-size: 13px; margin-top: 4px; } -.cardBody { padding: 14px; } - -.muted { color: var(--muted); } - -.btn { - border: 1px solid var(--line); - border-radius: 12px; - padding: 10px 12px; - color: var(--text); - cursor: pointer; - background: rgba(125,211,252,0.12); -} -.btn.secondary { background: rgba(255,255,255,0.06); } -.btn.ghost { background: transparent; } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } - -.input, .select { - border: 1px solid var(--line); - background: rgba(0,0,0,0.25); - color: var(--text); - border-radius: 12px; - padding: 10px 10px; - min-width: 120px; +.button:hover { + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); } -.textarea { - width: 100%; - min-height: 64px; - border: 1px solid var(--line); - background: rgba(0,0,0,0.25); - color: var(--text); - border-radius: 12px; - padding: 10px; - resize: vertical; +.button.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #1a1414; + border-color: transparent; } -.params { display: grid; gap: 10px; grid-template-columns: repeat(3, minmax(0, 1fr)); } -.paramLabel { color: var(--muted); font-size: 12px; margin-bottom: 6px; } -.param { display: flex; flex-direction: column; } - -.rowInline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.chip { - border: 1px solid var(--line); - background: rgba(255,255,255,0.05); - color: var(--text); - border-radius: 999px; - padding: 8px 10px; - cursor: pointer; +.button.ghost { + background: transparent; } -.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; } diff --git a/src/App.jsx b/src/App.jsx index c8ae56a..1befa3f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,306 +1,17 @@ -// src/App.jsx -import React, { useEffect, useMemo, useState } from "react"; -import { deleteHistory, getHistory, getLatest, recommend } from "./api"; +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import Navbar from './components/Navbar'; +import './App.css'; -function fmtKST(iso) { - // sqlite datetime('now') -> "YYYY-MM-DD HH:MM:SS" (UTC 로 저장될 수도) - // 그냥 표시용으로만 사용 - return iso?.replace("T", " ") ?? ""; +function App() { + return ( +
+ +
+ +
+
+ ); } -function Ball({ n }) { - // 범위별 톤만 다르게(색 직접 지정 안 하고, hue를 계산해도 되지만 여기선 단순) - const cls = - n <= 10 ? "ball ball-a" : n <= 20 ? "ball ball-b" : n <= 30 ? "ball ball-c" : n <= 40 ? "ball ball-d" : "ball ball-e"; - return {n}; -} - -function NumberRow({ nums }) { - return ( -
- {nums.map((n) => ( - - ))} -
- ); -} - -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 ( -
-
-
-

로또 추천기

-

- 최신 회차 기준 추천 + 히스토리 저장/삭제 -

-
-
- - -
-
- - {error ? ( -
-
- 에러 - -
-
{error}
-
- ) : null} - -
- {/* LEFT */} -
-
-

최신 회차

- {loading.latest ? 로딩... : null} -
- - {latest ? ( - <> -
-
-
{latest.drawNo}회
-
{latest.date}
-
- -
- - -
보너스: {latest.bonus}
- - ) : ( -
최신 데이터 없음
- )} -
- - {/* RIGHT */} -
-
-

추천 생성

- {loading.recommend ? 계산중... : null} -
- -
- {presets.map((p) => ( - - ))} -
- -
- - - - - - - -
- - {result ? ( -
-
-
-
추천 ID: #{result.id}
-
기준 회차: {result.based_on_latest_draw ?? "-"}
-
- -
- - -
- 설명 보기 -
{JSON.stringify(result.explain, null, 2)}
-
-
- ) : ( -
아직 추천 결과 없음
- )} -
-
- -
-
-

히스토리

-
{history.length}개
-
- - {loading.history ?
불러오는 중...
: null} - - {history.length === 0 ? ( -
저장된 히스토리가 없습니다.
- ) : ( -
- {history.map((h) => ( -
-
-
#{h.id}
-
{fmtKST(h.created_at)}
-
기준: {h.based_on_draw ?? "-"}
-
- -
- -
- window={h.params?.recent_window}, weight={h.params?.recent_weight}, avoid_k={h.params?.avoid_recent_k} -
-
- -
- - -
-
- ))} -
- )} -
- - -
- ); -} +export default App; diff --git a/src/Router.jsx b/src/Router.jsx new file mode 100644 index 0000000..2980025 --- /dev/null +++ b/src/Router.jsx @@ -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: , + children: appRoutes, + }, +]); + +export default router; diff --git a/src/assets/main_logo.png b/src/assets/main_logo.png new file mode 100644 index 0000000..1fffd54 Binary files /dev/null and b/src/assets/main_logo.png differ diff --git a/src/assets/main_logo.svg b/src/assets/main_logo.svg new file mode 100644 index 0000000..d958d88 --- /dev/null +++ b/src/assets/main_logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + diff --git a/src/components/Navbar.css b/src/components/Navbar.css new file mode 100644 index 0000000..04b5753 --- /dev/null +++ b/src/components/Navbar.css @@ -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; + } +} diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx new file mode 100644 index 0000000..3b0aa56 --- /dev/null +++ b/src/components/Navbar.jsx @@ -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 ( +
+
+
+ Logo +
+

Jaeoh Archive

+

Stories, notes, and snapshots

+
+
+ +
+
+ ); +}; + +export default Navbar; diff --git a/src/content/blog/2026-01-lotto-lab.md b/src/content/blog/2026-01-lotto-lab.md new file mode 100644 index 0000000..ef7af23 --- /dev/null +++ b/src/content/blog/2026-01-lotto-lab.md @@ -0,0 +1,17 @@ +--- +title: 로또 실험실을 조금 더 재미있게 +date: 2026-01-12 +tags: product, lotto +excerpt: 작은 실험으로 시작한 로또 페이지를 앞으로 어떻게 발전시키려는지 정리했습니다. +--- + +# 로또 실험실을 조금 더 재미있게 + +처음에는 숫자를 뽑는 기능만 있었지만, 데이터 기록과 패턴 시각화를 더해보고 싶었습니다. +지금은 간단한 기능만 있지만, 앞으로는 아래 방향으로 확장하려 합니다. + +- 추첨 기록을 캘린더 뷰로 보기 +- 자주 등장하는 숫자 조합 시각화 +- 개인별 기록을 비교할 수 있는 리포트 + +이 블로그에 중간 과정과 고민들을 계속 기록해 보려고 합니다. diff --git a/src/content/blog/2026-01-welcome.md b/src/content/blog/2026-01-welcome.md new file mode 100644 index 0000000..c996528 --- /dev/null +++ b/src/content/blog/2026-01-welcome.md @@ -0,0 +1,18 @@ +--- +title: 새 블로그를 열었습니다 +date: 2026-01-18 +tags: intro, blog +excerpt: 이제부터 개발 기록과 여행 기록을 이곳에 차곡차곡 쌓아갑니다. +--- + +# 새 블로그를 열었습니다 + +처음엔 로또 페이지로 시작했지만, 이 공간을 개인 아카이브로 확장하려고 합니다. +작은 실험, 긴 이야기, 그리고 여행에서 얻은 감정까지 모두 모아둘 예정입니다. + +앞으로 이곳에서 하고 싶은 것들: +- 틈틈이 쓰는 개발 메모 +- 사진과 함께 기록하는 여행기 +- 나만의 프로젝트 회고 + +첫 페이지를 열어둡니다. 천천히 채워나갈게요. diff --git a/src/data/blog.js b/src/data/blog.js new file mode 100644 index 0000000..eaa694e --- /dev/null +++ b/src/data/blog.js @@ -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; + }); +}; diff --git a/src/data/travel.js b/src/data/travel.js new file mode 100644 index 0000000..d4893a8 --- /dev/null +++ b/src/data/travel.js @@ -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', + }, +]; diff --git a/src/index.css b/src/index.css index 9befbc8..e1cebe2 100644 --- a/src/index.css +++ b/src/index.css @@ -1,143 +1,27 @@ -:root { - --bg: #0b0f19; - --card: #111827; - --text: #e5e7eb; - --muted: #9ca3af; - --border: rgba(255,255,255,0.08); - --shadow: 0 20px 50px rgba(0,0,0,0.35); +@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap'); + +* { + box-sizing: border-box; } -* { box-sizing: border-box; } -html, body { height: 100%; } +html, body { - margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Apple SD Gothic Neo, "Noto Sans KR", "Malgun Gothic", sans-serif; - background: radial-gradient(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); + height: 100%; } -a { color: inherit; } - -.page { max-width: 1100px; margin: 0 auto; padding: 28px 16px 60px; } - -.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; } -.header h1 { margin: 0; font-size: 28px; letter-spacing: -0.02em; } -.sub { margin: 6px 0 0; color: var(--muted); } - -.card { - background: rgba(17,24,39,0.9); - border: 1px solid var(--border); - border-radius: 18px; - padding: 16px; - box-shadow: var(--shadow); - backdrop-filter: blur(6px); +body { + margin: 0; + 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%), + #0f0d12; + color: var(--text); + font-family: var(--font-body); } -.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; } -@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } } - -.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; +a { + color: inherit; } -.form { display: grid; gap: 10px; margin-top: 10px; } -label { display: grid; gap: 6px; font-size: 13px; } -input { - border: 1px solid var(--border); - background: rgba(0,0,0,0.25); - color: var(--text); - padding: 10px 12px; - border-radius: 12px; - outline: none; +img { + max-width: 100%; } -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; } diff --git a/src/main.jsx b/src/main.jsx index b91620d..1593602 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,11 @@ import React from "react"; 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"; ReactDOM.createRoot(document.getElementById("root")).render( - + ); diff --git a/src/pages/blog/Blog.css b/src/pages/blog/Blog.css new file mode 100644 index 0000000..1637ca9 --- /dev/null +++ b/src/pages/blog/Blog.css @@ -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; + } +} diff --git a/src/pages/blog/Blog.jsx b/src/pages/blog/Blog.jsx new file mode 100644 index 0000000..3e11a6d --- /dev/null +++ b/src/pages/blog/Blog.jsx @@ -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 ( + {part.replace(/\*\*/g, '')} + ); + } + if (part.startsWith('*')) { + return {part.replace(/\*/g, '')}; + } + if (part.startsWith('`')) { + return {part.replace(/`/g, '')}; + } + return {part}; + }); +}; + +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

{block.value}

; + if (block.type === 'h2') return

{block.value}

; + if (block.type === 'h3') return

{block.value}

; + if (block.type === 'list') + return ( + + ); + if (block.type === 'code') + return ( +
+                    {block.value}
+                
+ ); + return ( +

+ {renderInline(block.value)} +

+ ); + }); +}; + +const Blog = () => { + const posts = useMemo(() => getBlogPosts(), []); + const [activeSlug, setActiveSlug] = useState(posts[0]?.slug); + const activePost = posts.find((post) => post.slug === activeSlug) || posts[0]; + + return ( +
+
+
+

Journal

+

개인 블로그

+

+ 마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다. +

+
+
+

이번 주의 기록

+

+ 손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다. +

+
+
+ +
+ +
+ {activePost ? ( + <> +
+ {activePost.date || '작성일 미정'} + {activePost.tags.length > 0 && ( + + {activePost.tags.map((tag) => ( + + {tag} + + ))} + + )} +
+
+ {renderMarkdown(activePost.body)} +
+ + ) : ( +

+ 아직 작성된 글이 없습니다. `src/content/blog`에 마크다운 파일을 + 추가해 주세요. +

+ )} +
+
+
+ ); +}; + +export default Blog; diff --git a/src/pages/home/Home.css b/src/pages/home/Home.css new file mode 100644 index 0000000..9b00f96 --- /dev/null +++ b/src/pages/home/Home.css @@ -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; + } +} diff --git a/src/pages/home/Home.jsx b/src/pages/home/Home.jsx new file mode 100644 index 0000000..54f9b79 --- /dev/null +++ b/src/pages/home/Home.jsx @@ -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 ( +
+
+
+

Personal Archive

+

기록을 모으고, 이야기를 이어붙이는 작은 집.

+

+ 개발 실험, 여행 스냅, 그리고 생각을 모아두는 공간입니다. 블로그 글은 + 마크다운으로 작성해 계속 추가할 수 있어요. +

+
+ + 블로그 둘러보기 + + + 여행 갤러리 열기 + +
+
+
+

이번 달 집중 테마

+
+

느린 기록, 깊은 회고

+

+ 빠르게 업데이트하는 대신, 한 번쯤 되돌아보며 기록하는 걸 목표로 + 합니다. 글은 매주 한 편씩 추가될 예정이에요. +

+
+
+
+

게시 글

+

{posts.length}편

+
+
+

다음 업데이트

+

이번 주말

+
+
+
+
+ +
+
+

공간 둘러보기

+

확장 가능한 구조로 구성해 이후에도 쉽게 페이지를 추가할 수 있습니다.

+
+
+ {highlights.map((item) => ( + +
+

{item.label}

+

{item.description}

+
+ 열기 + + ))} +
+
+ +
+
+

최근 블로그

+

마크다운 파일을 추가하면 자동으로 목록에 반영됩니다.

+
+
+ {posts.map((post) => ( + +

{post.title}

+

{post.excerpt}

+ {post.date || '작성일 미정'} + + ))} +
+
+
+ ); +}; + +export default Home; diff --git a/src/pages/lotto/Functions.jsx b/src/pages/lotto/Functions.jsx new file mode 100644 index 0000000..1122ba8 --- /dev/null +++ b/src/pages/lotto/Functions.jsx @@ -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 }) => {n}; + +const NumberRow = ({ nums }) => ( +
+ {nums.map((n) => ( + + ))} +
+); + +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 ( +
+ {error ? ( +
+
+

오류

+

{error}

+
+ +
+ ) : null} + +
+
+
+
+

Latest Draw

+

최신 회차

+

+ 최신 회차와 번호를 빠르게 확인할 수 있습니다. +

+
+
+ {loading.latest ? 로딩 중 : null} + +
+
+ + {latest ? ( + <> +
+
+

{latest.drawNo}회

+

{latest.date}

+
+ +
+ +

+ 보너스 {latest.bonus} +

+ + ) : ( +

최신 회차 데이터가 없습니다.

+ )} +
+ +
+
+
+

Recommendation

+

추천 생성

+

+ 파라미터를 조정해 다른 추천 전략을 만들 수 있습니다. +

+
+
+ {loading.recommend ? 계산 중 : null} +
+
+ +
+ {presets.map((preset) => ( + + ))} +
+ +
+ + + + + +
+ + + + {result ? ( +
+
+
+

추천 ID #{result.id}

+

+ 기준 회차 {result.based_on_latest_draw ?? '-'} +

+
+ +
+ +
+ 설명 보기 +
{JSON.stringify(result.explain, null, 2)}
+
+
+ ) : ( +

아직 추천 결과가 없습니다.

+ )} +
+
+ +
+
+
+

History

+

추천 히스토리

+

+ 최근 추천 결과를 모아서 확인할 수 있습니다. +

+
+
+ {history.length}건 + +
+
+ + {loading.history ?

불러오는 중...

: null} + + {history.length === 0 ? ( +

저장된 히스토리가 없습니다.

+ ) : ( +
+ {history.map((item) => ( +
+
+

#{item.id}

+

{fmtKST(item.created_at)}

+

기준 회차 {item.based_on_draw ?? '-'}

+
+
+ +

+ window={item.params?.recent_window}, weight= + {item.params?.recent_weight}, avoid_k= + {item.params?.avoid_recent_k} +

+
+
+ + +
+
+ ))} +
+ )} +
+ +
+ backend: FastAPI / nginx proxy / DB: sqlite +
+
+ ); +} diff --git a/src/pages/lotto/Lotto.css b/src/pages/lotto/Lotto.css new file mode 100644 index 0000000..faf4b06 --- /dev/null +++ b/src/pages/lotto/Lotto.css @@ -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; + } +} diff --git a/src/pages/lotto/Lotto.jsx b/src/pages/lotto/Lotto.jsx new file mode 100644 index 0000000..8135a57 --- /dev/null +++ b/src/pages/lotto/Lotto.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import Functions from './Functions'; +import './Lotto.css'; + +const Lotto = () => { + return ( +
+
+
+

Playground

+

Lotto Lab

+

+ 기존 로또 추천 기능을 그대로 유지하면서 새로운 블로그 스타일에 맞게 + 레이아웃을 정리했습니다. +

+
+
+

다음 업데이트 아이디어

+
    +
  • 로또 기록을 캘린더 형태로 정리
  • +
  • 자주 등장하는 번호 조합 분석
  • +
  • 그래프로 추첨 추세 확인
  • +
+
+
+ + +
+ ); +}; + +export default Lotto; diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css new file mode 100644 index 0000000..edd8ec6 --- /dev/null +++ b/src/pages/travel/Travel.css @@ -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; + } +} diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx new file mode 100644 index 0000000..4cd950e --- /dev/null +++ b/src/pages/travel/Travel.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { travelGallery } from '../../data/travel'; +import './Travel.css'; + +const Travel = () => { + return ( +
+
+
+

Visual Diary

+

Travel Archive

+

+ 여행에서 본 색감과 분위기를 모아 전시하는 페이지입니다. +

+
+
+

렌더링 포인트

+

+ 사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다. +

+
+
+ +
+ {travelGallery.map((photo, index) => ( +
+ {photo.title} +
+

{photo.title}

+

+ {photo.location} · {photo.month} +

+
+
+ ))} +
+
+ ); +}; + +export default Travel; diff --git a/src/routes.jsx b/src/routes.jsx new file mode 100644 index 0000000..a080d29 --- /dev/null +++ b/src/routes.jsx @@ -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: , + }, + { + path: 'blog', + element: , + }, + { + path: 'lotto', + element: , + }, + { + path: 'travel', + element: , + }, +];