AI DEV portal 초기 커밋

Express+EJS+Postgres 사내 개발 포털.
- Keycloak OIDC(사번 로그인), GitHub 감성 UI
- 사이트 모음 + 프로젝트 공유/코멘트/스타
- DB: OpenEverest appdb portal schema (portal_app role)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0310700
2026-06-22 16:16:40 +09:00
commit 9eb3fd1b4c
18 changed files with 1919 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.project-env
.env
.env.*
!.project-env.example
npm-debug.log*
dist/
.DS_Store
HANDOFF.md

18
.project-env.example Normal file
View File

@@ -0,0 +1,18 @@
# AI DEV 포털 설정 예시. 실제 값은 .project-env (git 제외) 또는 Kubero Env 에.
# --- 포털 ---
PORTAL_BRAND=AI DEV
PORT=3000
# 로컬: http://localhost:3000 / 배포: https://portal.bokdev.in
BASE_URL=http://localhost:3000
# 세션 서명 키 (배포 시 임의의 긴 문자열로 교체)
SESSION_SECRET=change-me-to-a-long-random-string
# --- DB (OpenEverest appdb, portal schema 전용 role) ---
# host 는 클러스터 내부 OpenEverest pgbouncer (워크스페이스/Kubero 양쪽 도달).
DATABASE_URL=postgresql://portal_app:7e2950ce6ed0c5a1457ea5b5@postgresql-6ox-pgbouncer.everest.svc.cluster.local:5432/appdb?options=-c%20search_path%3Dportal
# --- Keycloak OIDC (realm bokdev, client portal) ---
OIDC_ISSUER=https://keycloak.bokdev.in/realms/bokdev
OIDC_CLIENT_ID=portal
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET

35
README.md Normal file
View File

@@ -0,0 +1,35 @@
# AI DEV — 사내 개발 포털
직원이 한 곳에서 **사내 개발 사이트로 이동**하고, **만든 프로젝트를 공유·코멘트**하는 포털.
GitHub 감성 UI. Keycloak SSO(사번 로그인). Node(Express+EJS) + Postgres.
> 브랜드명 `AI DEV` 는 `PORTAL_BRAND` 환경변수로 바꿀 수 있습니다.
## 기능
- **Keycloak OIDC 로그인** (realm `bokdev`, client `portal`, 사번 = preferred_username)
- **개발 사이트 모음** — 개발 핵심(Coder/Gitea/Kubero/Harbor) + AI·데이터(LiteLLM/Chat/OpenEverest/MinIO). 목록은 `src/sites.js`.
- **프로젝트 공유** — repo/앱 URL·설명·태그
- **코멘트 / 스타**
## 로컬 실행
```bash
cp .project-env.example .project-env # 값 채우기 (OIDC_CLIENT_SECRET 등)
set -a; . ./.project-env; set +a
npm install
npm run db:init # schema.sql 적용 (최초 1회)
npm run dev # http://localhost:3000
```
- 로컬에서 OIDC 콜백을 받으려면 Keycloak `portal` client redirect 에
`http://localhost:3000/auth/callback` 이 등록돼 있어야 합니다(이미 등록됨).
## 배포 (Kubero)
- NodeJS buildpack (`npm install``node index.js`). Dockerfile 불필요.
- Kubero Env: `BASE_URL=https://portal.bokdev.in`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`.
- 도메인: `portal.bokdev.in` (게이트웨이/라우트 별도 구성 필요) 또는 `<app>.apps.bokdev.in`.
- 배포 도메인이 정해지면 Keycloak `portal` client redirect 에 `https://<도메인>/auth/callback` 추가.
## 연결 정보
- **DB**: OpenEverest `postgresql-6ox``appdb`, schema `portal`, role `portal_app`.
- **Issuer**: `https://keycloak.bokdev.in/realms/bokdev`
자세한 작업 규약은 `CLAUDE.md`.

2
index.js Normal file
View File

@@ -0,0 +1,2 @@
// Kubero NodeJS buildpack 진입점 (run = node index.js). 서버 구현은 src/server.js.
import "./src/server.js";

1193
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "ai-dev-portal",
"version": "0.1.0",
"description": "AI DEV 사내 개발 포털 — Keycloak SSO + 사이트 모음 + 프로젝트 공유/코멘트",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node index.js",
"db:init": "node scripts/init-db.js"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"ejs": "^3.1.10",
"express": "^4.21.2",
"express-session": "^1.18.1",
"openid-client": "^5.7.0",
"pg": "^8.13.1"
}
}

