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 변경 이력 추가
This commit is contained in:
qorgh529
2026-06-10 18:18:53 +09:00
parent 72689d8647
commit 54877f8e96
7 changed files with 1019 additions and 17 deletions

View File

@@ -802,3 +802,136 @@ def delete_notice_reply(reply_id: int, token=Depends(verify_token), conn=Depends
cur.execute("DELETE FROM notice_replies WHERE id = %s", (reply_id,))
conn.commit()
return {"ok": True}
# ─── Homepage Cards ──────────────────────────────────────────────────────────
# 이 코드를 기존 main.py 맨 아래에 붙여넣으세요.
# init_db() 함수 안의 cur.execute("""...""") 블록에도 아래 테이블을 추가해야 합니다.
# (init_db 수정 방법은 아래 주석 참고)
#
# [init_db() 수정] 기존 CREATE TABLE ... 블록 마지막에 추가:
#
# CREATE TABLE IF NOT EXISTS homepage_cards (
# id SERIAL PRIMARY KEY,
# title VARCHAR(100) NOT NULL,
# subtitle VARCHAR(200),
# description TEXT,
# url VARCHAR(500) NOT NULL,
# tag VARCHAR(20) DEFAULT 'LIVE',
# sort_order INTEGER DEFAULT 0,
# visible BOOLEAN DEFAULT TRUE,
# created_at TIMESTAMP DEFAULT NOW()
# );
#
# ─────────────────────────────────────────────────────────────────────────────
def init_homepage_cards_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS homepage_cards (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
subtitle VARCHAR(200),
description TEXT,
url VARCHAR(500) NOT NULL,
tag VARCHAR(20) DEFAULT 'LIVE',
sort_order INTEGER DEFAULT 0,
visible BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
""")
# 기본 카드 초기 데이터 (최초 1회만)
cur.execute("SELECT COUNT(*) FROM homepage_cards")
count = cur.fetchone()[0]
if count == 0:
default_cards = [
("Web Portal", "내부 서비스 관리", "로그인 후 할당된 웹 서비스 목록을 확인하고 접속할 수 있는 통합 포털.", "/portal", "LIVE", 1),
("King's Cup", "멀티플레이 술게임", "디스코드 화면공유로 친구들과 함께 즐기는 킹컵 카드 게임. 실시간 WebSocket 기반.", "/kingscup", "NEW", 2),
("Gitea", "셀프호스티드 Git", "GitHub 대신 자체 서버에서 운영하는 Git 저장소. Container Registry 포함.", "https://gitea.cyanburu.com", "LIVE", 3),
("ArgoCD", "GitOps 배포 엔진", "Gitea 저장소를 감시해 K8s 클러스터에 자동으로 배포하는 GitOps 도구.", "https://argo.cyanburu.com", "LIVE", 4),
]
cur.executemany(
"INSERT INTO homepage_cards (title, subtitle, description, url, tag, sort_order) VALUES (%s,%s,%s,%s,%s,%s)",
default_cards
)
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_homepage_cards():
import time
for _ in range(10):
try:
init_homepage_cards_db()
print("Homepage cards DB initialized")
break
except Exception as e:
print(f"Homepage cards DB not ready... {e}")
time.sleep(3)
# ── Public API (인증 불필요 - 허브 홈페이지에서 호출) ──────────────────────
@app.get("/api/homepage/cards")
def get_homepage_cards(conn=Depends(get_db)):
"""허브 홈페이지(cyanburu.com/)에서 카드 목록을 가져옵니다. 인증 불필요."""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT id, title, subtitle, description, url, tag, sort_order
FROM homepage_cards
WHERE visible = TRUE
ORDER BY sort_order ASC, id ASC
""")
return cur.fetchall()
# ── Admin API (관리자 전용) ────────────────────────────────────────────────
class HomepageCardCreate(BaseModel):
title: str
subtitle: Optional[str] = ""
description: Optional[str] = ""
url: str
tag: Optional[str] = "LIVE"
sort_order: Optional[int] = 0
visible: Optional[bool] = True
@app.get("/api/admin/homepage-cards")
def list_homepage_cards(token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 전체 카드 목록 (비공개 포함)"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM homepage_cards ORDER BY sort_order ASC, id ASC")
return cur.fetchall()
@app.post("/api/admin/homepage-cards")
def create_homepage_card(data: HomepageCardCreate, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 추가"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO homepage_cards (title, subtitle, description, url, tag, sort_order, visible)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *""",
(data.title, data.subtitle, data.description, data.url, data.tag, data.sort_order, data.visible)
)
conn.commit()
return cur.fetchone()
@app.put("/api/admin/homepage-cards/{card_id}")
def update_homepage_card(card_id: int, data: HomepageCardCreate, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 수정"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE homepage_cards
SET title=%s, subtitle=%s, description=%s, url=%s, tag=%s, sort_order=%s, visible=%s
WHERE id=%s RETURNING *""",
(data.title, data.subtitle, data.description, data.url, data.tag, data.sort_order, data.visible, card_id)
)
result = cur.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Card not found")
conn.commit()
return result
@app.delete("/api/admin/homepage-cards/{card_id}")
def delete_homepage_card(card_id: int, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 삭제"""
cur = conn.cursor()
cur.execute("DELETE FROM homepage_cards WHERE id=%s", (card_id,))
conn.commit()
return {"ok": True}

View File

@@ -210,6 +210,7 @@
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-notify')" style="display:none">🔔 알림 설정</button>
<button class="nav-btn admin-only" onclick="showPage('admin-hub-cards')" style="display:none">🏠 홈 카드 관리</button>
</nav>
<main>
@@ -447,10 +448,60 @@
</div>
</div>
<div id="page-admin-hub-cards" class="page">
<div class="page-header">
<div><h2>🏠 홈 카드 관리</h2><p>cyanburu.com 메인 허브 홈페이지에 표시될 카드를 관리합니다.</p></div>
<button class="btn btn-sm" onclick="openHubCardModal()">+ 카드 추가</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th style="width:50px">순서</th>
<th>제목</th>
<th>부제목</th>
<th>URL</th>
<th style="width:70px">태그</th>
<th style="width:70px">공개</th>
<th style="width:140px">관리</th>
</tr>
</thead>
<tbody id="hub-cards-table-body"></tbody>
</table>
</div>
</div>
</main>
</div>
<!-- MODALS -->
<div id="hub-card-modal" class="modal-overlay">
<div class="modal">
<h3 id="hub-card-modal-title">카드 추가</h3>
<div class="form-group"><label>제목 *</label><input type="text" id="hcm-title" placeholder="예: King's Cup"/></div>
<div class="form-group"><label>부제목</label><input type="text" id="hcm-subtitle" placeholder="예: 멀티플레이 술게임"/></div>
<div class="form-group"><label>설명</label><textarea id="hcm-desc" style="width:100%;padding:10px;border:2px solid var(--border);border-radius:8px;resize:vertical;min-height:80px;font-family:inherit;font-size:.9rem" placeholder="카드 설명을 입력하세요"></textarea></div>
<div class="form-group"><label>URL *</label><input type="text" id="hcm-url" placeholder="예: /kingscup 또는 https://gitea.cyanburu.com"/></div>
<div class="form-group">
<label>태그</label>
<select id="hcm-tag" style="width:100%;padding:10px;border:2px solid var(--border);border-radius:8px;font-size:.9rem">
<option value="LIVE">LIVE</option>
<option value="NEW">NEW</option>
<option value="WIP">WIP</option>
<option value="DEV">DEV</option>
</select>
</div>
<div class="form-group"><label>정렬 순서 (숫자가 작을수록 앞에 표시)</label><input type="number" id="hcm-sort" value="0" min="0" style="width:100%;padding:10px;border:2px solid var(--border);border-radius:8px;font-size:.9rem"/></div>
<div class="form-group" style="display:flex;align-items:center;gap:10px">
<input type="checkbox" id="hcm-visible" checked style="width:16px;height:16px;accent-color:var(--primary)"/>
<label for="hcm-visible" style="margin:0;font-size:.9rem">홈페이지에 공개</label>
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('hub-card-modal')">취소</button>
<button class="btn btn-sm" onclick="saveHubCard()">저장</button>
</div>
</div>
</div>
<div id="page-modal" class="modal-overlay">
<div class="modal">
<h3 id="page-modal-title">페이지 추가</h3>
@@ -512,7 +563,7 @@
</div>
<script>
const API='/api';
const API='/portal/api';
let token=localStorage.getItem('portal_token');
let currentUser=null;
let editingPageId=null;
@@ -539,6 +590,7 @@ function showPage(name){
if(name==='admin-pages')loadAdminPages();
if(name==='admin-users')loadAdminUsers();
if(name==='admin-notify')loadNotifyConfig();
if(name==='admin-hub-cards')loadHubCards();
}
function closeModal(id){document.getElementById(id).classList.remove('open');}
@@ -1043,6 +1095,8 @@ function renderNotifyChannelList(){
<button class="btn btn-sm btn-danger" style="padding:5px 10px;" onclick="event.stopPropagation();deleteNotifyChannel(${ch.id})">🗑️</button>
</div>
</div>
<!-- 홈 카드 관리 (관리자) -->
<!-- 홈 카드 추가/수정 모달 -->
`).join('');
}
@@ -1149,6 +1203,73 @@ function fmtDate(s){
}catch{doLogout();}
}
})();
// ── 홈 카드 관리 ──────────────────────────────────────────────────────────
let editingHubCardId = null;
async function loadHubCards() {
const cards = await apiFetch('/admin/homepage-cards');
const tbody = document.getElementById('hub-cards-table-body');
if (!cards || cards.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--muted);padding:40px">등록된 카드가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = cards.map(c => `
<tr>
<td style="text-align:center;color:var(--muted)">${c.sort_order}</td>
<td><strong>${escHtml(c.title)}</strong></td>
<td style="color:var(--muted)">${escHtml(c.subtitle || '-')}</td>
<td><a href="${escHtml(c.url)}" target="_blank" style="color:var(--primary);font-size:.8rem">${escHtml(c.url)}</a></td>
<td><span class="tag ${c.tag === 'LIVE' ? 'tag-ok' : c.tag === 'NEW' ? 'tag-warning' : 'tag-user'}">${escHtml(c.tag || '')}</span></td>
<td style="text-align:center">${c.visible ? '✅' : '🔒'}</td>
<td><div class="actions">
<button class="btn btn-sm btn-gray" onclick="openHubCardModal(${c.id},'${escJs(c.title)}','${escJs(c.subtitle||'')}','${escJs(c.description||'')}','${escJs(c.url)}','${escJs(c.tag||'LIVE')}',${c.sort_order},${c.visible})">수정</button>
<button class="btn btn-sm btn-danger" onclick="deleteHubCard(${c.id})">삭제</button>
</div></td>
</tr>`).join('');
}
function openHubCardModal(id, title, subtitle, desc, url, tag, sort, visible) {
editingHubCardId = id || null;
document.getElementById('hub-card-modal-title').textContent = id ? '카드 수정' : '카드 추가';
document.getElementById('hcm-title').value = title || '';
document.getElementById('hcm-subtitle').value = subtitle || '';
document.getElementById('hcm-desc').value = desc || '';
document.getElementById('hcm-url').value = url || '';
document.getElementById('hcm-tag').value = tag || 'LIVE';
document.getElementById('hcm-sort').value = sort !== undefined ? sort : 0;
document.getElementById('hcm-visible').checked = visible !== undefined ? visible : true;
openModal('hub-card-modal');
}
async function saveHubCard() {
const body = {
title: document.getElementById('hcm-title').value.trim(),
subtitle: document.getElementById('hcm-subtitle').value.trim(),
description: document.getElementById('hcm-desc').value.trim(),
url: document.getElementById('hcm-url').value.trim(),
tag: document.getElementById('hcm-tag').value,
sort_order: parseInt(document.getElementById('hcm-sort').value) || 0,
visible: document.getElementById('hcm-visible').checked,
};
if (!body.title || !body.url) { alert('제목과 URL은 필수입니다.'); return; }
try {
if (editingHubCardId) {
await apiFetch('/admin/homepage-cards/' + editingHubCardId, { method: 'PUT', body: JSON.stringify(body) });
} else {
await apiFetch('/admin/homepage-cards', { method: 'POST', body: JSON.stringify(body) });
}
closeModal('hub-card-modal');
loadHubCards();
} catch(e) { alert(e.message); }
}
async function deleteHubCard(id) {
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
try {
await apiFetch('/admin/homepage-cards/' + id, { method: 'DELETE' });
loadHubCards();
} catch(e) { alert(e.message); }
}
</script>
</body>
</html>

4
hub/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80

653
hub/index.html Executable file
View File

@@ -0,0 +1,653 @@
<!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>

61
k8s/00-hub.yaml Executable file
View File

@@ -0,0 +1,61 @@
---
# hub 네임스페이스
apiVersion: v1
kind: Namespace
metadata:
name: hub
---
# 허브 홈페이지 Deployment
# index.html을 정적으로 서빙하는 nginx 파드
apiVersion: apps/v1
kind: Deployment
metadata:
name: hub
namespace: hub
spec:
replicas: 1
selector:
matchLabels:
app: hub
template:
metadata:
labels:
app: hub
spec:
imagePullSecrets:
- name: gitea-registry-secret
containers:
- name: hub
image: 192.168.10.101:30000/qorgh529/hub:latest
ports:
- containerPort: 80
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
---
# hub Service (ClusterIP)
apiVersion: v1
kind: Service
metadata:
name: hub-service
namespace: hub
spec:
selector:
app: hub
ports:
- port: 80
targetPort: 80
---
# hub용 Gitea Registry Secret
# kubectl create secret docker-registry gitea-registry-secret \
# --namespace=hub \
# --docker-server=192.168.10.101:30000 \
# --docker-username=<계정> \
# --docker-password=<패스워드>

View File

@@ -4,24 +4,28 @@ metadata:
name: web-portal-ingress
namespace: web-portal
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# Rate limiting (기존 유지)
nginx.ingress.kubernetes.io/limit-rps: "10"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- cyanburu.com
secretName: web-portal-tls
- hosts:
- cyanburu.com
secretName: cyanburu-tls
rules:
- host: cyanburu.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
- host: cyanburu.com
http:
paths:
# /portal 경로 → web-portal 프론트엔드
# rewrite-target: /portal/foo → /foo 로 strip
- path: /portal(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: frontend-service
port:
number: 80

26
k8s/13-ingress-hub.yaml Executable file
View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hub-ingress
namespace: hub
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- cyanburu.com
secretName: cyanburu-tls
rules:
- host: cyanburu.com
http:
paths:
# / (루트) → 허브 홈페이지 정적 파일
- path: /
pathType: Prefix
backend:
service:
name: hub-service
port:
number: 80