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:
4
hub/Dockerfile
Normal file
4
hub/Dockerfile
Normal 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
653
hub/index.html
Executable 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 Active</span></div>
|
||||
<div class="t-line"><span class="t-out"><span class="t-status"></span>kingscup Active</span></div>
|
||||
<div class="t-line"><span class="t-out"><span class="t-status"></span>gitea Active</span></div>
|
||||
<div class="t-line"><span class="t-out"><span class="t-status"></span>argocd Active</span></div>
|
||||
<div class="t-line"><span class="t-out"><span class="t-status"></span>ingress-nginx 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 Ready · 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>
|
||||
Reference in New Issue
Block a user