135
public/style.css Normal file
View File

@@ -0,0 +1,135 @@
/* GitHub 감성 (Primer 팔레트 근사). 라이트 테마. */
:root {
--canvas: #ffffff;
--canvas-subtle: #f6f8fa;
--border: #d0d7de;
--border-muted: #d8dee4;
--fg: #1f2328;
--fg-muted: #656d76;
--accent: #0969da;
--accent-emphasis: #0969da;
--success: #1a7f37;
--btn-primary: #1f883d;
--btn-primary-hover: #1a7f37;
--header-bg: #24292f;
--star: #9a6700;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", Helvetica, Arial, sans-serif;
color: var(--fg);
background: var(--canvas-subtle);
font-size: 14px;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* 헤더 */
.header {
background: var(--header-bg);
color: #fff;
padding: 0 16px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
}
.header .brand { font-weight: 600; font-size: 16px; color: #fff; display: flex; align-items: center; gap: 8px; }
.header .brand:hover { text-decoration: none; }
.header .spacer { flex: 1; }
.header .who { color: #d1d9e0; font-size: 13px; }
.header a.navlink { color: #fff; font-size: 14px; }
/* 컨테이너 */
.container { max-width: 1012px; margin: 24px auto; padding: 0 16px; }
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; }
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
/* 카드 */
.box {
background: var(--canvas);
border: 1px solid var(--border);
border-radius: 6px;
}
.box-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
background: var(--canvas-subtle);
border-radius: 6px 6px 0 0;
}
.box-body { padding: 16px; }
.box-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border-muted);
display: flex;
align-items: center;
gap: 12px;
}
.box-row:last-child { border-bottom: 0; }
/* 사이트 그리드 */
.site-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
@media (max-width: 540px) { .site-grid { grid-template-columns: 1fr; } }
.site-card {
display: flex; gap: 12px; align-items: flex-start;
border: 1px solid var(--border); border-radius: 6px;
padding: 12px; background: var(--canvas);
}
.site-card:hover { border-color: var(--accent); text-decoration: none; }
.site-card .ico { font-size: 22px; line-height: 1; }
.site-card .name { font-weight: 600; color: var(--fg); }
.site-card .desc { color: var(--fg-muted); font-size: 12px; }
.group-title { font-size: 13px; font-weight: 600; color: var(--fg-muted); margin: 20px 0 8px; text-transform: uppercase; letter-spacing: .04em; }
/* 버튼 */
.btn {
display: inline-block; padding: 5px 16px; font-size: 14px; font-weight: 500;
border: 1px solid var(--border); border-radius: 6px; background: var(--canvas-subtle);
color: var(--fg); cursor: pointer;
}
.btn:hover { background: #f3f4f6; text-decoration: none; }
.btn-primary { background: var(--btn-primary); border-color: rgba(31,35,40,.15); color: #fff; }
.btn-primary:hover { background: var(--btn-primary-hover); color: #fff; }
.btn-sm { padding: 3px 12px; font-size: 12px; }
/* 폼 */
input[type=text], textarea, input[type=url] {
width: 100%; padding: 6px 12px; border: 1px solid var(--border);
border-radius: 6px; font-size: 14px; font-family: inherit;
}
textarea { min-height: 80px; resize: vertical; }
label { display: block; font-weight: 600; margin: 10px 0 4px; font-size: 13px; }
/* 프로젝트 리스트 */
.proj-item { padding: 16px; border-bottom: 1px solid var(--border-muted); }
.proj-item:last-child { border-bottom: 0; }
.proj-title { font-size: 16px; font-weight: 600; }
.proj-meta { color: var(--fg-muted); font-size: 12px; margin-top: 4px; }
.tag {
display: inline-block; background: #ddf4ff; color: var(--accent);
border-radius: 2em; padding: 0 8px; font-size: 12px; margin-right: 4px;
}
.counter { color: var(--fg-muted); font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
/* 아바타 */
.avatar {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent); color: #fff; display: inline-flex;
align-items: center; justify-content: center; font-size: 12px; font-weight: 600;
}
/* 코멘트 */
.comment { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; }
.comment-head { background: var(--canvas-subtle); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
.comment-body { padding: 12px; white-space: pre-wrap; }
/* 로그인 */
.login-wrap { max-width: 340px; margin: 80px auto; text-align: center; }
.login-card { padding: 32px 24px; }
.login-logo { font-size: 40px; }
.login-title { font-size: 24px; font-weight: 300; margin: 12px 0 24px; }
.alert { background: #ffebe9; border: 1px solid #ff818266; color: #cf222e; padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
.muted { color: var(--fg-muted); font-size: 12px; }

43
schema.sql Normal file
View File

@@ -0,0 +1,43 @@
-- AI DEV portal 스키마 (search_path=portal). 멱등.
-- 직원 식별자는 사번(Keycloak preferred_username). 표시 이름은 name.
CREATE TABLE IF NOT EXISTS users (
sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username)
name text NOT NULL,
email text,
avatar_seed text, -- 아바타 색/이니셜용
created_at timestamptz NOT NULL DEFAULT now(),
last_login timestamptz
);
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_sabun text NOT NULL REFERENCES users(sabun),
title text NOT NULL,
description text DEFAULT '',
repo_url text DEFAULT '', -- Gitea repo
app_url text DEFAULT '', -- 배포된 앱 (*.apps.bokdev.in)
tags text DEFAULT '', -- 콤마 구분
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC);
-- 코멘트
CREATE TABLE IF NOT EXISTS comments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
author_sabun text NOT NULL REFERENCES users(sabun),
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, created_at);
-- 스타(좋아요)
CREATE TABLE IF NOT EXISTS stars (
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
sabun text NOT NULL REFERENCES users(sabun),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project_id, sabun)
);

18
scripts/init-db.js Normal file
View File

@@ -0,0 +1,18 @@
// schema.sql 을 DATABASE_URL 대상에 적용 (멱등). 사용: npm run db:init
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import pg from "pg";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sql = fs.readFileSync(path.join(__dirname, "..", "schema.sql"), "utf8");
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL 미설정 — .project-env 를 로드했는지 확인하세요");
process.exit(1);
}
const client = new pg.Client({ connectionString: url });
await client.connect();
await client.query(sql);
await client.end();
console.log("schema.sql 적용 완료");

