Files
nginx-portal/hub/index.html
qorgh529 54877f8e96 feat: 허브 홈페이지 구축 및 URL 구조 개편 (2026-05)
- hub/ 신규 추가: cyanburu.com/ 허브 홈페이지 (에디토리얼+사이버펑크 디자인)
- hub/index.html: /portal/api/homepage/cards 동적 카드 로딩
- k8s/00-hub.yaml: hub 네임스페이스 + Deployment + Service
- k8s/13-ingress-hub.yaml: cyanburu.com/ → hub 라우팅
- k8s/08-ingress.yaml: cyanburu.com/ → cyanburu.com/portal 경로 변경
- backend/main.py: homepage_cards CRUD API 추가, root_path=/portal 설정
- frontend/index.html: API 경로 /portal/api 수정, 홈 카드 관리 탭 추가
- README.md: 2026-05 변경 이력 추가
2026-06-10 18:18:53 +09:00

654 lines
20 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cyanburu.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,600;1,300;1,600&family=IBM+Plex+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--cream: #f4f0e8;
--ink: #141210;
--ink-muted: #5a5650;
--ink-faint: #c8c4bc;
--mint: #00e5b0;
--mint-dim: rgba(0,229,176,0.12);
--mint-glow: rgba(0,229,176,0.35);
--grid-color: rgba(20,18,16,0.055);
--serif: 'Cormorant Garamond', Georgia, serif;
--mono: 'IBM Plex Mono', monospace;
--sans: 'DM Sans', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--cream);
color: var(--ink);
font-family: var(--sans);
min-height: 100vh;
overflow-x: hidden;
cursor: none;
}
/* 커스텀 커서 */
.cursor {
position: fixed;
width: 8px; height: 8px;
background: var(--mint);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%,-50%);
transition: transform .1s, width .2s, height .2s, background .2s;
mix-blend-mode: multiply;
}
.cursor-ring {
position: fixed;
width: 32px; height: 32px;
border: 1px solid var(--ink-faint);
border-radius: 50%;
pointer-events: none;
z-index: 9998;
transform: translate(-50%,-50%);
transition: transform .18s ease, width .25s, height .25s, border-color .25s;
}
body:has(.service-card:hover) .cursor { width: 4px; height: 4px; background: var(--mint); }
body:has(.service-card:hover) .cursor-ring { width: 48px; height: 48px; border-color: var(--mint); }
/* 배경 그리드 */
.bg-grid {
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
/* 노이즈 오버레이 */
.bg-noise {
position: fixed;
inset: 0;
opacity: .025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px 256px;
pointer-events: none;
z-index: 0;
}
/* 레이아웃 */
.wrap {
position: relative;
z-index: 1;
max-width: 1080px;
margin: 0 auto;
padding: 0 40px;
}
/* 헤더 */
header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--ink-faint);
background: rgba(244,240,232,.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.logo {
font-family: var(--serif);
font-size: 20px;
font-weight: 600;
letter-spacing: -.01em;
color: var(--ink);
text-decoration: none;
}
.logo span { font-style: italic; font-weight: 300; color: var(--ink-muted); }
.header-tag {
font-family: var(--mono);
font-size: 10px;
color: var(--mint);
letter-spacing: .1em;
background: var(--mint-dim);
padding: 4px 10px;
border-radius: 2px;
}
/* 히어로 */
.hero {
padding: 160px 0 80px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
align-items: end;
border-bottom: 1px solid var(--ink-faint);
}
.hero-left { padding-bottom: 60px; }
.hero-eyebrow {
font-family: var(--mono);
font-size: 11px;
color: var(--mint);
letter-spacing: .15em;
margin-bottom: 24px;
opacity: 0;
animation: fadeUp .7s .1s ease forwards;
}
.hero-title {
font-family: var(--serif);
font-size: clamp(56px, 7vw, 96px);
font-weight: 300;
line-height: .95;
letter-spacing: -.02em;
color: var(--ink);
opacity: 0;
animation: fadeUp .7s .2s ease forwards;
}
.hero-title em {
font-style: italic;
font-weight: 300;
color: var(--ink-muted);
}
.hero-title strong {
font-weight: 600;
display: block;
}
.hero-desc {
margin-top: 32px;
font-size: 15px;
font-weight: 300;
color: var(--ink-muted);
line-height: 1.7;
max-width: 360px;
opacity: 0;
animation: fadeUp .7s .35s ease forwards;
}
.hero-cta {
margin-top: 40px;
display: flex;
gap: 16px;
align-items: center;
opacity: 0;
animation: fadeUp .7s .5s ease forwards;
}
.btn-primary {
font-family: var(--mono);
font-size: 11px;
letter-spacing: .08em;
color: var(--ink);
background: transparent;
border: 1px solid var(--ink);
padding: 12px 24px;
text-decoration: none;
transition: background .2s, color .2s, border-color .2s;
display: inline-block;
}
.btn-primary:hover { background: var(--ink); color: var(--cream); }
.btn-secondary {
font-family: var(--mono);
font-size: 11px;
color: var(--mint);
letter-spacing: .08em;
text-decoration: none;
border-bottom: 1px solid var(--mint-glow);
padding-bottom: 2px;
transition: border-color .2s;
}
.btn-secondary:hover { border-color: var(--mint); }
/* 히어로 오른쪽 — 터미널 블록 */
.hero-right {
padding-left: 60px;
padding-bottom: 60px;
border-left: 1px solid var(--ink-faint);
opacity: 0;
animation: fadeUp .7s .4s ease forwards;
}
.terminal {
font-family: var(--mono);
font-size: 12px;
line-height: 1.9;
color: var(--ink-muted);
}
.terminal .t-label {
font-size: 9px;
letter-spacing: .15em;
color: var(--ink-faint);
margin-bottom: 16px;
text-transform: uppercase;
}
.terminal .t-line { display: flex; gap: 12px; }
.terminal .t-prompt { color: var(--mint); flex-shrink: 0; }
.terminal .t-cmd { color: var(--ink); }
.terminal .t-out { color: var(--ink-muted); padding-left: 20px; }
.terminal .t-status {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--mint);
margin-right: 8px;
box-shadow: 0 0 6px var(--mint-glow);
vertical-align: middle;
}
.terminal .t-status.off { background: var(--ink-faint); box-shadow: none; }
/* 서비스 섹션 */
.section-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .2em;
color: var(--ink-faint);
text-transform: uppercase;
padding: 48px 0 32px;
}
.services {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border-top: 1px solid var(--ink-faint);
border-left: 1px solid var(--ink-faint);
margin-bottom: 0;
}
.service-card {
border-right: 1px solid var(--ink-faint);
border-bottom: 1px solid var(--ink-faint);
padding: 40px;
text-decoration: none;
color: var(--ink);
display: block;
position: relative;
overflow: hidden;
transition: background .25s;
opacity: 0;
animation: fadeUp .6s ease forwards;
}
.service-card:nth-child(1) { animation-delay: .1s; }
.service-card:nth-child(2) { animation-delay: .2s; }
.service-card:nth-child(3) { animation-delay: .3s; }
.service-card:nth-child(4) { animation-delay: .4s; }
.service-card::before {
content: '';
position: absolute;
inset: 0;
background: var(--mint-dim);
opacity: 0;
transition: opacity .25s;
}
.service-card::after {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--mint);
transform: scaleY(0);
transform-origin: bottom;
transition: transform .3s ease;
}
.service-card:hover::before { opacity: 1; }
.service-card:hover::after { transform: scaleY(1); }
.service-card:hover .card-arrow { transform: translate(4px,-4px); color: var(--mint); }
.service-card:hover .card-num { color: var(--mint); }
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
position: relative;
}
.card-num {
font-family: var(--mono);
font-size: 11px;
color: var(--ink-faint);
letter-spacing: .1em;
transition: color .25s;
}
.card-tag {
font-family: var(--mono);
font-size: 9px;
color: var(--ink-faint);
border: 1px solid var(--ink-faint);
padding: 3px 8px;
letter-spacing: .08em;
}
.card-tag.live {
color: var(--mint);
border-color: var(--mint-glow);
background: var(--mint-dim);
}
.card-tag.new-tag {
color: #f59e0b;
border-color: rgba(245,158,11,0.3);
background: rgba(245,158,11,0.08);
}
.card-title {
font-family: var(--serif);
font-size: 36px;
font-weight: 600;
line-height: 1;
letter-spacing: -.02em;
margin-bottom: 8px;
position: relative;
}
.card-subtitle {
font-family: var(--serif);
font-size: 18px;
font-weight: 300;
font-style: italic;
color: var(--ink-muted);
margin-bottom: 20px;
position: relative;
}
.card-desc {
font-size: 13px;
font-weight: 300;
color: var(--ink-muted);
line-height: 1.7;
position: relative;
}
.card-footer {
margin-top: 28px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.card-url {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-faint);
letter-spacing: .05em;
}
.card-arrow {
font-size: 18px;
color: var(--ink-faint);
transition: transform .25s, color .25s;
line-height: 1;
}
/* 킹컵 카드는 full-width */
.service-card.wide {
grid-column: 1 / -1;
}
.service-card.wide .card-title { font-size: 48px; }
.service-card.wide .card-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: end;
}
/* 스택 섹션 */
.stack-section {
border-top: 1px solid var(--ink-faint);
padding: 48px 0;
display: grid;
grid-template-columns: 200px 1fr;
gap: 40px;
align-items: start;
}
.stack-heading {
font-family: var(--serif);
font-size: 13px;
font-style: italic;
color: var(--ink-muted);
padding-top: 4px;
}
.stack-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.stack-tag {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .06em;
color: var(--ink-muted);
border: 1px solid var(--ink-faint);
padding: 5px 12px;
transition: border-color .2s, color .2s;
}
.stack-tag:hover { border-color: var(--mint); color: var(--mint); }
/* 푸터 */
footer {
border-top: 1px solid var(--ink-faint);
padding: 32px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
font-family: var(--serif);
font-size: 13px;
font-style: italic;
color: var(--ink-muted);
}
.footer-right {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-faint);
letter-spacing: .08em;
}
/* 애니메이션 */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* 반응형 */
@media (max-width: 768px) {
header { padding: 16px 20px; }
.wrap { padding: 0 20px; }
.hero { grid-template-columns: 1fr; padding: 120px 0 60px; }
.hero-right { border-left: none; border-top: 1px solid var(--ink-faint); padding-left: 0; padding-top: 40px; margin-top: 40px; }
.services { grid-template-columns: 1fr; }
.service-card.wide .card-content { grid-template-columns: 1fr; gap: 16px; }
.stack-section { grid-template-columns: 1fr; gap: 16px; }
footer { flex-direction: column; gap: 12px; text-align: center; }
}
</style>
</head>
<body>
<div class="cursor" id="cursor"></div>
<div class="cursor-ring" id="cursorRing"></div>
<div class="bg-grid"></div>
<div class="bg-noise"></div>
<header>
<a href="/" class="logo">cyan<span>buru</span>.com</a>
<div class="header-tag">// SYSTEM ONLINE</div>
</header>
<main>
<div class="wrap">
<!-- 히어로 -->
<section class="hero">
<div class="hero-left">
<div class="hero-eyebrow">// PERSONAL INFRASTRUCTURE HUB · 2026</div>
<h1 class="hero-title">
<em>built</em><br>
<strong>by hand,</strong><br>
<em>runs on</em><br>
<strong>k8s.</strong>
</h1>
<p class="hero-desc">
개인 서버 위에서 직접 설계하고 운영하는 서비스들의 집합소입니다.
Kubernetes, FastAPI, GitOps — 직접 만든 것들로 이루어진 공간.
</p>
<div class="hero-cta">
<a href="/portal" class="btn-primary">PORTAL 입장</a>
<a href="/kingscup" class="btn-secondary">킹컵 게임 →</a>
</div>
</div>
<div class="hero-right">
<div class="terminal">
<div class="t-label">// cluster status</div>
<div class="t-line"><span class="t-prompt">$</span><span class="t-cmd">kubectl get ns</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>web-portal&nbsp;&nbsp;&nbsp;&nbsp;Active</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>kingscup&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Active</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>gitea&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Active</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>argocd&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Active</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>ingress-nginx&nbsp;Active</span></div>
<br>
<div class="t-line"><span class="t-prompt">$</span><span class="t-cmd">kubectl get nodes</span></div>
<div class="t-line"><span class="t-out"><span class="t-status"></span>docker-desktop&nbsp;Ready&nbsp;·&nbsp;Ryzen 5600G</span></div>
<br>
<div class="t-line"><span class="t-prompt">$</span><span class="t-cmd">uptime</span></div>
<div class="t-line"><span class="t-out" id="uptime-line">calculating...</span></div>
</div>
</div>
</section>
<!-- 서비스 카드 (동적 로딩) -->
<div class="section-label">// services</div>
<div class="services" id="services-grid">
<!-- JS에서 동적으로 렌더링 -->
<div style="grid-column:1/-1;padding:60px;text-align:center;color:var(--ink-faint);font-family:var(--mono);font-size:12px">
// loading services...
</div>
</div>
<!-- 기술 스택 -->
<div class="stack-section">
<div class="stack-heading">기술 스택</div>
<div class="stack-tags">
<span class="stack-tag">Kubernetes</span>
<span class="stack-tag">Docker Desktop</span>
<span class="stack-tag">Nginx Ingress</span>
<span class="stack-tag">cert-manager</span>
<span class="stack-tag">FastAPI</span>
<span class="stack-tag">Python</span>
<span class="stack-tag">WebSocket</span>
<span class="stack-tag">React</span>
<span class="stack-tag">PostgreSQL</span>
<span class="stack-tag">Redis</span>
<span class="stack-tag">Gitea</span>
<span class="stack-tag">ArgoCD</span>
<span class="stack-tag">Let's Encrypt</span>
<span class="stack-tag">Discord Webhook</span>
<span class="stack-tag">Ryzen 5600G · 64GB</span>
</div>
</div>
<!-- 푸터 -->
<footer>
<div class="footer-left">cyanburu.com — personal infrastructure</div>
<div class="footer-right">// SELF-HOSTED · ALL RIGHTS RESERVED · 2026</div>
</footer>
</div>
</main>
<script>
// 커스텀 커서
const cursor = document.getElementById('cursor');
const ring = document.getElementById('cursorRing');
let mx = 0, my = 0, rx = 0, ry = 0;
document.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
function animCursor() {
cursor.style.left = mx + 'px';
cursor.style.top = my + 'px';
rx += (mx - rx) * .12;
ry += (my - ry) * .12;
ring.style.left = rx + 'px';
ring.style.top = ry + 'px';
requestAnimationFrame(animCursor);
}
animCursor();
// 업타임 계산 (페이지 로드 기준 표시)
const start = Date.now();
function updateUptime() {
const s = Math.floor((Date.now() - start) / 1000);
const h = String(Math.floor(s/3600)).padStart(2,'0');
const m = String(Math.floor((s%3600)/60)).padStart(2,'0');
const sec = String(s%60).padStart(2,'0');
document.getElementById('uptime-line').textContent = `up ${h}:${m}:${sec}, load avg 0.12`;
}
updateUptime();
setInterval(updateUptime, 1000);
// 카드 호버 시 커서 블렌드 (동적 카드에 적용)
function bindCardCursor() {
document.querySelectorAll('.service-card').forEach(card => {
card.addEventListener('mouseenter', () => cursor.style.mixBlendMode = 'normal');
card.addEventListener('mouseleave', () => cursor.style.mixBlendMode = 'multiply');
});
}
// 태그 스타일 매핑
function tagClass(tag) {
if (tag === 'LIVE') return 'live';
if (tag === 'NEW') return 'new-tag';
return '';
}
// URL에서 표시용 짧은 주소 추출
function displayUrl(url) {
try {
const u = new URL(url, location.origin);
return u.hostname === location.hostname
? 'cyanburu.com' + u.pathname
: u.hostname;
} catch { return url; }
}
// 동적 카드 렌더링
async function loadCards() {
const grid = document.getElementById('services-grid');
try {
const res = await fetch('/portal/api/homepage/cards');
if (!res.ok) throw new Error('fetch error');
const cards = await res.json();
if (!cards || cards.length === 0) {
grid.innerHTML = '<div style="grid-column:1/-1;padding:60px;text-align:center;color:var(--ink-faint);font-family:var(--mono);font-size:12px">// no services registered</div>';
return;
}
grid.innerHTML = cards.map((c, i) => {
const isExternal = c.url.startsWith('http') && !c.url.includes('cyanburu.com');
const tc = tagClass(c.tag);
return `<a href="${c.url}" class="service-card" ${isExternal ? 'target="_blank" rel="noopener"' : ''} style="animation-delay:${i * 0.1}s">
<div class="card-top">
<span class="card-num">${String(i + 1).padStart(2, '0')}</span>
<span class="card-tag ${tc}">${c.tag || ''}</span>
</div>
<div class="card-title">${c.title}</div>
<div class="card-subtitle">${c.subtitle || ''}</div>
<p class="card-desc">${c.description || ''}</p>
<div class="card-footer">
<span class="card-url">${displayUrl(c.url)}</span>
<span class="card-arrow">↗</span>
</div>
</a>`;
}).join('');
bindCardCursor();
} catch (e) {
// API 실패 시 정적 카드로 폴백
grid.innerHTML = `
<a href="/portal" class="service-card"><div class="card-top"><span class="card-num">01</span><span class="card-tag live">LIVE</span></div><div class="card-title">Web Portal</div><div class="card-subtitle">내부 서비스 관리</div><p class="card-desc">로그인 후 할당된 웹 서비스 목록을 확인하고 접속할 수 있는 통합 포털.</p><div class="card-footer"><span class="card-url">cyanburu.com/portal</span><span class="card-arrow">↗</span></div></a>
<a href="https://gitea.cyanburu.com" class="service-card" target="_blank" rel="noopener"><div class="card-top"><span class="card-num">02</span><span class="card-tag live">LIVE</span></div><div class="card-title">Gitea</div><div class="card-subtitle">셀프호스티드 Git</div><p class="card-desc">자체 서버에서 운영하는 Git 저장소.</p><div class="card-footer"><span class="card-url">gitea.cyanburu.com</span><span class="card-arrow">↗</span></div></a>
<a href="https://argo.cyanburu.com" class="service-card" target="_blank" rel="noopener"><div class="card-top"><span class="card-num">03</span><span class="card-tag live">LIVE</span></div><div class="card-title">ArgoCD</div><div class="card-subtitle">GitOps 배포 엔진</div><p class="card-desc">K8s 클러스터 자동 배포 도구.</p><div class="card-footer"><span class="card-url">argo.cyanburu.com</span><span class="card-arrow">↗</span></div></a>`;
bindCardCursor();
}
}
loadCards();
</script>
</body>
</html>