Compare commits
3 Commits
e10f15ec00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54877f8e96 | ||
|
|
72689d8647 | ||
|
|
c092efe160 |
157
README.md
157
README.md
@@ -12,17 +12,21 @@
|
||||
|
||||
```
|
||||
사용자 (외부 인터넷)
|
||||
├── https://cyanburu.com → Web Portal
|
||||
├── https://gitea.cyanburu.com → Gitea
|
||||
└── https://argo.cyanburu.com → ArgoCD
|
||||
├── https://cyanburu.com → Hub 홈페이지 (포트폴리오/서비스 허브)
|
||||
├── https://cyanburu.com/portal → Web Portal
|
||||
├── https://cyanburu.com/kingscup → King's Cup 게임 (개발 예정)
|
||||
├── https://gitea.cyanburu.com → Gitea
|
||||
└── https://argo.cyanburu.com → ArgoCD
|
||||
↓
|
||||
MSI 라우터 (포트포워딩 80/443)
|
||||
↓
|
||||
Nginx Ingress Controller ← TLS 종료, 도메인별 라우팅
|
||||
Nginx Ingress Controller ← TLS 종료, 도메인/경로별 라우팅
|
||||
↓
|
||||
cert-manager ← Let's Encrypt 인증서 자동 발급/갱신
|
||||
↓
|
||||
Kubernetes 네임스페이스별 서비스
|
||||
├── hub
|
||||
│ └── Nginx (정적 HTML) ← cyanburu.com/ 허브 홈페이지
|
||||
├── web-portal
|
||||
│ ├── Nginx Frontend (ClusterIP: 80)
|
||||
│ ├── FastAPI Backend (ClusterIP: 8000)
|
||||
@@ -46,6 +50,7 @@ Gitea → ArgoCD 자동 감지 & 배포
|
||||
| Frontend | Nginx + HTML/CSS/JS (SPA) |
|
||||
| Backend | Python FastAPI |
|
||||
| Database | PostgreSQL |
|
||||
| Cache | Redis (King's Cup 세션) |
|
||||
| Container | Docker Desktop |
|
||||
| Orchestration | Kubernetes (Docker Desktop 내장) |
|
||||
| GitOps | Gitea + ArgoCD |
|
||||
@@ -70,22 +75,27 @@ nginx-portal/
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── index.html # 싱글 페이지 앱 (SPA)
|
||||
│ ├── nginx.conf # Nginx 설정 + /api/* 프록시
|
||||
│ ├── nginx.conf # Nginx 설정 + /api/* 프록시 (/portal subpath 대응)
|
||||
│ └── Dockerfile
|
||||
├── hub/ # 허브 홈페이지 (cyanburu.com/)
|
||||
│ ├── index.html # 포트폴리오/서비스 허브 정적 페이지
|
||||
│ └── Dockerfile
|
||||
├── k8s/
|
||||
│ ├── 00-hub.yaml # hub 네임스페이스 + Deployment + Service
|
||||
│ ├── 01-namespace.yaml # web-portal 네임스페이스
|
||||
│ ├── 02-postgres.yaml # PostgreSQL + PVC + Service
|
||||
│ ├── 03-secrets.yaml # DB/JWT 시크릿
|
||||
│ ├── 04-backend.yaml # FastAPI Deployment + Service
|
||||
│ └── 05-frontend.yaml # Nginx Deployment + NodePort(30090)
|
||||
│ ├── 05-frontend.yaml # Nginx Deployment + NodePort(30090)
|
||||
│ ├── 07-clusterissuer.yaml # Let's Encrypt ClusterIssuer
|
||||
│ ├── 08-ingress.yaml # Web Portal Ingress (cyanburu.com/portal)
|
||||
│ ├── 09-ingress-gitea.yaml # Gitea Ingress (gitea.cyanburu.com)
|
||||
│ ├── 10-ingress-argocd.yaml # ArgoCD Ingress (argo.cyanburu.com)
|
||||
│ ├── 11-notify-secrets.yaml # Discord Webhook / Gmail Secret
|
||||
│ ├── 12-???.yaml # 기존 파일
|
||||
│ └── 13-ingress-hub.yaml # Hub Ingress (cyanburu.com/)
|
||||
├── 06-argocd-app.yaml # ArgoCD Application 정의 (k8s 폴더 밖에 위치)
|
||||
└── README.md
|
||||
|
||||
k8s/ 폴더 내 추가 파일:
|
||||
├── 07-clusterissuer.yaml # Let's Encrypt ClusterIssuer
|
||||
├── 08-ingress.yaml # Web Portal Ingress (cyanburu.com)
|
||||
├── 09-ingress-gitea.yaml # Gitea Ingress (gitea.cyanburu.com)
|
||||
└── 10-ingress-argocd.yaml # ArgoCD Ingress (argo.cyanburu.com)
|
||||
```
|
||||
|
||||
> ⚠️ `06-argocd-app.yaml` 은 반드시 `k8s/` 폴더 **밖**에 위치해야 합니다.
|
||||
@@ -126,6 +136,7 @@ k8s/ 폴더 내 추가 파일:
|
||||
- **계정 잠금 해제**: 잠긴 계정을 버튼 하나로 해제
|
||||
- **사용자 상태 확인**: 정상 / 🔒잠김 / 초기PW / 변경요청 태그로 한눈에 확인
|
||||
- **공지사항 작성**: 공지 탭에서 전체 사용자에게 공지 등록 / 삭제
|
||||
- **🏠 홈 카드 관리**: `cyanburu.com` 메인 허브 홈페이지에 표시될 카드 추가 / 수정 / 삭제 / 순서 변경
|
||||
|
||||
### 게시판 (공지 / 관리자 요청)
|
||||
|
||||
@@ -273,10 +284,32 @@ kubectl get certificate -n argocd
|
||||
|
||||
| 서비스 | URL |
|
||||
|--------|-----|
|
||||
| Web Portal | `https://cyanburu.com` |
|
||||
| Hub 홈페이지 | `https://cyanburu.com` |
|
||||
| Web Portal | `https://cyanburu.com/portal` |
|
||||
| Gitea | `https://gitea.cyanburu.com` |
|
||||
| ArgoCD | `https://argo.cyanburu.com` |
|
||||
|
||||
### 14단계. Hub 홈페이지 배포 (최초 1회)
|
||||
```bash
|
||||
# hub 네임스페이스 생성
|
||||
kubectl create namespace hub
|
||||
|
||||
# Registry Secret 생성
|
||||
kubectl create secret docker-registry gitea-registry-secret \
|
||||
--namespace=hub \
|
||||
--docker-server=192.168.10.101:30000 \
|
||||
--docker-username=<계정> \
|
||||
--docker-password=<패스워드>
|
||||
|
||||
# 이미지 빌드 & Push
|
||||
docker build -t 192.168.10.101:30000/<계정>/hub:latest ./hub/
|
||||
docker push 192.168.10.101:30000/<계정>/hub:latest
|
||||
|
||||
# 배포
|
||||
kubectl apply -f k8s/00-hub.yaml
|
||||
kubectl apply -f k8s/13-ingress-hub.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 이후 배포 방법 (코드 수정 시)
|
||||
@@ -822,3 +855,101 @@ backend/
|
||||
frontend/
|
||||
└── index.html ← 🔔 알림 설정 탭 추가 (좌측 폼 + 우측 리스트 UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2026-05-19 (허브 홈페이지 구축 + URL 구조 개편)
|
||||
|
||||
#### URL 구조 변경
|
||||
|
||||
| 변경 전 | 변경 후 | 내용 |
|
||||
|---------|---------|------|
|
||||
| `cyanburu.com/` | `cyanburu.com/portal` | 기존 웹 포털 경로 변경 |
|
||||
| — | `cyanburu.com/` | 신규 허브 홈페이지 (루트) |
|
||||
| — | `cyanburu.com/kingscup` | 킹컵 게임 (개발 예정) |
|
||||
|
||||
#### 기능 추가
|
||||
|
||||
**허브 홈페이지 (`cyanburu.com/`)**
|
||||
- 포트폴리오 겸 서비스 허브 페이지 신규 구축
|
||||
- 에디토리얼 매거진 + 사이버펑크 믹스 디자인
|
||||
- `Cormorant Garamond` 세리프 폰트 + 크림 배경 (에디토리얼)
|
||||
- 배경 격자 그리드, 민트 네온 호버 효과 (사이버펑크)
|
||||
- 터미널 스타일 클러스터 상태 표시 + 실시간 업타임 카운터
|
||||
- 커스텀 커서 (민트 점 + 링)
|
||||
- DB 기반 동적 카드 로딩 — `/portal/api/homepage/cards` API 호출
|
||||
- API 실패 시 정적 카드로 자동 폴백
|
||||
- `hub` 네임스페이스에 독립 배포 (nginx 정적 서빙)
|
||||
|
||||
**홈 카드 관리 (관리자)**
|
||||
- `cyanburu.com/portal` 관리자 탭에 `🏠 홈 카드 관리` 추가
|
||||
- 카드 추가 / 수정 / 삭제 / 순서 변경 / 공개 여부 설정
|
||||
- 변경 사항이 허브 홈페이지에 즉시 반영 (재배포 불필요)
|
||||
- `homepage_cards` 테이블 (PostgreSQL) 신규 추가
|
||||
|
||||
**web-portal 경로 변경 (`/` → `/portal`)**
|
||||
- Nginx Ingress `rewrite-target` 으로 `/portal` prefix strip 처리
|
||||
- `frontend/nginx.conf` subpath 대응 수정
|
||||
- `frontend/index.html` API 경로 `/api` → `/portal/api` 수정
|
||||
- `backend/main.py` `root_path="/portal"` 추가
|
||||
|
||||
#### 신규 k8s 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `k8s/00-hub.yaml` | hub 네임스페이스 + Deployment + Service |
|
||||
| `k8s/13-ingress-hub.yaml` | Hub Ingress (`cyanburu.com/`) |
|
||||
|
||||
#### DB 테이블 추가
|
||||
```sql
|
||||
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()
|
||||
);
|
||||
```
|
||||
|
||||
> ℹ️ 신규 배포 시 `main.py` startup 이벤트에서 자동 생성. 실패 시 psql에서 직접 실행.
|
||||
|
||||
#### 프로젝트 구조 변경
|
||||
```
|
||||
hub/ ← 신규 추가
|
||||
├── index.html ← 허브 홈페이지 (동적 카드 로딩)
|
||||
└── Dockerfile ← nginx:alpine 기반 정적 서빙
|
||||
|
||||
backend/
|
||||
└── main.py ← homepage_cards CRUD API 추가, root_path="/portal" 설정
|
||||
|
||||
frontend/
|
||||
├── index.html ← API 경로 /portal/api 로 수정, 🏠 홈 카드 관리 탭 추가
|
||||
└── nginx.conf ← /portal subpath 대응 수정
|
||||
|
||||
k8s/
|
||||
├── 00-hub.yaml ← 신규 추가
|
||||
├── 08-ingress.yaml ← /portal 경로로 변경
|
||||
└── 13-ingress-hub.yaml ← 신규 추가 (cyanburu.com/ → hub)
|
||||
```
|
||||
|
||||
#### 트러블슈팅 (5월)
|
||||
|
||||
**hub namespace not found**
|
||||
- Registry Secret 생성 전 네임스페이스가 없어서 발생
|
||||
- 해결: `kubectl create namespace hub` 먼저 실행 후 Secret 생성
|
||||
|
||||
**Ingress host+path 충돌 (BadRequest)**
|
||||
- 기존 web-portal-ingress가 `cyanburu.com /` 를 점유하고 있어서 hub-ingress 생성 불가
|
||||
- 해결: `08-ingress.yaml`을 `/portal` 경로로 먼저 apply 후 `13-ingress-hub.yaml` apply
|
||||
|
||||
**홈 카드 관리 탭 내용 미표시**
|
||||
- `page-admin-hub-cards` div가 `</main>` 밖에 위치해서 JS가 null 반환
|
||||
- 해결: sed 명령으로 div를 `</main>` 안으로 이동
|
||||
|
||||
**hub 홈페이지 카드 미반영**
|
||||
- `hub/index.html`에 동적 로딩 코드가 없는 구버전 파일이 배포됨
|
||||
- 해결: 동적 카드 로딩 버전으로 `hub/index.html` 교체 후 재빌드
|
||||
|
||||
133
backend/main.py
133
backend/main.py
@@ -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}
|
||||
@@ -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
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>
|
||||
61
k8s/00-hub.yaml
Executable file
61
k8s/00-hub.yaml
Executable 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=<패스워드>
|
||||
@@ -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
26
k8s/13-ingress-hub.yaml
Executable 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
|
||||
Reference in New Issue
Block a user