53
src/auth.js Normal file
View File

@@ -0,0 +1,53 @@
// Keycloak OIDC (Authorization Code). openid-client v5.
import { Issuer, generators } from "openid-client";
import { config } from "./config.js";
let client;
export async function initOidc() {
const issuer = await Issuer.discover(config.oidc.issuer);
client = new issuer.Client({
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
redirect_uris: [`${config.baseUrl}/auth/callback`],
response_types: ["code"],
});
return client;
}
// 로그인 시작 — state/nonce 를 세션에 저장하고 Keycloak 으로 리다이렉트
export function loginRedirect(req, res) {
const state = generators.state();
const nonce = generators.nonce();
req.session.oidc = { state, nonce };
const url = client.authorizationUrl({
scope: "openid profile email",
state,
nonce,
});
res.redirect(url);
}
// 콜백 처리 — 토큰 교환 후 사용자 클레임 반환
export async function handleCallback(req) {
const params = client.callbackParams(req);
const { state, nonce } = req.session.oidc || {};
const tokenSet = await client.callback(`${config.baseUrl}/auth/callback`, params, {
state,
nonce,
});
const claims = tokenSet.claims();
return {
sabun: claims.preferred_username, // 사번
name: claims.name || claims.given_name || claims.preferred_username,
email: claims.email,
};
}
// 로그인 필수 가드
export function requireAuth(req, res, next) {
if (req.session.user) return next();
// API 요청은 401, 페이지 요청은 로그인으로
if (req.path.startsWith("/api/")) return res.status(401).json({ error: "unauthorized" });
return res.redirect("/login");
}

18
src/config.js Normal file
View File

@@ -0,0 +1,18 @@
// 설정 — 비밀값은 환경변수에서만. 로컬은 .project-env, 배포는 Kubero Env.
export const config = {
port: parseInt(process.env.PORT || "3000", 10),
baseUrl: process.env.BASE_URL || "http://localhost:3000",
sessionSecret: process.env.SESSION_SECRET || "dev-only-insecure-secret",
databaseUrl: process.env.DATABASE_URL || "",
oidc: {
issuer: process.env.OIDC_ISSUER || "https://keycloak.bokdev.in/realms/bokdev",
clientId: process.env.OIDC_CLIENT_ID || "portal",
clientSecret: process.env.OIDC_CLIENT_SECRET || "",
// redirect 는 baseUrl + /auth/callback 로 구성
},
// 포털 표시 이름 (변경 가능)
brand: process.env.PORTAL_BRAND || "AI DEV",
};

78
src/db.js Normal file
View File

@@ -0,0 +1,78 @@
// Postgres 풀 (OpenEverest appdb, search_path=portal). DATABASE_URL 로 연결.
import pg from "pg";
import { config } from "./config.js";
export const pool = new pg.Pool({ connectionString: config.databaseUrl });
export const q = (text, params) => pool.query(text, params);
// 로그인 시 사용자 upsert
export async function upsertUser({ sabun, name, email }) {
await q(
`INSERT INTO users (sabun, name, email, avatar_seed, last_login)
VALUES ($1,$2,$3,$1, now())
ON CONFLICT (sabun) DO UPDATE
SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`,
[sabun, name, email || null]
);
}
// 프로젝트 목록 (스타 수 + 코멘트 수 + 소유자 이름 포함)
export async function listProjects() {
const { rows } = await q(
`SELECT p.*, u.name AS owner_name,
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count,
(SELECT count(*) FROM comments c WHERE c.project_id=p.id) AS comment_count
FROM projects p JOIN users u ON u.sabun=p.owner_sabun
ORDER BY p.created_at DESC`
);
return rows;
}
export async function getProject(id) {
const { rows } = await q(
`SELECT p.*, u.name AS owner_name,
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count
FROM projects p JOIN users u ON u.sabun=p.owner_sabun WHERE p.id=$1`,
[id]
);
return rows[0];
}
export async function listComments(projectId) {
const { rows } = await q(
`SELECT c.*, u.name AS author_name
FROM comments c JOIN users u ON u.sabun=c.author_sabun
WHERE c.project_id=$1 ORDER BY c.created_at ASC`,
[projectId]
);
return rows;
}
export async function addProject({ owner, title, description, repoUrl, appUrl, tags }) {
const { rows } = await q(
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[owner, title, description || "", repoUrl || "", appUrl || "", tags || ""]
);
return rows[0].id;
}
export async function addComment({ projectId, author, body }) {
await q(`INSERT INTO comments (project_id, author_sabun, body) VALUES ($1,$2,$3)`,
[projectId, author, body]);
}
export async function toggleStar({ projectId, sabun }) {
const { rowCount } = await q(`DELETE FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
if (rowCount === 0) {
await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]);
return true;
}
return false;
}
export async function isStarred({ projectId, sabun }) {
const { rowCount } = await q(`SELECT 1 FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
return rowCount > 0;
}

116
src/server.js Normal file
View File

@@ -0,0 +1,116 @@
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import { fileURLToPath } from "url";
import path from "path";
import { config } from "./config.js";
import { siteGroups } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth } from "./auth.js";
import * as db from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "..", "views"));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());
app.use("/public", express.static(path.join(__dirname, "..", "public")));
app.use(
session({
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", maxAge: 8 * 3600 * 1000 },
})
);
// 모든 뷰에 공통 노출
app.use((req, res, next) => {
res.locals.brand = config.brand;
res.locals.user = req.session.user || null;
next();
});
app.get("/healthz", (_req, res) => res.json({ ok: true }));
// --- 인증 ---
app.get("/login", (req, res) => {
if (req.session.user) return res.redirect("/");
res.render("login");
});
app.get("/auth/login", (req, res) => loginRedirect(req, res));
app.get("/auth/callback", async (req, res) => {
try {
const u = await handleCallback(req);
await db.upsertUser(u);
req.session.user = u;
res.redirect("/");
} catch (e) {
console.error("oidc callback error", e);
res.status(500).render("login", { error: "로그인 처리 중 오류가 발생했습니다." });
}
});
app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/login"));
});
// --- 대시보드 (로그인 필요) ---
app.get("/", requireAuth, async (req, res) => {
const projects = await db.listProjects();
res.render("dashboard", { siteGroups, projects });
});
// --- 프로젝트 ---
app.post("/projects", requireAuth, async (req, res) => {
const { title, description, repo_url, app_url, tags } = req.body;
if (!title || !title.trim()) return res.redirect("/");
await db.addProject({
owner: req.session.user.sabun,
title: title.trim(),
description,
repoUrl: repo_url,
appUrl: app_url,
tags,
});
res.redirect("/");
});
app.get("/projects/:id", requireAuth, async (req, res) => {
const project = await db.getProject(req.params.id);
if (!project) return res.status(404).send("not found");
const comments = await db.listComments(project.id);
const starred = await db.isStarred({ projectId: project.id, sabun: req.session.user.sabun });
res.render("project", { project, comments, starred });
});
app.post("/projects/:id/comments", requireAuth, async (req, res) => {
if (req.body.body && req.body.body.trim()) {
await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body: req.body.body.trim(),
});
}
res.redirect(`/projects/${req.params.id}`);
});
app.post("/projects/:id/star", requireAuth, async (req, res) => {
await db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun });
res.redirect(`/projects/${req.params.id}`);
});
// --- 부팅 ---
const start = async () => {
try {
await initOidc();
console.log("OIDC discovery 완료:", config.oidc.issuer);
} catch (e) {
console.error("OIDC 초기화 실패(로그인 비활성):", e.message);
}
app.listen(config.port, "0.0.0.0", () =>
console.log(`${config.brand} portal listening on :${config.port}`)
);
};
start();

22
src/sites.js Normal file
View File

@@ -0,0 +1,22 @@
// 포털에 모아둘 사이트 카탈로그. 카테고리: 개발 핵심 / AI·데이터.
// icon 은 이모지(추가 의존성 없이 GitHub 감성 카드에 표시).
export const siteGroups = [
{
group: "개발 핵심",
sites: [
{ name: "Coder", url: "https://coder.bokdev.in", icon: "💻", desc: "웹 개발 워크스페이스 (VS Code + AI CLI)" },
{ name: "Gitea", url: "https://gitea.bokdev.in", icon: "🍵", desc: "사내 Git 저장소" },
{ name: "Kubero", url: "https://kubero.bokdev.in", icon: "🚀", desc: "앱 배포 PaaS (*.apps.bokdev.in)" },
{ name: "Harbor", url: "https://harbor.bokdev.in", icon: "📦", desc: "컨테이너 이미지 레지스트리" },
],
},
{
group: "AI · 데이터",
sites: [
{ name: "LiteLLM", url: "https://litellm.bokdev.in", icon: "🤖", desc: "AI 게이트웨이 (모델 키)" },
{ name: "Chat", url: "https://chat.bokdev.in", icon: "💬", desc: "사내 챗 (Ollama)" },
{ name: "OpenEverest", url: "https://openeverest.bokdev.in",icon: "🗄️", desc: "DBaaS (DB 프로비저닝)" },
{ name: "MinIO", url: "https://minioc.bokdev.in", icon: "🪣", desc: "오브젝트 스토리지 콘솔" },
],
},
];

17
views/_header.ejs Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= brand %></title>
<link rel="stylesheet" href="/public/style.css" />
</head>
<body>
<div class="header">
<a class="brand" href="/"><span>🧠</span><%= brand %></a>
<div class="spacer"></div>
<% if (user) { %>
<span class="who"><%= user.name %> (<%= user.sabun %>)</span>
<a class="navlink" href="/logout">로그아웃</a>
<% } %>
</div>

79
views/dashboard.ejs Normal file
View File

@@ -0,0 +1,79 @@
<%- include("_header") %>
<div class="container">
<div class="layout">
<!-- 메인: 사이트 모음 + 공유 프로젝트 -->
<div>
<div class="box">
<div class="box-header">🔗 개발 사이트 바로가기</div>
<div class="box-body">
<% siteGroups.forEach(function(g){ %>
<div class="group-title"><%= g.group %></div>
<div class="site-grid">
<% g.sites.forEach(function(s){ %>
<a class="site-card" href="<%= s.url %>" target="_blank" rel="noopener">
<span class="ico"><%= s.icon %></span>
<span>
<span class="name"><%= s.name %></span><br/>
<span class="desc"><%= s.desc %></span>
</span>
</a>
<% }); %>
</div>
<% }); %>
</div>
</div>
<div class="box" style="margin-top:24px">
<div class="box-header">📂 공유 프로젝트</div>
<% if (projects.length === 0) { %>
<div class="box-body muted">아직 공유된 프로젝트가 없습니다. 오른쪽에서 추가해 보세요.</div>
<% } else { %>
<% projects.forEach(function(p){ %>
<div class="proj-item">
<a class="proj-title" href="/projects/<%= p.id %>"><%= p.title %></a>
<div class="proj-meta">
<%= p.owner_name %> · <%= new Date(p.updated_at).toLocaleDateString("ko-KR") %>
&nbsp; <span class="counter">⭐ <%= p.star_count %></span>
&nbsp; <span class="counter">💬 <%= p.comment_count %></span>
</div>
<% if (p.description) { %><div class="muted" style="margin-top:6px"><%= p.description %></div><% } %>
<% if (p.tags) { %>
<div style="margin-top:8px">
<% p.tags.split(",").map(t=>t.trim()).filter(Boolean).forEach(function(t){ %>
<span class="tag"><%= t %></span>
<% }); %>
</div>
<% } %>
</div>
<% }); %>
<% } %>
</div>
</div>
<!-- 사이드바: 프로젝트 공유 폼 -->
<div>
<div class="box">
<div class="box-header"> 프로젝트 공유</div>
<div class="box-body">
<form method="post" action="/projects">
<label>제목 *</label>
<input type="text" name="title" required placeholder="내 앱 이름" />
<label>설명</label>
<textarea name="description" placeholder="무엇을 만들었나요?"></textarea>
<label>Repo URL</label>
<input type="url" name="repo_url" placeholder="https://gitea.bokdev.in/..." />
<label>앱 URL</label>
<input type="url" name="app_url" placeholder="https://....apps.bokdev.in" />
<label>태그 (콤마 구분)</label>
<input type="text" name="tags" placeholder="node, ai" />
<div style="margin-top:16px">
<button class="btn btn-primary" style="width:100%" type="submit">공유하기</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

14
views/login.ejs Normal file
View File

@@ -0,0 +1,14 @@
<%- include("_header") %>
<div class="login-wrap">
<div class="box login-card">
<div class="login-logo">🧠</div>
<div class="login-title"><%= brand %></div>
<% if (typeof error !== "undefined" && error) { %>
<div class="alert"><%= error %></div>
<% } %>
<a class="btn btn-primary" style="width:100%" href="/auth/login">Keycloak 으로 로그인</a>
<p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p>
</div>
</div>
</body>
</html>

46
views/project.ejs Normal file
View File

@@ -0,0 +1,46 @@
<%- include("_header") %>
<div class="container" style="max-width:760px">
<a href="/" class="muted">← 대시보드</a>
<div class="box" style="margin-top:12px">
<div class="box-body">
<h2 style="margin:0 0 4px"><%= project.title %></h2>
<div class="proj-meta"><%= project.owner_name %> 님이 공유 · <%= new Date(project.created_at).toLocaleDateString("ko-KR") %></div>
<% if (project.description) { %><p style="margin-top:12px; white-space:pre-wrap"><%= project.description %></p><% } %>
<div style="margin-top:12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap">
<% if (project.repo_url) { %><a class="btn btn-sm" href="<%= project.repo_url %>" target="_blank">🍵 Repo</a><% } %>
<% if (project.app_url) { %><a class="btn btn-sm" href="<%= project.app_url %>" target="_blank">🚀 앱 열기</a><% } %>
<form method="post" action="/projects/<%= project.id %>/star" style="display:inline">
<button class="btn btn-sm" type="submit"><%= starred ? "★ 스타 취소" : "☆ 스타" %> (<%= project.star_count %>)</button>
</form>
</div>
<% if (project.tags) { %>
<div style="margin-top:12px">
<% project.tags.split(",").map(t=>t.trim()).filter(Boolean).forEach(function(t){ %>
<span class="tag"><%= t %></span>
<% }); %>
</div>
<% } %>
</div>
</div>
<h3 style="margin:24px 0 12px">💬 코멘트 (<%= comments.length %>)</h3>
<% comments.forEach(function(c){ %>
<div class="comment">
<div class="comment-head"><strong><%= c.author_name %></strong> · <%= new Date(c.created_at).toLocaleString("ko-KR") %></div>
<div class="comment-body"><%= c.body %></div>
</div>
<% }); %>
<div class="box" style="margin-top:16px">
<div class="box-body">
<form method="post" action="/projects/<%= project.id %>/comments">
<textarea name="body" placeholder="코멘트를 남겨보세요" required></textarea>
<div style="margin-top:8px; text-align:right">
<button class="btn btn-primary" type="submit">코멘트 등록</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>