Compare commits

...

11 Commits

Author SHA1 Message Date
Hyemin Lee
e174fccef2 docs: PRD·CLAUDE 보강과 PLANS 마무리 (Phase 6)
- PRD: 반응 2종(heart/star)·공개여부·lang·대댓글·라우트·다크모드/검색/토스트·role 확정 반영
- CLAUDE: 구조 맵(app.js·helpers.js·_project_detail·hearts)·role 인증 모델 갱신
- 부팅 스모크 테스트(/healthz) 통과, 실 DB 기능 점검만 잔여

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:31:47 +09:00
Hyemin Lee
e6928a8d61 docs: PLANS 진행상황 갱신 (Phase 4·5 완료 체크)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:27:09 +09:00
Hyemin Lee
42542a0912 feat(ui): 상세/공유 모달과 단독 상세·로그인 페이지
- _project_detail.ejs: 모달/단독 공용 상세(헤더·반응·바로가기·댓글 스레드·입력)
- dashboard: 상세 모달 오버레이 + 프로젝트별 template 저장소 + 새 프로젝트 등록 모달
- project.ejs(단독)·login.ejs 새 디자인으로 재작성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:26:50 +09:00
Hyemin Lee
ace46d2fb8 feat(server): 라우트 전면 개편 — 반응·가시성·댓글/대댓글·삭제
- GET / : 내 프로젝트/피드 분리, role 도구 필터, 댓글 일괄 조회(commentsByProject)
- 반응(heart/star) AJAX JSON, visibility/delete(본인), 댓글/대댓글(parent_id)
- 댓글 LGTM/🚀 egg 신호 + return_to 복귀, isAjax/safeBack 헬퍼
- res.locals 로 icon/identicon/langColor 노출
- db: listCommentsByProjects 추가, _header: body 시작 신호(toast/egg/open)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:26:37 +09:00
Hyemin Lee
6a9cf46db8 docs: 디자인·기능 개편 작업 계획 체크리스트(PLANS.md) 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:13:00 +09:00
Hyemin Lee
6549cc5870 feat(dashboard): 도구·내 프로젝트·공유 피드 3개 섹션 재구성
app.js DOM 계약(data-* 속성)에 맞춘 카드/배지/반응 버튼 렌더.
검색·정렬·필터·모달 오픈을 위한 data-search/created/pop/starred/vis 부여.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:12:52 +09:00
Hyemin Lee
5ab1e9f938 feat(ui): 디자인 시스템 전면 개편 — 헤더·CSS·아이콘 헬퍼·클라이언트 JS
- style.css: CSS 변수(라이트/다크) 단일 스타일시트로 재작성
- _header.ejs: 스티키 다크 헤더, 실제 role 배지, FOUC 방지 테마 초기화
- helpers.js: icon(Octicon)·identicon(SVG 아바타)·langColor·langFromTags
- app.js: 테마·메뉴·모달·검색/정렬/필터·반응 fetch·댓글 답글·이스터에그

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:12:31 +09:00
Hyemin Lee
8be3a532cb feat(auth): Keycloak role 기반 관리자 판별과 도구 카탈로그 개편
- auth.js: realm/client role 추출 → roles·isAdmin 세션 저장
- config.js: ADMIN_ROLE + 도구 URL 8종 환경변수화
- sites.js: 개발4 + 인프라4(adminOnly) 재구성, toolsForUser(isAdmin)
- .project-env.example: ADMIN_ROLE·도구 URL 항목 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:12:03 +09:00
Hyemin Lee
87289502c8 feat(db): 공개여부·언어·대댓글·좋아요(hearts) 스키마와 쿼리 개편
- projects: is_public(공개/비공개)·lang 컬럼 추가(멱등)
- comments: parent_id 추가 — 대댓글 1뎁스(앱에서 강제)
- hearts(❤️ 좋아요) 테이블 신설, stars( 즐겨찾기)는 유지
- db.js: 카운트+viewer 반응여부 공통 SELECT, 공개+본인비공개만 노출,
  toggleHeart/toggleStar({on,count}), setVisibility/deleteProject/deleteComment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:11:53 +09:00
Hyemin Lee
caa56f72dc chore: Claude Code 설정·디자인 레퍼런스·포털 로고 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:11:41 +09:00
Hyemin Lee
d889a8516f docs: 기획서(PRD)와 프로젝트 작업 지침(CLAUDE.md) 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:11:32 +09:00
23 changed files with 4498 additions and 336 deletions

View File

@@ -0,0 +1,26 @@
// PostToolUse 훅: Edit/Write 직후 편집된 .js 파일을 `node --check` 로 문법 검증한다.
// 비-개발 사용자가 Claude 의 편집 중 깨진 JS 를 바로 발견·수정할 수 있도록 돕는다.
//
// 입력: stdin 으로 훅 페이로드 JSON (tool_input.file_path 사용).
// 종료코드: 0 = 통과/대상 아님, 2 = 문법 오류(stderr 가 Claude 에게 피드백됨).
import { execFileSync } from "node:child_process";
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
let file;
try {
file = JSON.parse(raw || "{}")?.tool_input?.file_path;
} catch {
process.exit(0); // 페이로드 파싱 실패는 조용히 통과
}
if (!file || !file.endsWith(".js")) process.exit(0);
try {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
} catch (e) {
const msg = (e.stderr || e.stdout || e.message || "").toString();
console.error(`✗ JS 문법 오류 (${file}):\n${msg}`);
process.exit(2);
}
process.exit(0);
});

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-js.mjs\""
}
]
}
]
}
}

View File

@@ -20,3 +20,17 @@ DATABASE_URL=postgresql://portal_app:7e2950ce6ed0c5a1457ea5b5@postgresql-6ox-pgb
OIDC_ISSUER=https://keycloak.bokdev.in/realms/bokdev OIDC_ISSUER=https://keycloak.bokdev.in/realms/bokdev
OIDC_CLIENT_ID=portal OIDC_CLIENT_ID=portal
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
# --- 권한 ---
# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
ADMIN_ROLE=admin
# --- 도구 URL (기본값 있음 · 도메인/폐쇄망 대응으로 덮어쓰기) ---
# CODER_URL=https://coder.bokdev.in/api/v2/users/oidc/callback
# GITEA_URL=https://gitea.bokdev.in/user/oauth2/keycloak
# KUBERO_URL=https://kubero.bokdev.in
# HARBOR_URL=https://harbor.bokdev.in
# OPENEVEREST_URL=https://openeverest.bokdev.in
# MINIO_URL=https://minioc.bokdev.in
# KEYCLOAK_URL=https://keycloak.bokdev.in/admin
# LITELLM_URL=https://litellm.bokdev.in

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이트로 이동**하고, **만든 프로젝트를
공유·코멘트**하는 곳. Keycloak SSO(사번 로그인) + Node(Express+EJS) + Postgres.
## 이 저장소를 다룰 때 (먼저 읽기)
- **사용자는 개발 전문가가 아닌 사내 IT 기획자/직원**이다. 변경은 **단순하게** 유지한다.
- 빌드 단계·프런트엔드 프레임워크(React 등)·번들러 도입 **금지**. 서버 렌더링(EJS) + 단일 CSS 유지.
- 새 npm 의존성은 꼭 필요할 때만, 이유와 함께 추가한다.
- **UI 문구와 코드 주석은 한국어**로 쓴다.
- **비밀값은 코드에 넣지 않는다.** 환경변수(`.project-env` 로컬 / Kubero Env 배포)로만 다룬다.
- 할 일·작업 단위는 [`PLANS.md`](./PLANS.md), 디자인 방향은 [`design-refs/`](./design-refs/) 참고.
## 스택
- **Node 22 ESM** (`"type": "module"`) — import 경로에 `.js` 확장자 **필수**.
- **Express 4 + EJS** — 서버 사이드 렌더링, 빌드 없음.
- **Postgres** (`pg`) — OpenEverest `appdb`, schema `portal`.
- **Keycloak OIDC** (`openid-client` v5) — Authorization Code.
- 정적 CSS 한 장: `public/style.css`(라이트/다크 CSS 변수). 가벼운 바닐라 JS: `public/app.js`. 로고: `public/logo.svg`.
- 프레임워크/번들러 없이 **점진적 향상**: 폼 POST 기본 동작 + JS 있으면 fetch 로 즉시 반영.
## 명령
- `npm run dev``node --watch`, http://localhost:3000
- `npm start` — 프로덕션 (`node index.js`)
- `npm run db:init``schema.sql` 적용 (멱등, 최초 1회)
- 로컬 환경변수 로드: `cp .project-env.example .project-env` 로 값을 채운 뒤
`set -a; . ./.project-env; set +a`
## 구조 맵
- `index.js``src/server.js` 진입 (Kubero buildpack run = `node index.js`)
- `src/server.js` — Express 앱, 라우트, 부팅
- `src/config.js` — 환경변수 설정
- `src/auth.js` — Keycloak OIDC (로그인 리다이렉트 / 콜백 / `requireAuth` 가드)
- `src/db.js` — Postgres 풀 + 쿼리 함수 (`hearts`❤️/`stars`⭐/`comments`/`projects`)
- `src/sites.js` — 도구 카탈로그(개발/인프라) + `toolsForUser(isAdmin)` role 필터
- `src/helpers.js` — 뷰 헬퍼: `icon`(Octicon SVG)·`identicon`(아바타 SVG)·`langColor`·`langFromTags` (res.locals 노출)
- `views/``_header`, `_project_detail`(모달/단독 공용 상세), `dashboard`, `login`, `project` (EJS)
- `public/``style.css`(CSS 변수 라이트/다크 단일 파일), `app.js`(테마·모달·검색/정렬·반응 fetch·이스터에그), `logo.svg`
- `schema.sql` — DB 스키마(멱등), `scripts/init-db.js` 가 적용
- `deploy/portal-route.yaml` — Gateway HTTPRoute (portal.bokdev.in)
- `design-refs/` — 디자인 레퍼런스(.dc.html). 화면 변경 시 시각 기준.
- `.claude/` — Claude Code 훅·설정
## 인증 모델
- **사번 = Keycloak `preferred_username`.** 세션에 `req.session.user = { sabun, name, email, roles, isAdmin }`.
- **관리자 = `realm_access.roles``ADMIN_ROLE`(기본 `admin`) 포함.** 인프라 도구(OpenEverest/MinIO/
Keycloak/LiteLLM)는 관리자에게만 렌더(`sites.js``adminOnly` + `toolsForUser`).
- 미인증으로 보호 페이지 진입 시 중간 화면 없이 곧장 Keycloak 로그인으로 리다이렉트
(`requireAuth``loginRedirect`). OIDC discovery 실패 시에만 `login.ejs` 폴백.
- ⚠️ **알려진 문제**: `/logout` 은 로컬 세션만 파기 → Keycloak SSO 세션이 남아 재진입 시 자동
재로그인된다(= 로그아웃 안 됨). 세션이 인메모리라 재시작/다중 replica 시 로그인이 풀린다.
자세한 내용과 해결 작업은 [`PLANS.md`](./PLANS.md) 참고.
## 배포
- **Kubero NodeJS buildpack** (`npm install``node index.js`). 평소 Dockerfile 불필요.
- `Dockerfile` 은 Kubero buildpack 의 harbor push 실패를 우회하는 **수동 push 용**.
- 도메인 `portal.bokdev.in` (`deploy/portal-route.yaml`). 도메인 추가 시 Keycloak `portal`
client redirect 에 `https://<도메인>/auth/callback` 등록 필요.
- Kubero Env: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_ROLE`, (선택) 도구 `*_URL`.
## 컨벤션
- 라우트는 `src/server.js` 에, DB 접근은 `src/db.js` 의 함수로만 (인라인 SQL 산재 금지).
- 새 화면을 추가할 때는 `views/_header` 를 include 하고 기존 `.box` / `.btn` / `.tag`
CSS 클래스를 재사용한다. 새 스타일은 `public/style.css` 한 곳에.
- DB 스키마 변경은 `schema.sql` 에 멱등(`IF NOT EXISTS`)으로 추가하고 `npm run db:init` 재실행.
- 브랜드명은 하드코딩하지 말고 `PORTAL_BRAND`(= `res.locals.brand`)를 사용한다.

89
PLANS.md Normal file
View File

@@ -0,0 +1,89 @@
# PLANS.md — 디자인·기능 전격 개편
> 목표: `design-refs/AI DEV 포털.dc.html` 의 디자인/기능을 **거의 그대로** 살려서
> 현재 포털을 개편한다. 단, 제약은 유지 — **빌드 없음 · React 없음 · 서버 렌더링(EJS) +
> 단일 CSS(`public/style.css`) + 가벼운 바닐라 JS(`public/app.js`)**.
>
> 기준: 디자인이 기획(PRD)보다 앞섰으므로 **디자인을 정답으로** 본다. PRD에 빠진 항목은
> PRD에 보강한다(Phase 6).
## 확정된 설계 결정 (사용자 합의)
- **반응 2종 유지**: ❤️ 좋아요(heart) + ⭐ 즐겨찾기(star) 둘 다. 인기순 정렬·즐겨찾기 필터 포함.
- **부가 요소 전부 유지**: 다크모드 토글, 토스트, 헤더 검색, 이스터에그(로고 흔들기·👍🚀✨·LGTM 스탬프).
- **공개/비공개(visibility)** 도입: 내 프로젝트는 공개/비공개 전환 가능, 비공개는 피드 미노출.
- **관리자 판별은 실제 Keycloak role** 로. (디자인의 "관리자 보기" 데모 토글은 제거하고 role 기반으로 대체)
- **상세는 모달** + 비-JS 폴백용 `/projects/:id` 단독 페이지도 유지.
- **반응/댓글은 점진적 향상(progressive enhancement)**: JS 없으면 폼 POST→리다이렉트, JS 있으면 fetch 로 즉시 반영.
- 폐쇄망: 폰트/아이콘은 외부 CDN 의존 최소화(시스템 폰트 폴백 + 인라인 SVG/이모지).
---
## Phase 0 — DB 스키마 & db.js ✅ (db:init 실행만 남음)
- [x] `schema.sql`: `projects``is_public boolean NOT NULL DEFAULT true`, `lang text DEFAULT ''` 컬럼 추가(멱등 `ADD COLUMN IF NOT EXISTS`).
- [x] `schema.sql`: `comments``parent_id bigint REFERENCES comments(id) ON DELETE CASCADE` 추가(대댓글 1뎁스).
- [x] `schema.sql`: ❤️ 좋아요용 `hearts(project_id, sabun, created_at, PK)` 테이블 추가. (기존 `stars` = ⭐ 즐겨찾기 유지)
- [x] `db.js`: `listProjects(viewer)` — heart_count·star_count·comment_count·owner_name·is_public·lang + viewer hearted/starred. 공개 + 본인 비공개만 반환(남의 비공개 미노출).
- [x] `db.js`: `getProject(id, viewer)` — 단일 조회 + 카운트 + viewer 반응 여부.
- [x] `db.js`: `listComments(projectId)` — parent_id 포함, 작성자 이름.
- [x] `db.js`: `addProject({owner,title,description,repoUrl,appUrl,tags,isPublic,lang})`.
- [x] `db.js`: `addComment({projectId,author,body,parentId})` — 부모가 같은 프로젝트의 최상위일 때만(1뎁스 강제), 새 행 반환.
- [x] `db.js`: `deleteComment({id, sabun})` — 본인만, 자식 댓글까지 cascade.
- [x] `db.js`: `toggleHeart`(❤️) / `toggleStar`(⭐) — `{on,count}` 반환(AJAX 즉시 반영용).
- [x] `db.js`: `setVisibility({id, sabun, isPublic})` — 본인 프로젝트만.
- [x] `db.js`: `deleteProject({id, sabun})` — 본인만.
- [ ] **`npm run db:init` 재실행해 적용** — 로컬에 `.project-env`(DATABASE_URL)가 없어 미실행. 실제 DB 환경에서 사용자가 실행 필요.
## Phase 1 — 설정 · 권한 · 사이트 카탈로그 ✅
- [x] `auth.js`: 콜백 claims 에서 role 추출(`realm_access.roles` + 클라이언트 role), `roles` + `isAdmin` 반환 → 세션 저장.
- [x] `config.js`: 도구 URL 8종 환경변수화(기본값 유지). 관리자 role 이름(`ADMIN_ROLE`, 기본 `admin`) 추가.
- [x] `sites.js`: 디자인 도구 목록으로 재구성 — 개발(Coder·Gitea·Kubero·Harbor) + 인프라(OpenEverest·MinIO·Keycloak·LiteLLM, `adminOnly:true`). SSO 플래그·이모지 아이콘·`toolsForUser(isAdmin)` 헬퍼.
- [x] `.project-env.example`: `ADMIN_ROLE` + 도구 URL 항목 추가.
- 참고: `server.js` 는 아직 `siteGroups`(구 export)를 import → **Phase 4 에서 재작성 전까지 부팅 불가** (의도된 중간 상태).
## Phase 2 — 공통 레이아웃 · CSS · 클라이언트 JS ✅
- [x] `public/style.css`: CSS 변수 시스템(라이트+다크 `html[data-aidev-theme="dark"]`) 전면 도입. 헤더/도구 그리드/내 프로젝트 패널/피드 그리드/카드/배지/버튼/모달/공유모달/댓글 스레드/토스트/플라이어/푸터/로그인/단독페이지/키프레임/반응형 — **단일 파일**. 반응 아이콘 채움↔외곽선 토글 규칙 포함.
- [x] `views/_header.ejs`: 스티키 다크 헤더(로고+이스터에그 훅, 검색창, 테마 토글, 관리자 배지, 사용자 메뉴 드롭다운+로그아웃). 데모 관리자 토글 제거→실제 role. FOUC 방지 테마 초기화 인라인 스크립트.
- [x] 아이콘: `views/_icons.ejs` 대신 **`src/helpers.js`** 에 `icon(name,{size,fill})` 헬퍼로 구현(Octicon 22종). 더불어 `identicon(seed,size)`(서버 SVG 아바타)·`langColor`·`langFromTags` 도 제공 → Phase 4 에서 res.locals 로 노출.
- [x] `public/app.js`(바닐라): 테마 토글·localStorage, 사용자 메뉴, 상세 모달 열기/닫기(Esc·오버레이), 검색·정렬(최신/인기)·즐겨찾기 필터(피드, 클라이언트), 내 프로젝트 필터(전체/공개/비공개), 댓글 답글 타깃·Enter 등록·삭제 확인, 공유 모달·공개범위 토글, 반응 fetch 즉시 반영, 토스트, 이스터에그(로고 흔들기·👍🚀✨·콘솔 아트), body 데이터속성 시작 신호(`data-toast`/`data-egg`/`data-open-modal`).
- 참고: 대시보드/모달 마크업은 app.js 의 **DOM 계약(data-* 속성)** 에 맞춰 Phase 3/5 에서 렌더.
## Phase 3 — 대시보드(`/`) ✅
- [x] `views/dashboard.ejs` ① 개발 도구 섹션: role 필터된 카드 그리드(a target=_blank), SSO 배지, 관리자 전용 카드 스타일(보라) + "관리자 도구 표시 중" 배지.
- [x] ② 내 프로젝트 섹션: 좌측 액센트 바 패널, 전체/공개/비공개 필터 탭+카운트, "새 프로젝트" 버튼, 카드(가시성 배지·언어 점·❤️/⭐/💬 카운트·공개↔비공개 폼 토글), 새 프로젝트 추가 카드, 빈 상태.
- [x] ③ 공유 피드 섹션: 2열 카드 그리드, 인라인 ❤️/⭐ 반응 버튼(채움/외곽선 토글), 작성자 아바타·날짜, 바로가기 링크, 최신/인기 탭 + 즐겨찾기 필터, 검색 빈 상태 요소.
- [x] 검색/정렬/필터용 data-* 속성(`data-search`/`data-created`/`data-pop`/`data-starred`/`data-vis`) 부여 → `app.js` 가 클라이언트 처리.
- [x] 모의 데이터로 EJS 렌더 검증 통과(헤더 include 포함). 모달/공유모달/상세저장소는 Phase 5.
## Phase 4 — 라우트(`server.js`) ✅
- [x] 공통: res.locals 에 `icon`/`identicon`/`langColor` 노출, `isAjax`/`safeBack` 헬퍼.
- [x] `GET /``listProjects(me)` → 내 프로젝트/피드 분리, 도구 role 필터. `?open`/`?toast`/`?egg` → body 신호.
- [x] `POST /projects` — 태그 정리·lang 추론·visibility 반영 후 toast 리다이렉트.
- [x] `POST /projects/:id/heart` · `/star` — 토글. AJAX 면 JSON `{on,count}`, 아니면 Referer 복귀.
- [x] `POST /projects/:id/visibility` — 공개/비공개 전환(본인).
- [x] `POST /projects/:id/delete` — 본인 프로젝트 삭제.
- [x] `POST /projects/:id/comments` — 댓글/대댓글(`parent_id`), LGTM/🚀 egg 신호, `return_to` 복귀.
- [x] `POST /comments/:id/delete` — 본인 댓글 삭제.
- [x] `GET /projects/:id` — 단독 페이지(비공개는 본인만). `_header` body 신호용 typeof 가드 추가.
- [x] 크로스 모듈 import/export 해석 검증 통과(서버 부팅 가능 상태 복구).
## Phase 5 — 상세 모달 · 공유 모달 · 단독 페이지 ✅
- [x] `views/_project_detail.ejs` 신설 — 헤더(제목·가시성·작성자·날짜·언어)·태그·설명·❤️/⭐/바로가기/저장소·소유자 삭제·댓글 스레드(대댓글 들여쓰기·LGTM 스탬프·답글/삭제)·댓글 입력(Enter 등록). 모달/단독 페이지 공용(`modal` 플래그).
- [x] 대시보드: `#detailModal` 오버레이 + `#detailStore`(프로젝트별 `<template>`) + 공유 모달(제목*·설명·태그·저장소/배포 URL·공개범위 토글+안내).
- [x] 서버: `listCommentsByProjects` 로 댓글 일괄 조회 → `commentsByProject` 전달. `allProjects` 로 저장소 렌더.
- [x] `app.js` 가 template 내용을 `.modal` 에 주입해 모달 구성(이미 Phase 2 에서 구현).
- [x] `views/project.ejs`(단독) · `views/login.ejs` 새 디자인으로 재작성.
- [x] 세 뷰 모의 렌더 검증 통과(저장소 3개·공유모달·body 신호·백링크·알림).
## Phase 6 — 문서 · 마무리 · 점검 ✅ (실DB 점검만 사용자 몫)
- [x] `ai-dev-portal-prd.md` 보강: 반응 2종(heart/star), 공개/비공개, lang, 댓글 parent_id, 추가 라우트(heart/star/visibility/delete/comments-delete), 다크모드/검색/토스트/이스터에그, role 확정.
- [x] `CLAUDE.md` 구조 맵 갱신(`public/app.js`, `src/helpers.js`, `_project_detail.ejs`, hearts 테이블, role 인증 모델, design-refs).
- [x] Pretendard 폰트: 시스템 폰트 폴백 확정(CSS font-family + PRD 명시). (선택) woff2 로컬 호스팅은 추후.
- [x] 부팅 스모크 테스트: 더미 OIDC 로 기동 → `/healthz` `{ok:true}` 응답 확인(OIDC 실패는 우아하게 폴백).
- [x] 권한 규칙 점검(코드): `requireAuth`(미인증→Keycloak), `toolsForUser`(관리자 도구 숨김), `listProjects`(남의 비공개 비노출), `getProject`(비공개 본인만).
- [ ] **실 DB 기능 점검(사용자 몫)**: `.project-env` 채우고 `npm run db:init``npm run dev`
로그인/도구/내프로젝트/피드/모달/공유/❤️·⭐/댓글·대댓글/삭제/테마/검색 수동 확인.
---
### 진행 메모
- (작업하며 결정·이슈를 여기에 누적)

255
ai-dev-portal-prd.md Normal file
View File

@@ -0,0 +1,255 @@
# AI DEV 포털 — 기획서 (PRD)
> 사내 개발의 시작점. 한 곳에서 **개발 도구로 이동**하고, **만든 프로젝트를 공유·소통**하는 경량 포털.
> Backstage를 걷어내고 직접 만든 가벼운 사이트로 노선 변경.
> Stack: Node(Express + EJS) + Postgres + Keycloak SSO. Kubero로 배포.
---
## 1. 한 줄 정의
직원이 사번 계정 **한 번의 로그인(SSO)** 으로 사내 개발 도구(Coder/Gitea/Kubero 등)에 이동하고,
자기가 만든 프로젝트를 동료에게 **공유 → 좋아요 → 댓글**로 소통하는 내부 개발 포털.
---
## 2. 왜 Backstage를 버리는가
| 항목 | Backstage | 직접 만든 경량 포털 |
|---|---|---|
| 무게 | 무겁고 과함 (플러그인/카탈로그/엔티티 모델) | 필요한 화면 2~3개 |
| 학습/운영 비용 | 높음 | 낮음 (Express+EJS) |
| 커스터마이징 | 프레임워크에 종속 | 100% 자유 |
| 우리가 실제 쓰는 기능 | 링크 모음 + 공유 피드 | 정확히 그 두 가지 |
**우리가 원하는 건 "예쁜 링크 허브 + 가벼운 소셜 피드"**. Backstage는 오버킬.
---
## 3. 화면 구조 (3 페이지)
### 3.1 로그인 (`/login`)
- 미인증 진입 시 **중간 화면 없이 곧장 Keycloak 로그인 폼**으로 리다이렉트 (이미 구현됨).
- OIDC discovery 실패 시에만 안내 페이지 폴백.
### 3.2 대시보드 (`/`) — 메인
로그인 후 보게 되는 단일 페이지. 위→아래로 **3개 영역**.
```
┌─────────────────────────────────────────────┐
│ [헤더] AI DEV ····· 사번님 ▾ (로그아웃) │
├─────────────────────────────────────────────┤
│ ① 개발 도구 (Tools) │
│ - 모두에게: Coder / Gitea / Kubero / Harbor│
│ - 관리자만: OpenEverest / MinIO / │
│ Keycloak / LiteLLM │
│ 클릭 시 SSO 자동로그인 URL로 새 탭 │
├─────────────────────────────────────────────┤
│ ② 내 프로젝트 (My Projects) │
│ - 내가 공유한 것만. 공유 피드와 시각적으로 │
│ 확연히 구분 (배경/테두리/섹션 헤더 다름) │
│ - [+ 새 프로젝트 공유] 버튼 │
├─────────────────────────────────────────────┤
│ ③ 공유 프로젝트 피드 (Shared Projects) │
│ - 나 + 남이 공유한 전체 (최신순/인기순) │
│ - 카드: 제목·설명·태그·작성자·❤️ 수·💬 수 │
│ - 카드 클릭 → 프로젝트 상세 (3.3) │
│ - 카드의 "바로가기" → 배포 URL 새 탭 │
└─────────────────────────────────────────────┘
```
**핵심 UX 결정: ②와 ③의 구분**
- ②는 "내 작업 공간"의 느낌 — 좌측 액센트 바, 옅은 배경 톤, "내 프로젝트" 섹션 라벨.
- ③은 "공개 피드"의 느낌 — 카드 그리드, 작성자 아바타 노출, 좋아요/댓글 카운트 전면.
- 같은 카드 컴포넌트를 쓰되 **컨테이너 스타일과 배지로 분리**. (관리자가 아니어도 둘은 항상 분리.)
### 3.3 프로젝트 상세 (`/projects/:id`)
- 제목 / 설명 / 태그 / 작성자 / 작성일
- **바로가기 버튼** (배포 URL — 새 탭)
- **좋아요** 토글 (❤️ + 카운트)
- **댓글** + **대댓글(1뎁스까지)**
- 댓글: 작성자, 시각, 본문, [답글] 버튼
- 대댓글: 부모 댓글 아래 들여쓰기. **대댓글에는 [답글] 버튼 없음** (1뎁스 고정)
- 본인 댓글은 삭제 가능
---
## 4. 권한 모델 (Role)
Keycloak에서 내려주는 role(또는 group)로 분기. `preferred_username` = 사번.
| 역할 | 볼 수 있는 도구 | 비고 |
|---|---|---|
| **일반 사용자** | Coder, Gitea, Kubero, Harbor | 개발 핵심 |
| **관리자(admin)** | 위 + OpenEverest, MinIO, Keycloak, LiteLLM | 인프라/운영 도구까지 |
- 구현: 세션의 `roles``admin` 포함 여부로 `sites.js`의 각 항목 `adminOnly: true` 필터링.
- 일반 사용자에게 관리자 전용 카드는 **렌더링 자체를 안 함** (숨김, disabled 아님).
- 프로젝트 공유/좋아요/댓글은 역할 무관 모두 가능.
```js
// sites.js 형태 (예시)
export const sites = [
{ key:'coder', name:'Coder', desc:'클라우드 IDE', url: SSO_START.coder, icon:'coder', group:'dev' },
{ key:'gitea', name:'Gitea', desc:'Git 저장소', url: SSO_START.gitea, icon:'gitea', group:'dev' },
{ key:'kubero', name:'Kubero', desc:'배포(PaaS)', url: KUBERO_URL, icon:'kubero', group:'dev' },
{ key:'harbor', name:'Harbor', desc:'컨테이너 레지스트리', url: HARBOR_URL, icon:'harbor', group:'dev' },
{ key:'openeverest', name:'OpenEverest', desc:'DB 콘솔', url: OE_URL, icon:'db', group:'infra', adminOnly:true },
{ key:'minio', name:'MinIO', desc:'오브젝트 스토리지', url: MINIO_URL, icon:'minio', group:'infra', adminOnly:true },
{ key:'keycloak', name:'Keycloak', desc:'계정/SSO 관리', url: KC_URL, icon:'key', group:'infra', adminOnly:true },
{ key:'litellm', name:'LiteLLM', desc:'LLM 게이트웨이', url: LITELLM_URL, icon:'ai', group:'infra', adminOnly:true },
];
```
---
## 5. 배포 URL 연동 — Kubero에서 어떻게 받아오나
> 네 질문: "공유된 프로젝트 링크를 새 탭으로 열려면 Kubero에서 URL을 받아와야 하나?"
**결론: Kubero API를 실시간으로 조회하지 말고, 공유 시점에 URL을 직접 저장한다.**
### 왜 실시간 조회가 아닌가
- Kubero는 빌드 후 **설정된 도메인(configured domain)** 으로 앱을 노출함. 앱마다 도메인은 이미 정해져 있음 (`<app>.apps.bokdev.in` 같은 패턴).
- Kubero v3에 API가 있지만, 포털이 매 렌더링마다 Kubero API를 때리면: ① 토큰/권한 관리 부담 ② Kubero 다운 시 포털 피드도 깨짐 ③ 폐쇄망에서 불필요한 결합도 증가.
- 공유 프로젝트의 URL은 **거의 안 바뀜**. 실시간성이 필요 없는 데이터.
### 권장 방식 (단순)
프로젝트 공유 폼에서 사용자가 **URL을 직접 입력**(또는 선택):
- `repo_url` — Gitea 저장소 주소
- `app_url` — 배포된 앱 주소 (예: `https://myapp.apps.bokdev.in`) — **이게 "바로가기"가 여는 링크**
- 둘 다 선택 입력. `app_url`이 없으면 "바로가기" 버튼은 숨김.
### (선택) 편의 기능 — 나중에
공유 폼에서 "내 Kubero 앱 목록 불러오기" 버튼 → Kubero API로 사용자의 파이프라인/앱과 그 도메인을 가져와 드롭다운 제공. URL 오타 방지용. **MVP에는 불필요**, 직접 입력으로 충분.
---
## 6. SSO 자동로그인 (도구 카드 클릭 시)
이미 적용된 패턴 유지:
- Coder/Gitea처럼 OIDC를 지원하는 도구는 **OIDC 시작 URL**(자동로그인 진입점)로 링크 → 사용자가 도구에서 다시 로그인 안 함.
- 카드에 "SSO" 배지로 자동로그인 가능함을 표시.
- 모든 도구 링크는 `target="_blank"` + `rel="noopener"` 로 새 탭.
---
## 7. 데이터 모델 (Postgres, schema `portal`)
```sql
-- 사용자: Keycloak가 진실의 원천. 포털은 표시에 필요한 최소만 캐시.
users (
id text primary key, -- preferred_username (사번)
display_name text,
is_admin boolean default false,
created_at timestamptz default now()
)
projects (
id bigserial primary key,
owner_id text not null references users(id),
title text not null,
description text,
repo_url text, -- Gitea 등
app_url text, -- 배포 URL (바로가기 대상)
tags text, -- 콤마 구분(실제 구현은 text). 표시 시 split.
is_public boolean default true, -- 공개(피드 노출) / 비공개(나만)
lang text, -- 대표 언어(태그에서 추론, 카드 언어 점)
created_at timestamptz default now(),
updated_at timestamptz default now()
)
-- 반응은 2종(디자인 확정): ❤️ 좋아요(hearts) + ⭐ 즐겨찾기/Star(stars). 각 1인 1회.
hearts (
project_id bigint references projects(id) on delete cascade,
user_id text references users(id),
created_at timestamptz default now(),
primary key (project_id, user_id) -- 1인 1좋아요
)
stars (
project_id bigint references projects(id) on delete cascade,
user_id text references users(id),
created_at timestamptz default now(),
primary key (project_id, user_id) -- 1인 1즐겨찾기
)
comments (
id bigserial primary key,
project_id bigint not null references projects(id) on delete cascade,
user_id text not null references users(id),
parent_id bigint references comments(id) on delete cascade, -- null이면 최상위
body text not null,
created_at timestamptz default now()
)
-- 대댓글 1뎁스 제약: parent_id가 가리키는 댓글의 parent_id는 반드시 null
-- (앱 레벨에서 검증. 대댓글에 또 답글 달기 차단)
```
조회 쿼리 포인트:
- 피드 카드의 ❤️ 수 / ⭐ 수 / 💬 수는 `count` 서브쿼리로(`db.js``PROJECT_SELECT`).
- "내 좋아요/즐겨찾기 여부"는 `hearts`/`stars``(project_id, 현재 사용자)` 존재 여부(`EXISTS`).
- 목록은 **공개 프로젝트 + 본인 비공개**만 반환(남의 비공개 비노출).
- ⚠️ 실제 스키마는 사번(`sabun`)을 PK/FK 로 쓰고 `users.is_admin` 은 두지 않는다(관리자 여부는
매 로그인 시 Keycloak role 로 판별). 컬럼·타입의 단일 진실은 [`schema.sql`](./schema.sql).
---
## 8. 라우트 (API)
| Method | Path | 설명 | 권한 |
|---|---|---|---|
| GET | `/` | 대시보드 (도구 + 내 프로젝트 + 피드). `?open=:id`/`?toast=`/`?egg=` 신호 | 로그인 |
| GET | `/projects/:id` | 프로젝트 상세(단독 페이지·모달 폴백). 비공개는 본인만 | 로그인 |
| POST | `/projects` | 프로젝트 공유 생성 (visibility·lang 포함) | 로그인 |
| POST | `/projects/:id/heart` | ❤️ 좋아요 토글 (AJAX 면 `{on,count}`) | 로그인 |
| POST | `/projects/:id/star` | ⭐ 즐겨찾기 토글 (AJAX 면 `{on,count}`) | 로그인 |
| POST | `/projects/:id/visibility` | 공개/비공개 전환 | 본인 |
| POST | `/projects/:id/delete` | 내 프로젝트 삭제 (form POST) | 본인 |
| POST | `/projects/:id/comments` | 댓글/대댓글 작성 (`parent_id` 옵션, 1뎁스 검증) | 로그인 |
| POST | `/comments/:id/delete` | 내 댓글 삭제 (form POST) | 본인 |
| GET | `/auth/callback` | OIDC 콜백 | - |
| GET | `/logout` | 로그아웃 | - |
> 반응/댓글은 **점진적 향상**: JS 없으면 폼 POST→리다이렉트, JS 있으면 fetch 로 즉시 반영.
> 삭제는 메서드 제약(HTML 폼) 때문에 `POST .../delete` 로 처리(논리적으로는 DELETE).
---
## 9. 비기능 / 운영
- **배포**: Kubero, NodeJS buildpack (`npm install``node index.js`). Dockerfile 불필요.
- **Env**: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_ROLE`, 각 도구 URL.
- **DB**: OpenEverest Postgres의 `appdb`, schema `portal`, role `portal_app`.
- **Issuer**: `https://keycloak.bokdev.in/realms/bokdev`.
- **브랜드명**: `PORTAL_BRAND` 환경변수로 교체 가능 (`AI DEV` 기본).
- 폐쇄망: 외부 폰트/CDN 의존 최소화. 폰트는 **시스템 폰트 폴백**(Pretendard → Apple SD Gothic/Malgun
Gothic)으로 동작. CDN 불가 환경에선 `public/` 에 Pretendard woff2 로컬 호스팅 권장(선택).
아이콘은 인라인 SVG(Octicon)·이모지로 외부 의존 없음.
- **클라이언트 부가기능(프런트 전용, 서버 무관)**: 라이트/다크 테마 토글(localStorage), 헤더 검색,
토스트 알림, 이스터에그(로고 흔들기·👍🚀✨ 날리기·LGTM 스탬프·콘솔 아트).
---
## 10. MVP 범위 / 나중
**MVP (지금 — 구현 완료)**
- 3화면(대시보드+상세 모달/단독 페이지), 역할 분기(관리자 도구 숨김), 프로젝트 공유,
공개/비공개, ❤️ 좋아요 + ⭐ 즐겨찾기, 댓글+대댓글(1뎁스), SSO 링크, 바로가기(직접 입력 URL),
검색·정렬(최신/인기)·즐겨찾기 필터(클라이언트), 라이트/다크 테마.
**나중에 (선택)**
- Kubero API로 앱 목록 자동 불러오기 (URL 입력 편의)
- 서버측 검색/태그 필터·정렬(현재는 클라이언트 처리, 새로고침 시 초기화)
- 댓글 알림, 멘션
- 프로젝트 썸네일/스크린샷
- 내 프로젝트 **수정(edit)** (현재는 삭제·가시성 전환만)
---
## 11. 열린 질문 / 결정
1. ~~관리자/일반 구분을 role 로 줄지 group 으로 줄지~~**확정: Keycloak realm role**
(`realm_access.roles``ADMIN_ROLE`(기본 `admin`) 포함 여부). 운영 중 group 으로 바꾸려면 `auth.js` 수정.
2. "내 프로젝트" **수정(edit)** → MVP 제외(삭제·가시성 전환만). 추후 과제.
3. 좋아요/댓글에 **익명성**은 없음(사번 기반) — 사내 문화상 OK 가정.

View File

@@ -0,0 +1,844 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
:root {
color-scheme: light;
--bg:#ffffff; --panel:#f6f8fa; --card:#ffffff; --tile:#f3f5f8; --tile-bd:#e1e6eb;
--inset:#ffffff; --bg-soft:#fbfcfd;
--border:#d0d7de; --border-soft:#d8dee4; --divider:#eaecef;
--fg:#1f2328; --fg2:#656d76; --fg3:#8b949e;
--link:#0969da; --accent-bg:#ddf4ff; --accent-bd:#b6e3ff; --accent-fg:#0969da; --accent-strong:#0a3069;
--success:#1f883d; --success-hover:#1a7f37; --success-bg:#dafbe1; --success-bd:#b7ebc4; --success-bd2:#4ac26b; --success-fg:#1a7f37;
--attn:#d4a72c; --attn-fg:#9a6700; --attn-bg:#fff8e6; --attn-bd:#eac54f;
--admin:#8250df; --admin-fg:#8250df; --admin-bg:#f3effc; --admin-bd:#e7defb;
--avatar-bg:#eaeef2; --overlay:rgba(31,35,40,.42);
--shadow:rgba(31,35,40,.08); --shadow-hover:rgba(31,35,40,.12); --shadow-strong:rgba(31,35,40,.22);
--btn-bg:#f6f8fa; --btn-bd:#d0d7de; --btn-hover:#eef1f4;
--tab-underline:#fd8c73; --focus-ring:rgba(9,105,218,.12); --danger:#cf222e;
--heart:#bf3989; --heart-fg:#bf3989; --heart-bg:#ffeff5; --heart-bd:#ffc1da;
--hbg:#24292f; --hfg:#ffffff; --hfg2:#b9c0c9; --hbd:#444c56; --hsearch-bg:#383f47; --hsearch-bd:#565b62; --hsearch-fg:#cdd3da;
}
:root[data-aidev-theme="dark"] {
color-scheme: dark;
--bg:#0d1117; --panel:#161b22; --card:#1c2128; --tile:#2d333b; --tile-bd:#373e47;
--inset:#0d1117; --bg-soft:#161b22;
--border:#30363d; --border-soft:#373e47; --divider:#262c34;
--fg:#e6edf3; --fg2:#9198a1; --fg3:#6e7681;
--link:#4493f8; --accent-bg:rgba(56,139,253,.15); --accent-bd:rgba(56,139,253,.4); --accent-fg:#4493f8; --accent-strong:#a5d6ff;
--success:#238636; --success-hover:#2ea043; --success-bg:rgba(46,160,67,.15); --success-bd:rgba(46,160,67,.4); --success-bd2:rgba(63,185,80,.6); --success-fg:#3fb950;
--attn:#e3b341; --attn-fg:#e3b341; --attn-bg:rgba(227,179,65,.12); --attn-bd:rgba(187,128,9,.6);
--admin:#bc8cff; --admin-fg:#bc8cff; --admin-bg:rgba(163,113,247,.15); --admin-bd:rgba(163,113,247,.4);
--avatar-bg:#2d333b; --overlay:rgba(1,4,9,.7);
--shadow:rgba(1,4,9,.4); --shadow-hover:rgba(1,4,9,.55); --shadow-strong:rgba(1,4,9,.6);
--btn-bg:#21262d; --btn-bd:#30363d; --btn-hover:#30363d;
--tab-underline:#f78166; --focus-ring:rgba(56,139,253,.3); --danger:#f85149;
--heart:#db61a2; --heart-fg:#db61a2; --heart-bg:rgba(219,97,162,.15); --heart-bd:rgba(219,97,162,.4);
--hbg:#010409; --hfg:#e6edf3; --hfg2:#9198a1; --hbd:#21262d; --hsearch-bg:#0d1117; --hsearch-bd:#30363d; --hsearch-fg:#9198a1;
}
html, body { background: var(--bg); color: var(--fg); }
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', 'Malgun Gothic', sans-serif;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
transition: background-color .25s ease, color .2s ease;
}
::selection { background: color-mix(in srgb, var(--link) 22%, transparent); }
::placeholder { color: var(--fg3); }
@keyframes aidev-flyup { 0%{transform:translateY(0) scale(.5);opacity:0} 12%{opacity:1} 100%{transform:translateY(-180px) scale(1.15);opacity:0} }
@keyframes aidev-sparkle { 0%{transform:scale(0) rotate(0);opacity:1} 100%{transform:scale(1.5) rotate(120deg);opacity:0} }
@keyframes aidev-rocket { 0%{transform:translateX(-40px) rotate(-12deg);opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{transform:translateX(calc(100vw + 60px)) rotate(-12deg);opacity:0} }
@keyframes aidev-shake { 10%,90%{transform:translateX(-1px) rotate(-1deg)} 20%,80%{transform:translateX(2px) rotate(1.5deg)} 30%,50%,70%{transform:translateX(-4px) rotate(-2deg)} 40%,60%{transform:translateX(4px) rotate(2deg)} }
@keyframes aidev-stamp { 0%{transform:scale(2.4) rotate(-22deg);opacity:0} 55%{transform:scale(.86) rotate(-11deg);opacity:1} 100%{transform:scale(1) rotate(-11deg);opacity:1} }
@keyframes aidev-pop { 0%{transform:scale(.96) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
@keyframes aidev-toast { 0%{transform:translateY(12px);opacity:0} 12%{transform:translateY(0);opacity:1} 88%{transform:translateY(0);opacity:1} 100%{transform:translateY(8px);opacity:0} }
</style>
</helmet>
<div style="min-height:100vh;background:var(--bg);">
<header style="position:sticky;top:0;z-index:300;background:var(--hbg);border-bottom:1px solid var(--hbd);">
<div style="max-width:1180px;margin:0 auto;padding:0 24px;height:60px;display:flex;align-items:center;gap:16px;">
<div style="display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none;flex:0 0 auto;" ref="{{ logoRef }}" onClick="{{ onLogoClick }}">
<span style="display:inline-flex;color:var(--hfg);" innerel="{{ logoMarkEl }}">{{ logoMarkEl }}</span>
<span style="font-weight:800;font-size:15px;letter-spacing:.13em;color:var(--hfg);">AI&#8202;DEV</span>
<span style="font-size:11px;color:var(--hfg2);font-weight:500;border-left:1px solid var(--hbd);padding-left:10px;margin-left:2px;white-space:nowrap;">사내 개발 포털</span>
</div>
<div style="flex:1;max-width:300px;position:relative;display:flex;align-items:center;">
<span style="position:absolute;left:10px;display:inline-flex;color:var(--hfg2);pointer-events:none;" innerel="{{ searchIconEl }}">{{ searchIconEl }}</span>
<input value="{{ searchQuery }}" onInput="{{ onSearchInput }}" placeholder="프로젝트 검색…" style="width:100%;font-family:inherit;font-size:13px;color:var(--hfg);background:var(--hsearch-bg);border:1px solid var(--hsearch-bd);border-radius:7px;padding:6px 10px 6px 32px;outline:none;" style-focus="border-color:var(--link);background:var(--bg);">
</div>
<div style="display:flex;align-items:center;gap:10px;flex:0 0 auto;margin-left:auto;">
<button onClick="{{ onToggleTheme }}" title="{{ themeTitle }}" style="width:32px;height:32px;border-radius:8px;border:1px solid var(--hbd);background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--hfg2);transition:background .14s ease,color .14s ease;" style-hover="background:rgba(255,255,255,.08);color:var(--hfg);" innerel="{{ themeIconEl }}">{{ themeIconEl }}</button>
<button onClick="{{ onToggleAdmin }}" title="관리자 권한 분기 시연" style="display:flex;align-items:center;gap:9px;background:transparent;border:1px solid var(--hbd);border-radius:999px;padding:5px 6px 5px 12px;cursor:pointer;font-family:inherit;">
<span style="font-size:12.5px;font-weight:600;color:var(--hfg2);white-space:nowrap;">관리자 보기</span>
<span style="{{ switchTrackStyle }}"><span style="{{ switchKnobStyle }}"></span></span>
</button>
<div style="position:relative;">
<button onClick="{{ onToggleUserMenu }}" style="display:flex;align-items:center;gap:8px;background:transparent;border:1px solid transparent;border-radius:8px;padding:4px 6px 4px 8px;cursor:pointer;font-family:inherit;" style-hover="background:rgba(255,255,255,.08);">
<span style="font-size:13px;font-weight:600;color:var(--hfg);font-variant-numeric:tabular-nums;">{{ currentUser }}</span>
<span style="display:inline-flex;" innerel="{{ headerAvatarEl }}">{{ headerAvatarEl }}</span>
<span style="display:inline-flex;color:var(--hfg2);" innerel="{{ caretEl }}">{{ caretEl }}</span>
</button>
<sc-if value="{{ userMenuOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;top:46px;right:0;width:212px;background:var(--card);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px var(--shadow-strong);padding:6px;z-index:320;animation:aidev-pop .14s ease-out;">
<div style="padding:8px 10px 10px;border-bottom:1px solid var(--divider);margin-bottom:6px;">
<div style="font-size:12px;color:var(--fg2);">로그인 계정</div>
<div style="font-size:13.5px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ currentUser }}</div>
<div style="font-size:11.5px;color:var(--fg2);margin-top:2px;">{{ roleLabel }}</div>
</div>
<button onClick="{{ onLogout }}" style="width:100%;text-align:left;background:transparent;border:0;border-radius:6px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--fg);display:flex;align-items:center;gap:8px;" style-hover="background:var(--panel);">
<span style="display:inline-flex;color:var(--fg2);" innerel="{{ logoutIconEl }}">{{ logoutIconEl }}</span>로그아웃
</button>
</div>
</sc-if>
</div>
</div>
</div>
</header>
<main style="max-width:1180px;margin:0 auto;padding:32px 24px 64px;">
<!-- ===================== SECTION 1 — TOOLS ===================== -->
<section style="margin-bottom:40px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px;">
<div style="display:flex;align-items:baseline;gap:10px;">
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">개발 도구</h2>
<span style="font-size:13px;color:var(--fg2);">SSO로 한 번에 로그인 · 새 탭으로 이동</span>
</div>
<sc-if value="{{ adminView }}" hint-placeholder-val="{{ false }}">
<span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--admin-fg);background:var(--admin-bg);border:1px solid var(--admin-bd);border-radius:999px;padding:3px 10px;white-space:nowrap;">
<span style="display:inline-flex;" innerel="{{ lockAdminEl }}">{{ lockAdminEl }}</span>관리자 도구 표시 중
</span>
</sc-if>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
<sc-for list="{{ visibleTools }}" as="tool" hint-placeholder-count="4">
<button onClick="{{ tool.onOpen }}" style="{{ tool.cardStyle }}" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow-hover);transform:translateY(-3px);">
<span style="position:absolute;top:13px;right:13px;font-size:10px;font-weight:700;letter-spacing:.06em;color:var(--accent-fg);background:var(--accent-bg);border:1px solid var(--accent-bd);border-radius:4px;padding:2px 6px;">SSO</span>
<span style="width:44px;height:44px;border-radius:11px;display:inline-flex;align-items:center;justify-content:center;{{ tool.iconWrapStyle }}" innerel="{{ tool.iconEl }}">{{ tool.iconEl }}</span>
<span style="display:block;margin-top:14px;font-size:14.5px;font-weight:700;color:var(--fg);">{{ tool.name }}</span>
<span style="display:block;margin-top:3px;font-size:12.5px;color:var(--fg2);line-height:1.45;">{{ tool.desc }}</span>
<sc-if value="{{ tool.isAdmin }}" hint-placeholder-val="{{ false }}">
<span style="display:inline-flex;align-items:center;gap:5px;margin-top:11px;font-size:11px;font-weight:600;color:var(--admin-fg);background:var(--admin-bg);border:1px solid var(--admin-bd);border-radius:999px;padding:2px 8px;white-space:nowrap;">
<span style="display:inline-flex;" innerel="{{ lockAdminEl }}">{{ lockAdminEl }}</span>관리자 전용
</span>
</sc-if>
</button>
</sc-for>
</div>
</section>
<!-- ===================== SECTION 2 — MY PROJECTS ===================== -->
<section style="margin-bottom:40px;position:relative;background:var(--panel);border:1px solid var(--border-soft);border-radius:14px;padding:22px 22px 24px;">
<span style="position:absolute;left:0;top:18px;bottom:18px;width:4px;border-radius:0 4px 4px 0;background:var(--link);"></span>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;padding-left:8px;flex-wrap:wrap;">
<div style="display:flex;align-items:baseline;gap:10px;">
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">내 프로젝트</h2>
<span style="font-size:13px;color:var(--fg2);">{{ currentUser }} 님의 작업 공간</span>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="display:inline-flex;align-items:center;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:2px;gap:2px;">
<button onClick="{{ onFilterAll }}" style="{{ filterAllStyle }}">전체 {{ myTotalCount }}</button>
<button onClick="{{ onFilterShared }}" style="{{ filterSharedStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniEl }}">{{ globeMiniEl }}</span>공개 {{ mySharedCount }}</button>
<button onClick="{{ onFilterPrivate }}" style="{{ filterPrivateStyle }}"><span style="display:inline-flex;" innerel="{{ lockMiniEl }}">{{ lockMiniEl }}</span>비공개 {{ myPrivateCount }}</button>
</div>
<button onClick="{{ onOpenShare }}" title="새 프로젝트 등록" style="{{ newProjectBtnStyle }}" style-hover="background:var(--success-hover);"><span style="display:inline-flex;" innerel="{{ plusWhiteEl }}">{{ plusWhiteEl }}</span>새 프로젝트</button>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px;padding-left:8px;">
<sc-for list="{{ myProjects }}" as="p" hint-placeholder-count="3">
<div onClick="{{ p.onOpen }}" style="{{ p.myCardStyle }}" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow);transform:translateY(-2px);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div style="display:flex;align-items:center;gap:7px;min-width:0;">
<span style="display:inline-flex;color:var(--fg2);flex:0 0 auto;" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
<span style="font-size:14px;font-weight:700;color:var(--link);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ p.title }}</span>
</div>
<span style="{{ p.visBadgeStyle }}"><span style="display:inline-flex;" innerel="{{ p.visIconEl }}">{{ p.visIconEl }}</span>{{ p.visLabel }}</span>
</div>
<div style="margin-top:8px;font-size:12.5px;color:var(--fg2);line-height:1.5;flex:1;">{{ p.description }}</div>
<div style="margin-top:11px;display:flex;flex-wrap:wrap;gap:6px;">
<sc-for list="{{ p.tags }}" as="tag" hint-placeholder-count="2">
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
</sc-for>
</div>
<div style="margin-top:13px;display:flex;align-items:center;justify-content:space-between;gap:8px;">
<sc-if value="{{ p.shared }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:13px;font-size:12px;color:var(--fg2);">
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ p.langColor }};display:inline-block;"></span>{{ p.lang }}</span>
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;color:var(--heart);" innerel="{{ p.heartMiniEl }}">{{ p.heartMiniEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.heartCount }}</span></span>
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;color:var(--attn);" innerel="{{ p.starMiniEl }}">{{ p.starMiniEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.likeCount }}</span></span>
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;" innerel="{{ commentIconEl }}">{{ commentIconEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.commentCount }}</span></span>
</div>
<button onClick="{{ p.onToggleShare }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:11.5px;font-weight:600;color:var(--fg3);white-space:nowrap;" style-hover="color:var(--fg)">비공개로</button>
</sc-if>
<sc-if value="{{ p.isPrivate }}" hint-placeholder-val="{{ false }}">
<span style="font-size:11.5px;color:var(--fg3);">나만 볼 수 있어요</span>
<button onClick="{{ p.onToggleShare }}" style="{{ shareMiniBtnStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniWhiteEl }}">{{ globeMiniWhiteEl }}</span>공유하기</button>
</sc-if>
</div>
</div>
</sc-for>
<sc-if value="{{ showAddCard }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ onOpenShare }}" style="background:transparent;border:1.5px dashed var(--border);border-radius:12px;padding:16px;cursor:pointer;font-family:inherit;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--fg2);min-height:158px;width:100%;transition:border-color .16s ease,color .16s ease,background .16s ease;" style-hover="border-color:var(--success);color:var(--success);background:var(--card);">
<span style="width:38px;height:38px;border-radius:50%;background:var(--success-bg);border:1px solid var(--success-bd);display:inline-flex;align-items:center;justify-content:center;color:var(--success);" innerel="{{ plusIconEl }}">{{ plusIconEl }}</span>
<span style="font-size:13.5px;font-weight:700;white-space:nowrap;">새 프로젝트 등록</span>
<span style="font-size:11.5px;text-align:center;line-height:1.4;">공개·비공개를 정해 등록하세요</span>
</button>
</sc-if>
<sc-if value="{{ myEmpty }}" hint-placeholder-val="{{ false }}">
<div style="grid-column:1 / -1;text-align:center;padding:30px;color:var(--fg2);font-size:13px;border:1px dashed var(--border);border-radius:12px;">{{ myEmptyMsg }}</div>
</sc-if>
</div>
</section>
<!-- ===================== SECTION 3 — SHARED FEED ===================== -->
<section>
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:16px;border-bottom:1px solid var(--border-soft);">
<div style="display:flex;align-items:baseline;gap:10px;padding-bottom:14px;">
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">공유 프로젝트</h2>
<span style="font-size:13px;color:var(--fg2);">동료들이 만든 결과물 둘러보기</span>
</div>
<div style="display:flex;align-items:flex-end;gap:14px;">
<button onClick="{{ onToggleFeedStarred }}" title="내가 별표한 프로젝트" style="{{ feedStarBtnStyle }}" style-hover="border-color:var(--attn-bd);color:var(--attn-fg);"><span style="display:inline-flex;" innerel="{{ feedStarIconEl }}">{{ feedStarIconEl }}</span>즐겨찾기 <span style="font-variant-numeric:tabular-nums;">{{ starredCount }}</span></button>
<div style="display:flex;gap:2px;">
<button onClick="{{ onSortLatest }}" style="{{ tabLatestStyle }}">최신순</button>
<button onClick="{{ onSortPopular }}" style="{{ tabPopularStyle }}">인기순</button>
</div>
</div>
</div>
<sc-if value="{{ feedEmpty }}" hint-placeholder-val="{{ false }}">
<div style="text-align:center;padding:48px 20px;color:var(--fg2);font-size:14px;border:1px dashed var(--border);border-radius:12px;">{{ feedEmptyMsg }}</div>
</sc-if>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;">
<sc-for list="{{ feedProjects }}" as="p" hint-placeholder-count="4">
<div onClick="{{ p.onOpen }}" style="background:var(--card);border:1px solid var(--border-soft);border-radius:12px;padding:18px;cursor:pointer;transition:box-shadow .16s ease,border-color .16s ease,transform .16s ease;display:flex;flex-direction:column;" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow);transform:translateY(-2px);">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
<span style="display:inline-flex;color:var(--fg2);flex:0 0 auto;" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
<span style="font-size:15px;font-weight:700;color:var(--link);line-height:1.3;">{{ p.title }}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;flex:0 0 auto;">
<button onClick="{{ p.onToggleHeart }}" title="좋아요" style="{{ p.heartBtnStyle }}"><span style="display:inline-flex;" innerel="{{ p.heartEl }}">{{ p.heartEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.heartCount }}</span></button>
<button onClick="{{ p.onToggleLike }}" title="Star" style="{{ p.starBtnStyle }}"><span style="display:inline-flex;" innerel="{{ p.starEl }}">{{ p.starEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.likeCount }}</span></button>
</div>
</div>
<div style="margin-top:8px;font-size:13px;color:var(--fg2);line-height:1.55;">{{ p.description }}</div>
<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px;">
<sc-for list="{{ p.tags }}" as="tag" hint-placeholder-count="3">
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
</sc-for>
</div>
<div style="margin-top:15px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
<span style="display:inline-flex;flex:0 0 auto;" innerel="{{ p.avatarEl }}">{{ p.avatarEl }}</span>
<span style="font-size:12.5px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ p.ownerId }}</span>
<span style="font-size:11.5px;color:var(--fg3);">· {{ p.createdAt }}</span>
</div>
<div style="display:flex;align-items:center;gap:13px;font-size:12px;color:var(--fg2);">
<span style="display:inline-flex;align-items:center;gap:5px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ p.langColor }};display:inline-block;"></span>{{ p.lang }}</span>
<span style="display:inline-flex;align-items:center;gap:5px;"><span style="display:inline-flex;" innerel="{{ commentIconEl }}">{{ commentIconEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.commentCount }}</span></span>
<sc-if value="{{ p.hasAppUrl }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ p.onOpenApp }}" style="display:inline-flex;align-items:center;gap:4px;background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--link);white-space:nowrap;">바로가기 <span style="display:inline-flex;" innerel="{{ extIconEl }}">{{ extIconEl }}</span></button>
</sc-if>
</div>
</div>
</div>
</sc-for>
</div>
</section>
</main>
<footer style="border-top:1px solid var(--border-soft);">
<div style="max-width:1180px;margin:0 auto;padding:20px 24px 28px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:8px;color:var(--fg3);font-size:12px;">
<span style="display:inline-flex;color:var(--fg3);" innerel="{{ footerMarkEl }}">{{ footerMarkEl }}</span>
AI DEV · 사내 개발 포털
</div>
<div style="font-size:11.5px;color:var(--fg3);font-family:'SF Mono',ui-monospace,Menlo,monospace;">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
</div>
</footer>
<!-- ===================== DETAIL MODAL ===================== -->
<sc-if value="{{ modalOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ onCloseModal }}" style="position:fixed;inset:0;z-index:500;background:var(--overlay);display:flex;align-items:flex-start;justify-content:center;padding:48px 20px;overflow-y:auto;animation:aidev-fade .16s ease-out;">
<div onClick="{{ stopProp }}" style="width:100%;max-width:640px;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px var(--shadow-strong);overflow:hidden;animation:aidev-pop .18s ease-out;">
<div style="padding:22px 24px 18px;border-bottom:1px solid var(--divider);">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;">
<div style="min-width:0;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="display:inline-flex;color:var(--fg2);" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
<h3 style="margin:0;font-size:20px;font-weight:800;color:var(--link);line-height:1.3;letter-spacing:-.01em;">{{ modalProject.title }}</h3>
<span style="{{ modalProject.visBadgeStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.visIconEl }}">{{ modalProject.visIconEl }}</span>{{ modalProject.visLabel }}</span>
</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="display:inline-flex;" innerel="{{ modalProject.avatarEl }}">{{ modalProject.avatarEl }}</span>
<span style="font-size:13px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ modalProject.ownerId }}</span>
<span style="font-size:12px;color:var(--fg3);">· {{ modalProject.createdAt }} 공유</span>
<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--fg2);margin-left:4px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ modalProject.langColor }};display:inline-block;"></span>{{ modalProject.lang }}</span>
</div>
</div>
<button onClick="{{ onCloseModal }}" style="flex:0 0 auto;width:32px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--fg2);" style-hover="background:var(--panel);border-color:var(--border-soft);" innerel="{{ closeIconEl }}">{{ closeIconEl }}</button>
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:14px;">
<sc-for list="{{ modalProject.tags }}" as="tag" hint-placeholder-count="3">
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
</sc-for>
</div>
<p style="margin:14px 0 0;font-size:14px;color:var(--fg);line-height:1.65;">{{ modalProject.description }}</p>
<div style="margin-top:18px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<button onClick="{{ modalProject.onToggleHeart }}" style="{{ modalHeartBtnStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.heartEl }}">{{ modalProject.heartEl }}</span><span>좋아요</span><span style="font-variant-numeric:tabular-nums;padding-left:8px;margin-left:2px;border-left:1px solid var(--border);">{{ modalProject.heartCount }}</span></button>
<button onClick="{{ modalProject.onToggleLike }}" style="{{ modalStarBtnStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.starEl }}">{{ modalProject.starEl }}</span><span>{{ modalProject.starLabel }}</span><span style="font-variant-numeric:tabular-nums;padding-left:8px;margin-left:2px;border-left:1px solid var(--border);">{{ modalProject.likeCount }}</span></button>
<sc-if value="{{ modalProject.hasAppUrl }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ modalProject.onOpenApp }}" style="display:inline-flex;align-items:center;gap:6px;background:var(--success);border:1px solid color-mix(in srgb, var(--fg) 12%, transparent);border-radius:8px;padding:8px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;color:#fff;white-space:nowrap;transition:background .14s ease;" style-hover="background:var(--success-hover);">바로가기 <span style="display:inline-flex;" innerel="{{ extIconWhiteEl }}">{{ extIconWhiteEl }}</span></button>
</sc-if>
<sc-if value="{{ modalProject.hasRepo }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ modalProject.onOpenRepo }}" style="display:inline-flex;align-items:center;gap:6px;background:var(--btn-bg);border:1px solid var(--btn-bd);border-radius:8px;padding:8px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;color:var(--fg);white-space:nowrap;transition:background .14s ease;" style-hover="background:var(--btn-hover);"><span style="display:inline-flex;color:var(--fg2);" innerel="{{ codeIconEl }}">{{ codeIconEl }}</span>저장소</button>
</sc-if>
</div>
</div>
<div style="padding:18px 24px 4px;">
<div style="font-size:13px;font-weight:700;color:var(--fg);margin-bottom:14px;display:flex;align-items:center;gap:7px;">댓글 <span style="font-weight:600;color:var(--fg2);background:var(--panel);border:1px solid var(--border-soft);border-radius:999px;padding:0 8px;font-size:12px;font-variant-numeric:tabular-nums;">{{ modalProject.commentCount }}</span></div>
<div style="display:flex;flex-direction:column;gap:4px;">
<sc-for list="{{ threadedComments }}" as="c" hint-placeholder-count="3">
<div>
<div style="display:flex;gap:11px;padding:11px 0;">
<span style="display:inline-flex;flex:0 0 auto;margin-top:1px;" innerel="{{ c.avatarEl }}">{{ c.avatarEl }}</span>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:700;color:var(--fg);font-variant-numeric:tabular-nums;">{{ c.userId }}</span>
<span style="font-size:11.5px;color:var(--fg3);">{{ c.time }}</span>
<sc-if value="{{ c.stamped }}" hint-placeholder-val="{{ false }}">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:800;letter-spacing:.04em;color:var(--success-fg);background:var(--success-bg);border:1px solid var(--success-bd2);border-radius:5px;padding:1px 7px;animation:aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2);"><span style="display:inline-flex;" innerel="{{ checkIconEl }}">{{ checkIconEl }}</span>LGTM</span>
</sc-if>
</div>
<div style="margin-top:3px;font-size:13.5px;color:var(--fg);line-height:1.6;white-space:pre-wrap;">{{ c.body }}</div>
<div style="margin-top:5px;display:flex;align-items:center;gap:12px;">
<button onClick="{{ c.onReply }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--fg2);display:inline-flex;align-items:center;gap:5px;" style-hover="color:var(--link);"><span style="display:inline-flex;" innerel="{{ replyIconEl }}">{{ replyIconEl }}</span>답글</button>
<sc-if value="{{ c.deletable }}" hint-placeholder-val="{{ false }}">
<button onClick="{{ c.onDelete }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--fg3);" style-hover="color:var(--danger);">삭제</button>
</sc-if>
</div>
</div>
</div>
<sc-if value="{{ c.hasReplies }}" hint-placeholder-val="{{ false }}">
<div style="margin-left:21px;padding-left:18px;border-left:2px solid var(--divider);display:flex;flex-direction:column;">
<sc-for list="{{ c.replies }}" as="r" hint-placeholder-count="1">
<div style="display:flex;gap:10px;padding:10px 0;">
<span style="display:inline-flex;flex:0 0 auto;margin-top:1px;" innerel="{{ r.avatarEl }}">{{ r.avatarEl }}</span>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:12.5px;font-weight:700;color:var(--fg);font-variant-numeric:tabular-nums;">{{ r.userId }}</span>
<span style="font-size:11px;color:var(--fg3);">{{ r.time }}</span>
<sc-if value="{{ r.stamped }}" hint-placeholder-val="{{ false }}">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:800;letter-spacing:.04em;color:var(--success-fg);background:var(--success-bg);border:1px solid var(--success-bd2);border-radius:5px;padding:1px 6px;animation:aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2);"><span style="display:inline-flex;" innerel="{{ checkIconEl }}">{{ checkIconEl }}</span>LGTM</span>
</sc-if>
</div>
<div style="margin-top:3px;font-size:13px;color:var(--fg);line-height:1.6;white-space:pre-wrap;">{{ r.body }}</div>
<sc-if value="{{ r.deletable }}" hint-placeholder-val="{{ false }}">
<button onClick="{{ r.onDelete }}" style="margin-top:4px;background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:11.5px;font-weight:600;color:var(--fg3);" style-hover="color:var(--danger);">삭제</button>
</sc-if>
</div>
</div>
</sc-for>
</div>
</sc-if>
</div>
</sc-for>
</div>
</div>
<div style="padding:14px 24px 22px;border-top:1px solid var(--divider);margin-top:6px;background:var(--bg-soft);">
<sc-if value="{{ replyActive }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:9px;background:var(--accent-bg);border:1px solid var(--accent-bd);border-radius:7px;padding:6px 10px;">
<span style="font-size:12px;color:var(--accent-strong);"><b style="font-variant-numeric:tabular-nums;">{{ replyTargetId }}</b> 님에게 답글 작성 중</span>
<button onClick="{{ onCancelReply }}" style="background:transparent;border:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--link);">취소</button>
</div>
</sc-if>
<div style="display:flex;gap:10px;align-items:flex-end;">
<span style="display:inline-flex;flex:0 0 auto;" innerel="{{ headerAvatarLightEl }}">{{ headerAvatarLightEl }}</span>
<textarea value="{{ commentDraft }}" onInput="{{ onCommentInput }}" onKeyDown="{{ onCommentKey }}" placeholder="{{ commentPlaceholder }}" rows="1" style="flex:1;resize:none;font-family:inherit;font-size:13.5px;line-height:1.5;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:9px;padding:9px 12px;outline:none;min-height:40px;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);"></textarea>
<button onClick="{{ onSubmitComment }}" style="{{ submitBtnStyle }}">등록</button>
</div>
<div style="margin-top:7px;font-size:11px;color:var(--fg3);padding-left:38px;">Enter 등록 · Shift+Enter 줄바꿈 · 대댓글은 1뎁스까지만 가능합니다</div>
</div>
</div>
</div>
</sc-if>
<!-- ===================== SHARE MODAL ===================== -->
<sc-if value="{{ shareOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ onCloseShare }}" style="position:fixed;inset:0;z-index:500;background:var(--overlay);display:flex;align-items:flex-start;justify-content:center;padding:64px 20px;overflow-y:auto;animation:aidev-fade .16s ease-out;">
<div onClick="{{ stopProp }}" style="width:100%;max-width:520px;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px var(--shadow-strong);overflow:hidden;animation:aidev-pop .18s ease-out;">
<div style="padding:20px 24px;border-bottom:1px solid var(--divider);display:flex;align-items:center;justify-content:space-between;">
<h3 style="margin:0;font-size:17px;font-weight:800;color:var(--fg);">새 프로젝트 등록</h3>
<button onClick="{{ onCloseShare }}" style="width:32px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--fg2);" style-hover="background:var(--panel);border-color:var(--border-soft);" innerel="{{ closeIconEl }}">{{ closeIconEl }}</button>
</div>
<div style="padding:20px 24px;display:flex;flex-direction:column;gap:15px;">
<label style="display:block;">
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">제목 <span style="color:var(--danger);">*</span></span>
<input value="{{ shareTitle }}" onInput="{{ onShareTitle }}" placeholder="예) 방화벽 정책 DSL 변환기" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
</label>
<label style="display:block;">
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">한 줄 설명</span>
<input value="{{ shareDesc }}" onInput="{{ onShareDesc }}" placeholder="무엇을 하는 프로젝트인가요?" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
</label>
<label style="display:block;">
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">태그 <span style="font-weight:500;color:var(--fg3);">쉼표로 구분</span></span>
<input value="{{ shareTags }}" onInput="{{ onShareTags }}" placeholder="infra, react, poc" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
</label>
<label style="display:block;">
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">배포 URL <span style="font-weight:500;color:var(--fg3);">선택 · 없으면 바로가기 숨김</span></span>
<input value="{{ shareUrl }}" onInput="{{ onShareUrl }}" placeholder="https://myapp.apps.bokdev.in" style="width:100%;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:13px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
</label>
<div>
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">공개 범위</span>
<div style="display:inline-flex;background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:3px;gap:3px;">
<button onClick="{{ onShareVisPublic }}" style="{{ visPublicStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniEl }}">{{ globeMiniEl }}</span>공개</button>
<button onClick="{{ onShareVisPrivate }}" style="{{ visPrivateStyle }}"><span style="display:inline-flex;" innerel="{{ lockMiniEl }}">{{ lockMiniEl }}</span>비공개</button>
</div>
<div style="margin-top:7px;font-size:11.5px;color:var(--fg3);">{{ visHint }}</div>
</div>
</div>
<div style="padding:16px 24px;border-top:1px solid var(--divider);background:var(--bg-soft);display:flex;align-items:center;justify-content:flex-end;gap:9px;">
<button onClick="{{ onCloseShare }}" style="background:var(--btn-bg);border:1px solid var(--btn-bd);border-radius:8px;padding:9px 16px;cursor:pointer;font-family:inherit;font-size:13.5px;font-weight:600;color:var(--fg);" style-hover="background:var(--btn-hover);">취소</button>
<button onClick="{{ onSubmitShare }}" style="{{ shareSubmitStyle }}">등록</button>
</div>
</div>
</div>
</sc-if>
<!-- ===================== TOAST ===================== -->
<sc-if value="{{ toastOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:600;background:#1f2328;color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px 16px;font-size:13px;font-weight:500;box-shadow:0 8px 24px rgba(1,4,9,.4);display:flex;align-items:center;gap:9px;max-width:90vw;animation:aidev-toast 2.6s ease forwards;">
<span style="display:inline-flex;color:#3fb950;" innerel="{{ toastIconEl }}">{{ toastIconEl }}</span>{{ toastMsg }}
</div>
</sc-if>
<!-- ===================== FLYERS OVERLAY ===================== -->
<div style="position:fixed;inset:0;z-index:650;pointer-events:none;overflow:hidden;">
<sc-for list="{{ flyers }}" as="f" hint-placeholder-count="0">
<span style="{{ f.style }}">{{ f.content }}</span>
</sc-for>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1180,&quot;height&quot;:1240},&quot;brandName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;AI DEV&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;브랜드&quot;},&quot;defaultTheme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;light&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;테마&quot;},&quot;defaultAdminView&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;권한&quot;},&quot;defaultSort&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;latest&quot;,&quot;options&quot;:[&quot;latest&quot;,&quot;popular&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;피드&quot;},&quot;enableEasterEggs&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;재미 요소&quot;}}">
class Component extends DCLogic {
constructor(props) {
super(props);
const sortDefault = (this.props.defaultSort === 'popular') ? 'popular' : 'latest';
this.state = {
theme: 'light',
adminView: this.props.defaultAdminView === true,
userMenuOpen: false,
sortMode: sortDefault,
myFilter: 'all',
feedView: 'all',
searchQuery: '',
modalId: null,
shareOpen: false,
commentDraft: '',
replyParent: null,
shareTitle: '', shareDesc: '', shareTags: '', shareUrl: '', shareVisibility: 'public',
toast: null,
flyers: [],
projects: this.seedProjects(),
};
this.logoRef = React.createRef();
this._fid = 0; this._logoClicks = 0; this._logoTimer = null; this._starTaps = {}; this._toastTimer = null; this._cid = 1000;
}
seedProjects() {
const me = '0310700';
return [
{ id: 1, ownerId: me, title: '방화벽 정책 DSL 변환기', description: '사람이 읽는 규칙을 장비별 설정으로 컴파일하는 사내 DSL. 검토·승인 후 배포.', tags: ['infra', 'fastapi', 'internal'], lang: 'Python', appUrl: 'https://fwdsl.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0310700/fw-dsl', shared: true, createdAt: '2026.06.22', createdAtN: 22, heartCount: 19, hearted: false, likeCount: 14, liked: false, comments: [
{ id: 11, userId: '0298211', parentId: null, body: '규칙 파싱은 PEG로 하셨어요? 오류 메시지가 꽤 친절하네요.', time: '3일 전', stamped: false },
{ id: 12, userId: '0310700', parentId: 11, body: 'lark 기반 PEG입니다. 라인/컬럼까지 잡아줘서 디버깅이 편해요.', time: '3일 전', stamped: false },
] },
{ id: 2, ownerId: me, title: 'QR 출결 앱 Attendly', description: '세미나·교육 QR 체크인과 통계 대시보드. 주말에 만든 사이드 프로젝트.', tags: ['react', 'k8s', 'poc'], lang: 'TypeScript', appUrl: 'https://attendly.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0310700/attendly', shared: true, createdAt: '2026.06.18', createdAtN: 18, heartCount: 28, hearted: false, likeCount: 9, liked: false, comments: [
{ id: 21, userId: '0265533', parentId: null, body: '지난 세미나 때 잘 썼습니다. 출석률 그래프 깔끔하더라구요.', time: '5일 전', stamped: false },
] },
{ id: 7, ownerId: me, title: '사번 OCR 자동입력 확장', description: '사내 폼에 사번을 자동으로 채워주는 크롬 확장. 아직 다듬는 중이라 비공개.', tags: ['poc', 'internal'], lang: 'TypeScript', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0310700/sabun-ocr', shared: false, createdAt: '2026.06.27', createdAtN: 27, heartCount: 0, hearted: false, likeCount: 0, liked: false, comments: [] },
{ id: 3, ownerId: '0298211', title: '사내 패치 저장소 콘솔', description: '폐쇄망 오프라인 패치 미러링과 2인 승인 워크플로를 관리하는 운영 콘솔.', tags: ['infra', 'internal'], lang: 'Go', appUrl: 'https://patchhub.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0298211/patchhub', shared: true, createdAt: '2026.06.25', createdAtN: 25, heartCount: 31, hearted: false, likeCount: 23, liked: true, comments: [
{ id: 31, userId: '0310700', parentId: null, body: '오프라인 미러링 무결성은 어떻게 검증하세요? 해시 비교인가요?', time: '2일 전', stamped: false },
{ id: 32, userId: '0298211', parentId: 31, body: '네, sha256 매니페스트 비교 후 통과한 것만 승인 큐로 넘어갑니다.', time: '2일 전', stamped: false },
{ id: 33, userId: '0310700', parentId: 31, body: '오 깔끔하네요 👍', time: '2일 전', stamped: false },
{ id: 34, userId: '0311902', parentId: null, body: '구조 깔끔합니다. 저희 팀 패치 배포에도 붙이고 싶어요. LGTM 👍', time: '어제', stamped: true },
{ id: 35, userId: '0265533', parentId: null, body: '승인 워크플로에 2인 결재 외에 긴급 단독 승인 모드도 있나요?', time: '7시간 전', stamped: false },
] },
{ id: 4, ownerId: '0311902', title: 'LLM 사내 위키 검색', description: '사내 문서를 RAG로 검색하는 프로토타입. LiteLLM 게이트웨이 경유로 모델 교체 자유.', tags: ['poc', 'fastapi', 'internal'], lang: 'Python', appUrl: 'https://wikillm.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0311902/wiki-rag', shared: true, createdAt: '2026.06.20', createdAtN: 20, heartCount: 12, hearted: false, likeCount: 18, liked: true, comments: [
{ id: 41, userId: '0287740', parentId: null, body: '청크 사이즈랑 임베딩 모델 뭐 쓰셨는지 공유 가능할까요?', time: '4일 전', stamped: false },
] },
{ id: 5, ownerId: '0265533', title: '회의실 예약 슬랙봇', description: '슬랙 슬래시 커맨드로 회의실 예약·취소·조회. 캘린더 연동까지.', tags: ['poc', 'internal'], lang: 'Python', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0265533/room-bot', shared: true, createdAt: '2026.06.12', createdAtN: 12, heartCount: 15, hearted: false, likeCount: 6, liked: false, comments: [] },
{ id: 6, ownerId: '0287740', title: '로그 이상탐지 대시보드', description: 'k8s 로그 스트림에서 이상치를 실시간 시각화. 아직 URL 정리 중이라 저장소만 공개.', tags: ['k8s', 'react', 'infra'], lang: 'TypeScript', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0287740/log-anomaly', shared: true, createdAt: '2026.06.15', createdAtN: 15, heartCount: 7, hearted: false, likeCount: 11, liked: false, comments: [
{ id: 61, userId: '0310700', parentId: null, body: '이상탐지 임계값은 고정인가요, 아니면 학습 기반인가요?', time: '6일 전', stamped: false },
{ id: 62, userId: '0287740', parentId: 61, body: 'EWMA 기반 동적 임계값이에요. 트래픽 패턴 따라 조정됩니다.', time: '6일 전', stamped: false },
] },
];
}
langColor(l) { return ({ Python: '#3572A5', TypeScript: '#3178c6', Go: '#00ADD8', JavaScript: '#f1e05a', Rust: '#dea584', Shell: '#89e051' })[l] || '#8b949e'; }
/* ---------- icons ---------- */
oct(path, o) { o = o || {}; const s = o.size || 16, f = o.fill || 'currentColor'; return '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s + '" viewBox="0 0 16 16" fill="' + f + '" style="display:block">' + path + '</svg>'; }
html(str, style) { return React.createElement('span', { style: Object.assign({ display: 'inline-flex' }, style || {}), dangerouslySetInnerHTML: { __html: str } }); }
OC(name) {
const P = {
code: '<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>',
search: '<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>',
sun: '<path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.061a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.751.751 0 0 1-1.061-1.061l1.06-1.06a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.751.751 0 0 1-1.061 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-1.06 1.06l-1.061-1.06a.75.75 0 0 1 0-1.06Z"/>',
moon: '<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/>',
caret: '<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>',
signout: '<path d="M2 2.75C2 1.784 2.784 1 3.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/>',
lock: '<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/>',
globe: '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z"/>',
repo: '<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/>',
starFill: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>',
star: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/>',
heart: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002ZM4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.58 20.58 0 0 0 8 13.393a20.58 20.58 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5Z"/>',
heartFill: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"/>',
comment: '<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>',
plus: '<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>',
x: '<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>',
reply: '<path d="M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"/>',
check: '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>',
ext: '<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>',
package: '<path d="m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z"/>',
server: '<path d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v4c0 .372-.116.717-.314 1 .198.283.314.628.314 1v4a1.75 1.75 0 0 1-1.75 1.75H1.75A1.75 1.75 0 0 1 0 12.75v-4c0-.358.109-.707.314-1a1.739 1.739 0 0 1-.314-1v-4C0 1.784.784 1 1.75 1ZM1.5 2.75v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm.25 5.75a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25ZM7 4.75A.75.75 0 0 1 7.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 7 4.75ZM7.75 10h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM3 4.75A.75.75 0 0 1 3.75 4h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 4.75ZM3.75 10h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z"/>',
database: '<path d="M8 0c4.42 0 8 1.234 8 2.75v10.5C16 14.766 12.42 16 8 16s-8-1.234-8-2.75V2.75C0 1.234 3.58 0 8 0Zm6.5 9.376c-1.732.886-4.041 1.374-6.5 1.374s-4.768-.488-6.5-1.374v3.874c0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 14.872 6.348 15 8 15c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582Zm0-3.5c-1.732.886-4.041 1.374-6.5 1.374s-4.768-.488-6.5-1.374v1.624c0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 11.372 6.348 11.5 8 11.5c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582ZM8 1.5c-1.652 0-3.14.128-4.512.518-.7.198-1.213.425-1.54.65-.332.229-.448.437-.448.582 0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 5.872 6.348 6 8 6c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582 0-.145-.116-.353-.448-.582-.327-.225-.84-.452-1.54-.65C11.14 1.628 9.652 1.5 8 1.5Z"/>',
};
return P[name] || P.repo;
}
starSvg(filled, size) { return this.oct(filled ? this.OC('starFill') : this.OC('star'), { size: size || 15, fill: filled ? 'var(--attn)' : 'currentColor' }); }
heartSvg(filled, size) { return this.oct(filled ? this.OC('heartFill') : this.OC('heart'), { size: size || 14, fill: filled ? 'var(--heart)' : 'currentColor' }); }
hue(seed) { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; return h; }
identicon(seed, size) {
size = size || 28;
const h = this.hue(seed), hue = h % 360, sat = 48 + (h % 22);
const color = 'hsl(' + hue + ',' + sat + '%,52%)';
const grid = 5, on = new Array(25).fill(false);
for (let col = 0; col < 3; col++) for (let row = 0; row < 5; row++) { const bit = (h >> (col * 5 + row)) & 1; on[row * 5 + col] = !!bit; on[row * 5 + (4 - col)] = !!bit; }
const inner = Math.round(size * 0.74), cell = inner / grid; let rects = '';
for (let r = 0; r < grid; r++) for (let c = 0; c < grid; c++) if (on[r * grid + c]) rects += '<rect x="' + (c * cell).toFixed(2) + '" y="' + (r * cell).toFixed(2) + '" width="' + cell.toFixed(2) + '" height="' + cell.toFixed(2) + '"/>';
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + inner + '" height="' + inner + '" viewBox="0 0 ' + inner + ' ' + inner + '" fill="' + color + '" style="display:block">' + rects + '</svg>';
return React.createElement('span', { style: { width: size, height: size, borderRadius: Math.max(5, Math.round(size * 0.2)), background: 'var(--avatar-bg)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', overflow: 'hidden', boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent)' }, dangerouslySetInnerHTML: { __html: svg } });
}
toolIconEl(tool, isAdmin, ink, adminInk) {
const color = isAdmin ? adminInk : ink;
if (tool.slug) {
return React.createElement('img', { src: 'https://cdn.simpleicons.org/' + tool.slug + '/' + color, width: 23, height: 23, alt: tool.name + ' 로고', draggable: false, loading: 'lazy', style: { display: 'block', width: 23, height: 23 } });
}
return this.html(this.oct(this.OC(tool.glyph), { size: 23, fill: isAdmin ? 'var(--admin-fg)' : 'var(--fg)' }));
}
/* ---------- theme ---------- */
applyTheme(t) { try { document.documentElement.setAttribute('data-aidev-theme', t); } catch (e) {} }
toggleTheme() { const t = this.state.theme === 'dark' ? 'light' : 'dark'; this.applyTheme(t); try { localStorage.setItem('aidev-theme', t); } catch (e) {} this.setState({ theme: t }); }
/* ---------- effects ---------- */
eggsOn() { return this.props.enableEasterEggs !== false; }
addFlyer(content, style, ttl) { const id = 'fly' + (this._fid++); this.setState(s => ({ flyers: s.flyers.concat([{ id, content, style }]) })); setTimeout(() => { this.setState(s => ({ flyers: s.flyers.filter(f => f.id !== id) })); }, ttl); }
base(extra) { return Object.assign({ position: 'fixed', pointerEvents: 'none', userSelect: 'none', willChange: 'transform,opacity' }, extra); }
burstThumbs() { if (!this.eggsOn()) return; const cx = window.innerWidth / 2; for (let i = 0; i < 6; i++) { const dx = (Math.random() - 0.5) * 180; this.addFlyer('👍', this.base({ left: (cx + dx) + 'px', top: (window.innerHeight * 0.62) + 'px', fontSize: (22 + Math.random() * 14) + 'px', animation: 'aidev-flyup ' + (0.9 + Math.random() * 0.3) + 's ease-out ' + (i * 60) + 'ms forwards', opacity: 0 }), 1500); } }
burstRocket() { if (!this.eggsOn()) return; this.addFlyer('🚀', this.base({ left: '-40px', top: (window.innerHeight * (0.18 + Math.random() * 0.2)) + 'px', fontSize: '34px', animation: 'aidev-rocket 1.1s ease-in forwards' }), 1300); }
burstSparkle(x, y) { if (!this.eggsOn()) return; const chars = ['✨', '⭐', '💫']; for (let i = 0; i < 7; i++) { const dx = (Math.random() - 0.5) * 90, dy = (Math.random() - 0.5) * 90; this.addFlyer(chars[i % 3], this.base({ left: (x + dx) + 'px', top: (y + dy) + 'px', fontSize: (12 + Math.random() * 12) + 'px', animation: 'aidev-sparkle ' + (0.5 + Math.random() * 0.3) + 's ease-out forwards' }), 900); } }
showToast(msg) { this.setState({ toast: msg }); if (this._toastTimer) clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => this.setState({ toast: null }), 2550); }
/* ---------- handlers ---------- */
toggleAdmin() { this.setState(s => ({ adminView: !s.adminView })); }
toggleUserMenu() { this.setState(s => ({ userMenuOpen: !s.userMenuOpen })); }
logout() { this.setState({ userMenuOpen: false }); this.showToast('로그아웃 — Keycloak 세션 종료 (데모)'); }
logoClick() {
if (!this.eggsOn()) return;
this._logoClicks++; clearTimeout(this._logoTimer); this._logoTimer = setTimeout(() => { this._logoClicks = 0; }, 700);
if (this._logoClicks >= 4) { this._logoClicks = 0; const el = this.logoRef.current; if (el) { el.style.animation = 'aidev-shake .5s ease'; const old = el.style.filter; el.style.filter = 'hue-rotate(180deg) saturate(1.5)'; setTimeout(() => { el.style.animation = ''; el.style.filter = old; }, 520); } this.burstThumbs(); this.showToast('🌈 git commit -m "found an easter egg"'); }
}
openTool(tool) { this.setState({ userMenuOpen: false }); this.showToast(tool.name + ' · SSO 자동로그인으로 새 탭에서 열기 (데모)'); try { console.log('%c[AI DEV] open tool → ' + tool.name + ' (' + tool.url + ', target=_blank)', 'color:#0969da'); } catch (e) {} }
openApp(p, e) { if (e) e.stopPropagation(); this.showToast('바로가기 → ' + (p.appUrl || '') + ' (새 탭, 데모)'); }
openRepo(p, e) { if (e) e.stopPropagation(); this.showToast('저장소 → ' + (p.repoUrl || '') + ' (새 탭, 데모)'); }
toggleLike(id, e) {
if (e) e.stopPropagation();
const now = Date.now(); const taps = (this._starTaps[id] || []).filter(t => now - t < 1400); taps.push(now); this._starTaps[id] = taps;
if (taps.length >= 4 && e) this.burstSparkle(e.clientX, e.clientY);
this.setState(s => ({ projects: s.projects.map(p => p.id === id ? Object.assign({}, p, { liked: !p.liked, likeCount: p.likeCount + (p.liked ? -1 : 1) }) : p) }));
}
toggleHeart(id, e) { if (e) e.stopPropagation(); this.setState(s => ({ projects: s.projects.map(p => p.id === id ? Object.assign({}, p, { hearted: !p.hearted, heartCount: (p.heartCount || 0) + (p.hearted ? -1 : 1) }) : p) })); }
toggleShare(id) {
let nowShared = false, title = '';
this.setState(s => ({ projects: s.projects.map(p => { if (p.id !== id) return p; nowShared = !p.shared; title = p.title; return Object.assign({}, p, { shared: !p.shared }); }) }), () => { this.showToast(nowShared ? '“' + title + '” 공개됨 — 이제 피드에 표시됩니다' : '“' + title + '” 비공개로 전환됨'); });
}
openModal(id) { this.setState({ modalId: id, userMenuOpen: false, commentDraft: '', replyParent: null }); try { document.body.style.overflow = 'hidden'; } catch (e) {} }
closeModal() { this.setState({ modalId: null, replyParent: null, commentDraft: '' }); try { document.body.style.overflow = ''; } catch (e) {} }
startReply(commentId) { this.setState({ replyParent: commentId }); }
cancelReply() { this.setState({ replyParent: null }); }
detectStamp(body) { return /\blgtm\b/i.test(body); }
hasRocket(body) { return body.indexOf('🚀') >= 0 || body.toLowerCase().indexOf(':shipit:') >= 0; }
submitComment() {
const body = (this.state.commentDraft || '').trim(); if (!body || this.state.modalId == null) return;
const id = this._cid++; const stamped = this.detectStamp(body), rocket = this.hasRocket(body); const parentId = this.state.replyParent;
const newC = { id, userId: '0310700', parentId, body, time: '방금 전', stamped };
this.setState(s => ({ projects: s.projects.map(p => p.id === s.modalId ? Object.assign({}, p, { comments: p.comments.concat([newC]) }) : p), commentDraft: '', replyParent: null }));
if (stamped) { this.burstThumbs(); this.showToast('LGTM 👍 — Looks Good To Me!'); }
if (rocket) this.burstRocket();
}
onCommentKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.submitComment(); } }
deleteComment(cid) { this.setState(s => ({ projects: s.projects.map(p => p.id !== s.modalId ? p : Object.assign({}, p, { comments: p.comments.filter(c => c.id !== cid && c.parentId !== cid) })) })); }
openShare() { this.setState({ shareOpen: true, userMenuOpen: false, shareTitle: '', shareDesc: '', shareTags: '', shareUrl: '', shareVisibility: 'public' }); try { document.body.style.overflow = 'hidden'; } catch (e) {} }
closeShare() { this.setState({ shareOpen: false }); try { document.body.style.overflow = ''; } catch (e) {} }
langFromTags(tags) { const t = tags.map(x => x.toLowerCase()); if (t.indexOf('fastapi') >= 0 || t.indexOf('python') >= 0) return 'Python'; if (t.indexOf('react') >= 0 || t.indexOf('ts') >= 0) return 'TypeScript'; if (t.indexOf('go') >= 0 || t.indexOf('k8s') >= 0) return 'Go'; return 'TypeScript'; }
submitShare() {
const title = (this.state.shareTitle || '').trim(); if (!title) { this.showToast('제목을 입력해주세요'); return; }
const tags = (this.state.shareTags || '').split(',').map(t => t.trim().replace(/^#/, '')).filter(Boolean).slice(0, 5);
const id = Date.now(), url = (this.state.shareUrl || '').trim(); const fin = tags.length ? tags : ['internal'];
const isPub = this.state.shareVisibility !== 'private';
const np = { id, ownerId: '0310700', title, description: (this.state.shareDesc || '').trim() || '설명 없음', tags: fin, lang: this.langFromTags(fin), appUrl: url, repoUrl: '', shared: isPub, createdAt: '방금', createdAtN: 999, likeCount: 0, liked: false, heartCount: 0, hearted: false, comments: [] };
this.setState(s => ({ projects: [np].concat(s.projects), shareOpen: false, myFilter: isPub ? 'all' : 'private' }));
try { document.body.style.overflow = ''; } catch (e) {}
this.showToast(isPub ? '“' + title + '” 등록 완료 · 피드에 공유됨' : '“' + title + '” 비공개로 등록됨');
}
componentDidMount() {
let t = 'light';
try { t = localStorage.getItem('aidev-theme') || this.props.defaultTheme || 'light'; } catch (e) { t = this.props.defaultTheme || 'light'; }
if (t !== 'light' && t !== 'dark') t = 'light';
this.applyTheme(t); this.setState({ theme: t });
if (!this.eggsOn()) return;
try {
const art = [' ___ ____ ____ _______ __', ' / | / _/ / __ \\/ ____/ | / /', ' / /| | / / / / / / __/ | | / / ', '/ ___ |_/ / / /_/ / /___ | |/ / ', '/_/ |_/___/ /_____/_____/ |___/ '].join('\n');
console.log('%c' + art, 'color:#0969da;font-family:monospace;font-size:11px;line-height:1.1');
console.log('%c👀 코드 구경 왔어요? 환영합니다.', 'color:#1f883d;font-size:13px;font-weight:bold');
console.log('%c여기까지 열어봤다면 우리랑 잘 맞을지도. 사내 개발팀은 늘 동료를 찾고 있어요 — git blame 하지 말고 git praise 합시다. 🙇', 'color:#656d76;font-size:12px');
console.log('%cps. 댓글에 LGTM 한 번 쳐보세요.', 'color:#8b949e;font-size:11px');
} catch (e) {}
}
componentWillUnmount() { try { document.body.style.overflow = ''; } catch (e) {} }
threadFor(p) {
const me = '0310700';
return p.comments.filter(c => c.parentId == null).map(c => {
const replies = p.comments.filter(r => r.parentId === c.id).map(r => ({ id: r.id, userId: r.userId, time: r.time, body: r.body, stamped: r.stamped, avatarEl: this.identicon(r.userId, 24), deletable: r.userId === me, onDelete: (e) => { if (e) e.stopPropagation(); this.deleteComment(r.id); } }));
return { id: c.id, userId: c.userId, time: c.time, body: c.body, stamped: c.stamped, avatarEl: this.identicon(c.userId, 28), hasReplies: replies.length > 0, replies, deletable: c.userId === me, onReply: (e) => { if (e) e.stopPropagation(); this.startReply(c.id); }, onDelete: (e) => { if (e) e.stopPropagation(); this.deleteComment(c.id); } };
});
}
mapProject(p) {
const shared = p.shared !== false;
const visBadgeStyle = {
display: 'inline-flex', alignItems: 'center', gap: '5px', flex: '0 0 auto',
fontSize: '11px', fontWeight: 600, borderRadius: '999px', padding: '1px 8px 1px 7px', whiteSpace: 'nowrap',
color: shared ? 'var(--success-fg)' : 'var(--fg2)',
background: shared ? 'var(--success-bg)' : 'var(--panel)',
border: '1px solid ' + (shared ? 'var(--success-bd)' : 'var(--border)'),
};
return {
id: p.id, title: p.title, description: p.description, ownerId: p.ownerId, createdAt: p.createdAt, tags: p.tags,
lang: p.lang || 'TypeScript', langColor: this.langColor(p.lang || 'TypeScript'),
likeCount: p.likeCount, commentCount: p.comments.length, liked: p.liked,
shared, isPrivate: !shared,
visLabel: shared ? '공개' : '비공개',
visIconEl: this.html(this.oct(this.OC(shared ? 'globe' : 'lock'), { size: 11 })),
visBadgeStyle,
hasAppUrl: !!p.appUrl, hasRepo: !!p.repoUrl,
avatarEl: this.identicon(p.ownerId, 22),
starEl: this.html(this.starSvg(p.liked, 14)), starMiniEl: this.html(this.starSvg(true, 13)),
starLabel: p.liked ? 'Starred' : 'Star',
heartCount: p.heartCount || 0, hearted: !!p.hearted,
heartEl: this.html(this.heartSvg(p.hearted, 14)), heartMiniEl: this.html(this.heartSvg(true, 13)),
heartBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', flex: '0 0 auto', background: p.hearted ? 'var(--heart-bg)' : 'var(--btn-bg)', border: '1px solid ' + (p.hearted ? 'var(--heart-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '4px 9px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 600, color: p.hearted ? 'var(--heart-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' },
onToggleHeart: (e) => this.toggleHeart(p.id, e),
myCardStyle: { background: shared ? 'var(--card)' : 'var(--bg)', border: shared ? '1px solid var(--border-soft)' : '1.5px dashed var(--border)', borderRadius: '12px', padding: '15px 16px 14px', cursor: 'pointer', transition: 'box-shadow .16s ease,border-color .16s ease,transform .16s ease', display: 'flex', flexDirection: 'column' },
starBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', flex: '0 0 auto', background: p.liked ? 'var(--attn-bg)' : 'var(--btn-bg)', border: '1px solid ' + (p.liked ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '4px 9px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 600, color: p.liked ? 'var(--attn-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' },
onOpen: () => this.openModal(p.id),
onToggleLike: (e) => this.toggleLike(p.id, e),
onToggleShare: (e) => { if (e) e.stopPropagation(); this.toggleShare(p.id); },
onOpenApp: (e) => this.openApp(p, e),
onOpenRepo: (e) => this.openRepo(p, e),
};
}
renderVals() {
const me = '0310700', admin = this.state.adminView, dark = this.state.theme === 'dark';
const ink = dark ? 'e6edf3' : '1f2328', adminInk = dark ? 'bc8cff' : '8250df';
const userTools = [
{ key: 'coder', slug: 'coder', name: 'Coder', desc: '클라우드 IDE' },
{ key: 'gitea', slug: 'gitea', name: 'Gitea', desc: 'Git 저장소' },
{ key: 'kubero', glyph: 'package', name: 'Kubero', desc: '배포 (PaaS)' },
{ key: 'harbor', slug: 'harbor', name: 'Harbor', desc: '컨테이너 레지스트리' },
];
const adminTools = [
{ key: 'openeverest', glyph: 'database', name: 'OpenEverest', desc: 'DB 콘솔' },
{ key: 'minio', slug: 'minio', name: 'MinIO', desc: '오브젝트 스토리지' },
{ key: 'keycloak', slug: 'keycloak', name: 'Keycloak', desc: '계정 / SSO 관리' },
{ key: 'litellm', glyph: 'server', name: 'LiteLLM', desc: 'LLM 게이트웨이' },
];
const toolList = admin ? userTools.concat(adminTools) : userTools;
const visibleTools = toolList.map((t, i) => {
const isAdmin = i >= 4;
return {
name: t.name, desc: t.desc, isAdmin, url: 'https://' + t.key + '.bokdev.in/sso',
iconEl: this.toolIconEl(t, isAdmin, ink, adminInk),
iconWrapStyle: isAdmin ? { background: 'var(--admin-bg)', border: '1px solid var(--admin-bd)' } : { background: 'var(--tile)', border: '1px solid var(--tile-bd)' },
cardStyle: { position: 'relative', textAlign: 'left', background: 'var(--card)', border: '1px solid ' + (isAdmin ? 'var(--admin-bd)' : 'var(--border-soft)'), borderRadius: '12px', padding: '16px', cursor: 'pointer', fontFamily: 'inherit', transition: 'box-shadow .16s ease,border-color .16s ease,transform .16s ease' },
onOpen: () => this.openTool(t),
};
});
const q = this.state.searchQuery.trim().toLowerCase();
let feed = this.state.projects.filter(p => p.shared !== false);
const starredCount = feed.filter(p => p.liked).length;
if (this.state.feedView === 'starred') feed = feed.filter(p => p.liked);
if (q) feed = feed.filter(p => (p.title + ' ' + p.description + ' ' + p.tags.join(' ') + ' ' + p.ownerId).toLowerCase().indexOf(q) >= 0);
if (this.state.sortMode === 'popular') feed.sort((a, b) => (b.heartCount || 0) - (a.heartCount || 0) || b.likeCount - a.likeCount || b.createdAtN - a.createdAtN);
else feed.sort((a, b) => b.createdAtN - a.createdAtN);
const feedProjects = feed.map(p => this.mapProject(p));
const mineAll = this.state.projects.filter(p => p.ownerId === me).sort((a, b) => b.createdAtN - a.createdAtN);
const mySharedCount = mineAll.filter(p => p.shared !== false).length;
const myPrivateCount = mineAll.length - mySharedCount;
let mineFiltered = mineAll;
if (this.state.myFilter === 'shared') mineFiltered = mineAll.filter(p => p.shared !== false);
else if (this.state.myFilter === 'private') mineFiltered = mineAll.filter(p => p.shared === false);
const myProjects = mineFiltered.map(p => this.mapProject(p));
const modalRaw = this.state.modalId != null ? this.state.projects.find(p => p.id === this.state.modalId) : null;
const modalProject = modalRaw ? this.mapProject(modalRaw) : { tags: [], title: '', description: '', ownerId: '', createdAt: '', commentCount: 0, hasAppUrl: false, hasRepo: false, isPrivate: false, likeCount: 0, heartCount: 0, hearted: false, heartEl: null, lang: '', langColor: '#8b949e', visLabel: '', visIconEl: null, visBadgeStyle: {}, avatarEl: null, starEl: null, starLabel: 'Star' };
const threadedComments = modalRaw ? this.threadFor(modalRaw) : [];
const switchTrackStyle = { width: '34px', height: '20px', borderRadius: '999px', position: 'relative', background: admin ? '#2ea043' : 'var(--hbd)', transition: 'background .18s ease', flex: '0 0 auto', display: 'inline-block' };
const switchKnobStyle = { position: 'absolute', top: '2px', left: admin ? '16px' : '2px', width: '16px', height: '16px', borderRadius: '50%', background: '#fff', transition: 'left .18s ease', boxShadow: '0 1px 2px rgba(0,0,0,.3)' };
const tabBase = { background: 'transparent', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, padding: '8px 14px 14px', transition: 'color .14s ease' };
const tabLatestStyle = Object.assign({}, tabBase, this.state.sortMode === 'latest' ? { color: 'var(--fg)', boxShadow: 'inset 0 -2px 0 var(--tab-underline)' } : { color: 'var(--fg2)' });
const tabPopularStyle = Object.assign({}, tabBase, this.state.sortMode === 'popular' ? { color: 'var(--fg)', boxShadow: 'inset 0 -2px 0 var(--tab-underline)' } : { color: 'var(--fg2)' });
const fv = this.state.feedView === 'starred';
const feedStarBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '6px', background: fv ? 'var(--attn-bg)' : 'transparent', border: '1px solid ' + (fv ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '6px 12px', marginBottom: '8px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: fv ? 'var(--attn-fg)' : 'var(--fg2)', whiteSpace: 'nowrap', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
const fBase = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'transparent', border: 0, borderRadius: '6px', padding: '5px 11px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12.5px', fontWeight: 600, whiteSpace: 'nowrap', transition: 'background .14s ease,color .14s ease' };
const fOn = { background: 'var(--panel)', color: 'var(--fg)', boxShadow: 'inset 0 0 0 1px var(--border)' };
const fOff = { color: 'var(--fg2)' };
const mf = this.state.myFilter;
const filterAllStyle = Object.assign({}, fBase, mf === 'all' ? fOn : fOff);
const filterSharedStyle = Object.assign({}, fBase, mf === 'shared' ? fOn : fOff);
const filterPrivateStyle = Object.assign({}, fBase, mf === 'private' ? fOn : fOff);
const modalStarBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '7px', background: modalProject.liked ? 'var(--attn-bg)' : 'var(--btn-bg)', border: '1px solid ' + (modalProject.liked ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '8px', padding: '8px 14px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: modalProject.liked ? 'var(--attn-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
const modalHeartBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '7px', background: modalProject.hearted ? 'var(--heart-bg)' : 'var(--btn-bg)', border: '1px solid ' + (modalProject.hearted ? 'var(--heart-bd)' : 'var(--btn-bd)'), borderRadius: '8px', padding: '8px 14px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: modalProject.hearted ? 'var(--heart-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
const visBase = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'transparent', border: 0, borderRadius: '6px', padding: '6px 16px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap', transition: 'background .14s ease,color .14s ease' };
const vPub = this.state.shareVisibility !== 'private';
const visPublicStyle = Object.assign({}, visBase, vPub ? { background: 'var(--success-bg)', color: 'var(--success-fg)', boxShadow: 'inset 0 0 0 1px var(--success-bd)' } : { color: 'var(--fg2)' });
const visPrivateStyle = Object.assign({}, visBase, !vPub ? { background: 'var(--panel)', color: 'var(--fg)', boxShadow: 'inset 0 0 0 1px var(--border)' } : { color: 'var(--fg2)' });
const submitBtnStyle = { flex: '0 0 auto', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '9px', padding: '10px 16px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, color: '#fff', transition: 'background .14s ease', alignSelf: 'stretch', whiteSpace: 'nowrap' };
const shareSubmitStyle = { background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '8px', padding: '9px 18px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' };
const shareMiniBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '7px', padding: '4px 10px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', transition: 'background .14s ease' };
return {
currentUser: me, roleLabel: admin ? '관리자 (admin)' : '일반 사용자',
logoRef: this.logoRef,
logoMarkEl: this.html(this.oct(this.OC('code'), { size: 22, fill: 'var(--hfg)' })),
footerMarkEl: this.html(this.oct(this.OC('code'), { size: 14, fill: 'var(--fg3)' })),
onLogoClick: () => this.logoClick(),
headerAvatarEl: this.identicon(me, 26), headerAvatarLightEl: this.identicon(me, 28),
caretEl: this.html(this.oct(this.OC('caret'), { size: 14 })),
searchIconEl: this.html(this.oct(this.OC('search'), { size: 15 })),
searchQuery: this.state.searchQuery, onSearchInput: (e) => this.setState({ searchQuery: e.target.value }),
logoutIconEl: this.html(this.oct(this.OC('signout'), { size: 15 })),
userMenuOpen: this.state.userMenuOpen, onToggleUserMenu: () => this.toggleUserMenu(), onLogout: () => this.logout(),
adminView: admin, onToggleAdmin: () => this.toggleAdmin(), switchTrackStyle, switchKnobStyle,
lockAdminEl: this.html(this.oct(this.OC('lock'), { size: 11, fill: 'var(--admin-fg)' })),
lockMiniEl: this.html(this.oct(this.OC('lock'), { size: 12 })),
globeMiniEl: this.html(this.oct(this.OC('globe'), { size: 12 })),
globeMiniWhiteEl: this.html(this.oct(this.OC('globe'), { size: 12, fill: '#fff' })),
repoIconEl: this.html(this.oct(this.OC('repo'), { size: 16 })),
codeIconEl: this.html(this.oct(this.OC('code'), { size: 14 })),
onToggleTheme: () => this.toggleTheme(),
themeIconEl: this.html(this.oct(this.OC(dark ? 'sun' : 'moon'), { size: 16 })),
themeTitle: dark ? '라이트 모드로 전환' : '다크 모드로 전환',
visibleTools,
myProjects, myTotalCount: mineAll.length, mySharedCount, myPrivateCount,
onFilterAll: () => this.setState({ myFilter: 'all' }), onFilterShared: () => this.setState({ myFilter: 'shared' }), onFilterPrivate: () => this.setState({ myFilter: 'private' }),
filterAllStyle, filterSharedStyle, filterPrivateStyle,
showAddCard: this.state.myFilter === 'all',
myEmpty: myProjects.length === 0,
myEmptyMsg: this.state.myFilter === 'private' ? '비공개 프로젝트가 없습니다.' : (this.state.myFilter === 'shared' ? '공개한 프로젝트가 없습니다.' : '아직 등록한 프로젝트가 없습니다.'),
onOpenShare: () => this.openShare(),
plusIconEl: this.html(this.oct(this.OC('plus'), { size: 18, fill: 'var(--success)' })),
plusWhiteEl: this.html(this.oct(this.OC('plus'), { size: 15, fill: '#fff' })),
newProjectBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '8px', padding: '7px 13px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', transition: 'background .14s ease' },
commentIconEl: this.html(this.oct(this.OC('comment'), { size: 14 })),
shareMiniBtnStyle,
feedProjects, feedEmpty: feedProjects.length === 0 && (!!q || fv),
feedEmptyMsg: fv ? '아직 별표한 프로젝트가 없어요 · 카드의 별 버튼으로 즐겨찾기하세요' : ('“' + this.state.searchQuery + '” 와 일치하는 프로젝트가 없습니다.'),
onSortLatest: () => this.setState({ sortMode: 'latest' }), onSortPopular: () => this.setState({ sortMode: 'popular' }),
tabLatestStyle, tabPopularStyle,
starredCount, feedStarBtnStyle,
feedStarIconEl: this.html(this.starSvg(fv, 14), fv ? {} : { color: 'var(--fg2)' }),
onToggleFeedStarred: () => this.setState(s => ({ feedView: s.feedView === 'starred' ? 'all' : 'starred' })),
extIconEl: this.html(this.oct(this.OC('ext'), { size: 12, fill: 'var(--link)' })),
extIconWhiteEl: this.html(this.oct(this.OC('ext'), { size: 12, fill: '#fff' })),
modalOpen: this.state.modalId != null, modalProject, threadedComments, modalStarBtnStyle, modalHeartBtnStyle,
onCloseModal: () => this.closeModal(), stopProp: (e) => { if (e) e.stopPropagation(); },
closeIconEl: this.html(this.oct(this.OC('x'), { size: 16 })),
checkIconEl: this.html(this.oct(this.OC('check'), { size: 11, fill: 'var(--success-fg)' })),
replyIconEl: this.html(this.oct(this.OC('reply'), { size: 13 })),
commentDraft: this.state.commentDraft, onCommentInput: (e) => this.setState({ commentDraft: e.target.value }), onCommentKey: (e) => this.onCommentKey(e), onSubmitComment: () => this.submitComment(), submitBtnStyle,
replyActive: this.state.replyParent != null,
replyTargetId: (() => { if (this.state.replyParent == null || !modalRaw) return ''; const t = modalRaw.comments.find(c => c.id === this.state.replyParent); return t ? t.userId : ''; })(),
onCancelReply: () => this.cancelReply(),
commentPlaceholder: this.state.replyParent != null ? '답글을 입력하세요…' : '댓글을 입력하세요…',
shareOpen: this.state.shareOpen, onCloseShare: () => this.closeShare(), onSubmitShare: () => this.submitShare(), shareSubmitStyle,
visPublicStyle, visPrivateStyle,
onShareVisPublic: () => this.setState({ shareVisibility: 'public' }), onShareVisPrivate: () => this.setState({ shareVisibility: 'private' }),
visHint: vPub ? '피드에 공개되어 동료가 볼 수 있어요' : '나만 볼 수 있어요 · 언제든 공유로 전환 가능',
shareTitle: this.state.shareTitle, onShareTitle: (e) => this.setState({ shareTitle: e.target.value }),
shareDesc: this.state.shareDesc, onShareDesc: (e) => this.setState({ shareDesc: e.target.value }),
shareTags: this.state.shareTags, onShareTags: (e) => this.setState({ shareTags: e.target.value }),
shareUrl: this.state.shareUrl, onShareUrl: (e) => this.setState({ shareUrl: e.target.value }),
toastOpen: !!this.state.toast, toastMsg: this.state.toast || '',
toastIconEl: this.html(this.oct(this.OC('check'), { size: 14, fill: '#3fb950' })),
flyers: this.state.flyers,
};
}
}
</script>
</body>
</html>

1603
design-refs/support.js Normal file

File diff suppressed because it is too large Load Diff

385
public/app.js Normal file
View File

@@ -0,0 +1,385 @@
/* AI DEV 포털 — 클라이언트 상호작용 (프레임워크 없음, 점진적 향상).
* JS 가 없어도 폼 POST 로 동작하고, 있으면 여기서 즉시 반영/모달/이펙트를 더한다.
*
* DOM 계약 (대시보드 EJS 가 맞춰 렌더):
* - 카드: <article class="fcard|pcard" data-open-id="3" data-search="텍스트"
* data-created="2026-06-25" data-pop="31" data-starred="true|false"
* data-vis="public|private"> ... </article>
* - 반응 버튼: <button data-react data-id="3" data-kind="heart|star" class="...on?">
* <span class="ico ico-on">…</span><span class="ico ico-off">…</span>
* <span class="count" data-rcount>19</span></button>
* - 상세 모달 내용 저장소: <div id="detailStore"><template data-detail-id="3">…</template></div>
* - 답글 버튼: <button data-reply-to="11" data-reply-name="0298211">
* - 토스트/이펙트 신호: body[data-toast], body[data-egg]
*/
(function () {
"use strict";
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var EGGS_ON = true;
/* ---------------- 테마 ---------------- */
function applyTheme(t) { document.documentElement.setAttribute("data-aidev-theme", t); }
function initTheme() {
var btn = $("#themeToggle");
if (!btn) return;
btn.addEventListener("click", function () {
var cur = document.documentElement.getAttribute("data-aidev-theme") === "dark" ? "dark" : "light";
var next = cur === "dark" ? "light" : "dark";
applyTheme(next);
try { localStorage.setItem("aidev-theme", next); } catch (e) {}
});
}
/* ---------------- 사용자 메뉴 ---------------- */
function initUserMenu() {
var btn = $("#userMenuBtn"), pop = $("#userMenuPop");
if (!btn || !pop) return;
btn.addEventListener("click", function (e) {
e.stopPropagation();
var open = pop.hidden;
pop.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", function (e) {
if (!pop.hidden && !pop.contains(e.target) && !btn.contains(e.target)) {
pop.hidden = true; btn.setAttribute("aria-expanded", "false");
}
});
}
/* ---------------- 토스트 ---------------- */
var toastTimer = null;
function toast(msg) {
if (!msg) return;
var old = $(".toast"); if (old) old.remove();
var t = document.createElement("div");
t.className = "toast";
t.innerHTML = '<span class="toast-ico">' + CHECK_SVG + "</span>";
t.appendChild(document.createTextNode(msg));
document.body.appendChild(t);
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { if (t.parentNode) t.remove(); }, 2700);
}
var CHECK_SVG = '<svg width="14" height="14" viewBox="0 0 16 16" fill="#3fb950" aria-hidden="true"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>';
/* ---------------- 이스터에그 (이모지 날리기) ---------------- */
var flyId = 0;
function flyLayer() {
var l = $(".flyers");
if (!l) { l = document.createElement("div"); l.className = "flyers"; document.body.appendChild(l); }
return l;
}
function addFlyer(content, style, ttl) {
var span = document.createElement("span");
span.className = "flyer"; span.textContent = content;
Object.keys(style).forEach(function (k) { span.style[k] = style[k]; });
var l = flyLayer(); l.appendChild(span);
var id = ++flyId; span._id = id;
setTimeout(function () { if (span.parentNode) span.remove(); }, ttl);
}
function burstThumbs() {
if (!EGGS_ON) return;
var cx = window.innerWidth / 2;
for (var i = 0; i < 6; i++) {
var dx = (Math.random() - 0.5) * 180;
addFlyer("👍", {
left: (cx + dx) + "px", top: (window.innerHeight * 0.62) + "px",
fontSize: (22 + Math.random() * 14) + "px",
animation: "aidev-flyup " + (0.9 + Math.random() * 0.3) + "s ease-out " + (i * 60) + "ms forwards",
opacity: 0,
}, 1500);
}
}
function burstRocket() {
if (!EGGS_ON) return;
addFlyer("🚀", {
left: "-40px", top: (window.innerHeight * (0.18 + Math.random() * 0.2)) + "px",
fontSize: "34px", animation: "aidev-rocket 1.1s ease-in forwards",
}, 1300);
}
function burstSparkle(x, y) {
if (!EGGS_ON) return;
var chars = ["✨", "⭐", "💫"];
for (var i = 0; i < 7; i++) {
var dx = (Math.random() - 0.5) * 90, dy = (Math.random() - 0.5) * 90;
addFlyer(chars[i % 3], {
left: (x + dx) + "px", top: (y + dy) + "px",
fontSize: (12 + Math.random() * 12) + "px",
animation: "aidev-sparkle " + (0.5 + Math.random() * 0.3) + "s ease-out forwards",
}, 900);
}
}
/* 로고 이스터에그: 4번 빠르게 클릭하면 흔들림 + 👍 (단일 클릭은 홈 이동) */
function initLogoEgg() {
var logo = $("#brandLink");
if (!logo) return;
var clicks = 0, timer = null, navTimer = null;
var mark = $(".brand-mark", logo);
logo.addEventListener("click", function (e) {
e.preventDefault();
clicks++;
clearTimeout(timer);
timer = setTimeout(function () { clicks = 0; }, 700);
if (navTimer) clearTimeout(navTimer);
if (clicks >= 4) {
clicks = 0;
if (mark) {
mark.style.animation = "aidev-shake .5s ease";
var old = mark.style.filter;
mark.style.filter = "hue-rotate(180deg) saturate(1.5)";
setTimeout(function () { mark.style.animation = ""; mark.style.filter = old; }, 520);
}
burstThumbs();
toast('🌈 git commit -m "found an easter egg"');
return;
}
// 추가 클릭이 없으면 잠시 뒤 홈으로 이동
navTimer = setTimeout(function () { window.location.href = logo.getAttribute("href") || "/"; }, 320);
});
}
/* ---------------- 반응(❤️/⭐) fetch 토글 ---------------- */
function initReactions() {
document.addEventListener("click", function (e) {
var btn = e.target.closest("[data-react]");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
var id = btn.getAttribute("data-id");
var kind = btn.getAttribute("data-kind"); // heart | star
// 별 빠르게 4탭 → 반짝
if (kind === "star") {
starTaps = (starTaps || []).filter(function (t) { return Date.now() - t < 1400; });
starTaps.push(Date.now());
if (starTaps.length >= 4) burstSparkle(e.clientX, e.clientY);
}
fetch("/projects/" + id + "/" + kind, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Accept": "application/json" },
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) { syncReaction(id, kind, data.on, data.count); })
.catch(function () { toast("처리에 실패했습니다. 다시 시도해 주세요."); });
});
}
var starTaps = [];
function syncReaction(id, kind, on, count) {
$$('[data-react][data-id="' + id + '"][data-kind="' + kind + '"]').forEach(function (b) {
b.classList.toggle("on", !!on);
var c = $(".count", b);
if (c) c.textContent = count;
});
}
/* ---------------- 상세 모달 ---------------- */
function openDetail(id) {
var tpl = $('#detailStore template[data-detail-id="' + id + '"]');
var overlay = $("#detailModal");
if (!tpl || !overlay) { window.location.href = "/projects/" + id; return; }
var mount = $(".modal", overlay) || overlay;
mount.innerHTML = tpl.innerHTML;
overlay.hidden = false;
document.body.style.overflow = "hidden";
overlay.setAttribute("data-open-id", id);
var ta = $(".comment-input", overlay);
if (ta) ta.focus();
}
function closeDetail() {
var overlay = $("#detailModal");
if (!overlay || overlay.hidden) return;
overlay.hidden = true;
overlay.removeAttribute("data-open-id");
document.body.style.overflow = "";
}
function initDetailModal() {
document.addEventListener("click", function (e) {
var opener = e.target.closest("[data-open-id]");
if (opener && !e.target.closest("[data-react]") && !e.target.closest("a") && !e.target.closest("[data-stop]")) {
e.preventDefault();
openDetail(opener.getAttribute("data-open-id"));
return;
}
if (e.target.closest("[data-close-detail]")) { e.preventDefault(); closeDetail(); return; }
var overlay = $("#detailModal");
if (overlay && !overlay.hidden && e.target === overlay) closeDetail();
});
document.addEventListener("keydown", function (e) { if (e.key === "Escape") { closeDetail(); closeShare(); } });
}
/* ---------------- 댓글: 답글 타깃 / Enter 등록 / 삭제 확인 ---------------- */
function initComments() {
document.addEventListener("click", function (e) {
var rb = e.target.closest("[data-reply-to]");
if (rb) {
e.preventDefault();
var scope = rb.closest(".modal") || document;
var parentInput = $('input[name="parent_id"]', scope);
var banner = $(".reply-banner", scope);
var ta = $(".comment-input", scope);
if (parentInput) parentInput.value = rb.getAttribute("data-reply-to");
if (banner) {
banner.hidden = false;
var who = $(".reply-banner-name", banner);
if (who) who.textContent = rb.getAttribute("data-reply-name") || "";
}
if (ta) { ta.placeholder = "답글을 입력하세요…"; ta.focus(); }
return;
}
if (e.target.closest("[data-cancel-reply]")) {
e.preventDefault();
var scope2 = e.target.closest(".modal") || document;
var pi = $('input[name="parent_id"]', scope2); if (pi) pi.value = "";
var bn = $(".reply-banner", scope2); if (bn) bn.hidden = true;
var ta2 = $(".comment-input", scope2); if (ta2) ta2.placeholder = "댓글을 입력하세요…";
return;
}
var del = e.target.closest("[data-confirm]");
if (del) {
if (!window.confirm(del.getAttribute("data-confirm"))) e.preventDefault();
}
});
// Enter 등록 / Shift+Enter 줄바꿈
document.addEventListener("keydown", function (e) {
var ta = e.target;
if (ta && ta.classList && ta.classList.contains("comment-input") && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
var form = ta.closest("form");
if (form && ta.value.trim()) form.submit();
}
});
}
/* ---------------- 공유(등록) 모달 ---------------- */
function openShare() {
var m = $("#shareModal"); if (!m) return;
m.hidden = false; document.body.style.overflow = "hidden";
var t = $('input[name="title"]', m); if (t) t.focus();
}
function closeShare() {
var m = $("#shareModal"); if (!m || m.hidden) return;
m.hidden = true; document.body.style.overflow = "";
}
function initShare() {
document.addEventListener("click", function (e) {
if (e.target.closest("[data-open-share]")) { e.preventDefault(); openShare(); return; }
if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; }
var m = $("#shareModal");
if (m && !m.hidden && e.target === m) closeShare();
// 공개/비공개 토글
var vo = e.target.closest("[data-vis]");
if (vo && m) {
var val = vo.getAttribute("data-vis");
var input = $('input[name="is_public"]', m);
if (input) input.value = val === "public" ? "1" : "0";
$$("[data-vis]", m).forEach(function (b) {
var pub = b.getAttribute("data-vis") === "public";
b.classList.toggle("on-public", pub && val === "public");
b.classList.toggle("on-private", !pub && val === "private");
});
var hint = $(".share-vishint", m);
if (hint) hint.textContent = val === "public" ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능";
}
});
}
/* ---------------- 검색 / 정렬 / 필터 (클라이언트, 피드 대상) ---------------- */
function applyFeed() {
var grid = $("#feedGrid"); if (!grid) return;
var q = ($("#feedSearch") && $("#feedSearch").value || "").trim().toLowerCase();
var starOnly = $("#feedStarFilter") && $("#feedStarFilter").classList.contains("on");
var cards = $$(".fcard", grid);
var visible = 0;
cards.forEach(function (c) {
var hit = !q || (c.getAttribute("data-search") || "").indexOf(q) >= 0;
var st = !starOnly || c.getAttribute("data-starred") === "true";
var show = hit && st;
c.style.display = show ? "" : "none";
if (show) visible++;
});
var empty = $("#feedEmpty");
if (empty) {
empty.hidden = visible !== 0;
empty.textContent = starOnly && visible === 0
? "아직 즐겨찾기한 프로젝트가 없어요 · 카드의 ⭐ 버튼으로 추가하세요"
: "“" + ($("#feedSearch") && $("#feedSearch").value || "") + "” 와 일치하는 프로젝트가 없습니다.";
}
}
function sortFeed(mode) {
var grid = $("#feedGrid"); if (!grid) return;
var cards = $$(".fcard", grid);
cards.sort(function (a, b) {
if (mode === "popular") {
return (+b.getAttribute("data-pop") || 0) - (+a.getAttribute("data-pop") || 0)
|| (b.getAttribute("data-created") || "").localeCompare(a.getAttribute("data-created") || "");
}
return (b.getAttribute("data-created") || "").localeCompare(a.getAttribute("data-created") || "");
});
cards.forEach(function (c) { grid.appendChild(c); });
}
function initFeedControls() {
var search = $("#feedSearch");
if (search) search.addEventListener("input", applyFeed);
var sl = $("#sortLatest"), sp = $("#sortPopular");
if (sl) sl.addEventListener("click", function () { sl.classList.add("on"); if (sp) sp.classList.remove("on"); sortFeed("latest"); });
if (sp) sp.addEventListener("click", function () { sp.classList.add("on"); if (sl) sl.classList.remove("on"); sortFeed("popular"); });
var sf = $("#feedStarFilter");
if (sf) sf.addEventListener("click", function () { sf.classList.toggle("on"); applyFeed(); });
}
/* ---------------- 내 프로젝트 필터 (전체/공개/비공개) ---------------- */
function initMyFilter() {
var tabs = $$("[data-myfilter]");
if (!tabs.length) return;
tabs.forEach(function (tab) {
tab.addEventListener("click", function () {
var f = tab.getAttribute("data-myfilter"); // all | public | private
tabs.forEach(function (t) { t.classList.toggle("on", t === tab); });
$$("#myProjGrid .pcard").forEach(function (c) {
var vis = c.getAttribute("data-vis");
c.style.display = (f === "all" || vis === f) ? "" : "none";
});
var add = $("#addCard");
if (add) add.style.display = f === "all" ? "" : "none";
});
});
}
/* ---------------- 시작 신호 처리 (서버가 body 속성으로 전달) ---------------- */
function initSignals() {
var b = document.body;
if (b.getAttribute("data-toast")) toast(b.getAttribute("data-toast"));
var egg = b.getAttribute("data-egg");
if (egg === "lgtm") { burstThumbs(); toast("LGTM 👍 — Looks Good To Me!"); }
else if (egg === "rocket") burstRocket();
var open = b.getAttribute("data-open-modal");
if (open) openDetail(open);
}
/* ---------------- 콘솔 이스터에그 ---------------- */
function consoleArt() {
if (!EGGS_ON) return;
try {
var art = [" ___ ____ ____ _______ __", " / | / _/ / __ \\/ ____/ | / /", " / /| | / / / / / / __/ | | / / ", "/ ___ |_/ / / /_/ / /___ | |/ / ", "/_/ |_/___/ /_____/_____/ |___/ "].join("\n");
console.log("%c" + art, "color:#0969da;font-family:monospace;font-size:11px;line-height:1.1");
console.log("%c👀 코드 구경 왔어요? 환영합니다.", "color:#1f883d;font-size:13px;font-weight:bold");
console.log("%c사내 개발팀은 늘 동료를 찾고 있어요 — git blame 하지 말고 git praise 합시다. 🙇", "color:#656d76;font-size:12px");
console.log("%cps. 댓글에 LGTM 한 번 쳐보세요.", "color:#8b949e;font-size:11px");
} catch (e) {}
}
document.addEventListener("DOMContentLoaded", function () {
initTheme();
initUserMenu();
initLogoEgg();
initReactions();
initDetailModal();
initComments();
initShare();
initFeedControls();
initMyFilter();
initSignals();
consoleArt();
});
})();

12
public/logo.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="AI DEV">
<defs>
<linearGradient id="aidev" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset="1" stop-color="#6366f1"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#aidev)"/>
<path d="M12.5 11 L7.5 16 L12.5 21" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 11 L24.5 16 L19.5 21" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="17.4" y1="9.5" x2="14.6" y2="22.5" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@@ -1,136 +1,399 @@
/* GitHub 감성 (Primer 팔레트 근사). 라이트 테마. */ /* AI DEV 포털 — 단일 스타일시트. (디자인 레퍼런스 GitHub 감성 / 라이트·다크)
:root { 빌드 없음: CSS 변수 + 클래스만. 다크는 html[data-aidev-theme="dark"] 로 전환. */
--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; } * { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
:root {
color-scheme: light;
--bg:#ffffff; --panel:#f6f8fa; --card:#ffffff; --tile:#f3f5f8; --tile-bd:#e1e6eb;
--inset:#ffffff; --bg-soft:#fbfcfd;
--border:#d0d7de; --border-soft:#d8dee4; --divider:#eaecef;
--fg:#1f2328; --fg2:#656d76; --fg3:#8b949e;
--link:#0969da; --accent-bg:#ddf4ff; --accent-bd:#b6e3ff; --accent-fg:#0969da; --accent-strong:#0a3069;
--success:#1f883d; --success-hover:#1a7f37; --success-bg:#dafbe1; --success-bd:#b7ebc4; --success-bd2:#4ac26b; --success-fg:#1a7f37;
--attn:#d4a72c; --attn-fg:#9a6700; --attn-bg:#fff8e6; --attn-bd:#eac54f;
--admin:#8250df; --admin-fg:#8250df; --admin-bg:#f3effc; --admin-bd:#e7defb;
--avatar-bg:#eaeef2; --overlay:rgba(31,35,40,.42);
--shadow:rgba(31,35,40,.08); --shadow-hover:rgba(31,35,40,.12); --shadow-strong:rgba(31,35,40,.22);
--btn-bg:#f6f8fa; --btn-bd:#d0d7de; --btn-hover:#eef1f4;
--tab-underline:#fd8c73; --focus-ring:rgba(9,105,218,.12); --danger:#cf222e;
--heart:#bf3989; --heart-fg:#bf3989; --heart-bg:#ffeff5; --heart-bd:#ffc1da;
--hbg:#24292f; --hfg:#ffffff; --hfg2:#b9c0c9; --hbd:#444c56; --hsearch-bg:#383f47; --hsearch-bd:#565b62; --hsearch-fg:#cdd3da;
}
html[data-aidev-theme="dark"] {
color-scheme: dark;
--bg:#0d1117; --panel:#161b22; --card:#1c2128; --tile:#2d333b; --tile-bd:#373e47;
--inset:#0d1117; --bg-soft:#161b22;
--border:#30363d; --border-soft:#373e47; --divider:#262c34;
--fg:#e6edf3; --fg2:#9198a1; --fg3:#6e7681;
--link:#4493f8; --accent-bg:rgba(56,139,253,.15); --accent-bd:rgba(56,139,253,.4); --accent-fg:#4493f8; --accent-strong:#a5d6ff;
--success:#238636; --success-hover:#2ea043; --success-bg:rgba(46,160,67,.15); --success-bd:rgba(46,160,67,.4); --success-bd2:rgba(63,185,80,.6); --success-fg:#3fb950;
--attn:#e3b341; --attn-fg:#e3b341; --attn-bg:rgba(227,179,65,.12); --attn-bd:rgba(187,128,9,.6);
--admin:#bc8cff; --admin-fg:#bc8cff; --admin-bg:rgba(163,113,247,.15); --admin-bd:rgba(163,113,247,.4);
--avatar-bg:#2d333b; --overlay:rgba(1,4,9,.7);
--shadow:rgba(1,4,9,.4); --shadow-hover:rgba(1,4,9,.55); --shadow-strong:rgba(1,4,9,.6);
--btn-bg:#21262d; --btn-bd:#30363d; --btn-hover:#30363d;
--tab-underline:#f78166; --focus-ring:rgba(56,139,253,.3); --danger:#f85149;
--heart:#db61a2; --heart-fg:#db61a2; --heart-bg:rgba(219,97,162,.15); --heart-bd:rgba(219,97,162,.4);
--hbg:#010409; --hfg:#e6edf3; --hfg2:#9198a1; --hbd:#21262d; --hsearch-bg:#0d1117; --hsearch-bd:#30363d; --hsearch-fg:#9198a1;
}
html, body { background: var(--bg); color: var(--fg); }
body { body {
margin: 0; font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', 'Malgun Gothic', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
color: var(--fg); transition: background-color .25s ease, color .2s ease;
background: var(--canvas-subtle);
font-size: 14px;
line-height: 1.5;
} }
a { color: var(--accent); text-decoration: none; } ::selection { background: color-mix(in srgb, var(--link) 22%, transparent); }
a:hover { text-decoration: underline; } ::placeholder { color: var(--fg3); }
a { color: inherit; text-decoration: none; }
svg { display: block; }
.tabnum { font-variant-numeric: tabular-nums; }
/* 헤더 */ /* 공통 아바타 래퍼 (identicon 헬퍼가 사용) */
.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; }
.sso-badge { display: inline-block; font-size: 10px; font-weight: 600; color: #1a7f37; background: #dafbe1; border: 1px solid #aceebb; border-radius: 999px; padding: 1px 7px; vertical-align: middle; margin-left: 4px; }
.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 { .avatar {
width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
background: var(--accent); color: #fff; display: inline-flex; overflow: hidden; background: var(--avatar-bg);
align-items: center; justify-content: center; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
} }
/* 코멘트 */ /* ===================== 헤더 ===================== */
.comment { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; } .hdr { position: sticky; top: 0; z-index: 300; background: var(--hbg); border-bottom: 1px solid var(--hbd); }
.comment-head { background: var(--canvas-subtle); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; } .hdr-inner { max-width: 1180px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 16px; }
.comment-body { padding: 12px; white-space: pre-wrap; } .brand { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; flex: 0 0 auto; }
.brand-mark { display: inline-flex; color: var(--hfg); transition: transform .2s ease, filter .2s ease; }
.brand-name { font-weight: 800; font-size: 15px; letter-spacing: .13em; color: var(--hfg); }
.brand-sub { font-size: 11px; color: var(--hfg2); font-weight: 500; border-left: 1px solid var(--hbd); padding-left: 10px; margin-left: 2px; white-space: nowrap; }
/* 로그인 */ .hdr-search { flex: 1; max-width: 300px; position: relative; display: flex; align-items: center; }
.login-wrap { max-width: 340px; margin: 80px auto; text-align: center; } .hdr-search-ico { position: absolute; left: 10px; display: inline-flex; color: var(--hfg2); pointer-events: none; }
.login-card { padding: 32px 24px; } .hdr-search input {
.login-logo { font-size: 40px; } width: 100%; font-family: inherit; font-size: 13px; color: var(--hfg);
.login-title { font-size: 24px; font-weight: 300; margin: 12px 0 24px; } background: var(--hsearch-bg); border: 1px solid var(--hsearch-bd); border-radius: 7px;
.alert { background: #ffebe9; border: 1px solid #ff818266; color: #cf222e; padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; } padding: 6px 10px 6px 32px; outline: none;
.muted { color: var(--fg-muted); font-size: 12px; } }
.hdr-search input:focus { border-color: var(--link); background: var(--bg); color: var(--fg); }
.hdr-search input::-webkit-search-cancel-button { filter: invert(.5); }
.hdr-right { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-left: auto; }
.icon-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--hbd); background: transparent;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--hfg2);
transition: background .14s ease, color .14s ease;
}
.icon-btn:hover { background: rgba(255,255,255,.08); color: var(--hfg); }
.theme-ico-sun { display: none; }
html[data-aidev-theme="dark"] .theme-ico-moon { display: none; }
html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
.admin-pill {
display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600;
color: #fff; background: color-mix(in srgb, var(--admin) 80%, transparent);
border: 1px solid var(--hbd); border-radius: 999px; padding: 4px 11px; white-space: nowrap;
}
.usermenu { position: relative; }
.usermenu-btn {
display: flex; align-items: center; gap: 8px; background: transparent; border: 1px solid transparent;
border-radius: 8px; padding: 4px 6px 4px 8px; cursor: pointer; font-family: inherit;
}
.usermenu-btn:hover { background: rgba(255,255,255,.08); }
.usermenu-btn .who { font-size: 13px; font-weight: 600; color: var(--hfg); font-variant-numeric: tabular-nums; }
.usermenu-btn .caret { display: inline-flex; color: var(--hfg2); }
.usermenu-pop {
position: absolute; top: 46px; right: 0; width: 212px; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; box-shadow: 0 8px 24px var(--shadow-strong); padding: 6px; z-index: 320; animation: aidev-pop .14s ease-out;
}
.usermenu-pop[hidden] { display: none; }
.usermenu-head { padding: 8px 10px 10px; border-bottom: 1px solid var(--divider); margin-bottom: 6px; }
.usermenu-label { font-size: 12px; color: var(--fg2); }
.usermenu-sabun { font-size: 13.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
.usermenu-role { font-size: 11.5px; color: var(--fg2); margin-top: 2px; }
.usermenu-item { width: 100%; text-align: left; background: transparent; border: 0; border-radius: 6px; padding: 8px 10px; cursor: pointer; font-family: inherit; font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 8px; }
.usermenu-item:hover { background: var(--panel); }
.usermenu-ico { display: inline-flex; color: var(--fg2); }
/* ===================== 레이아웃 / 섹션 ===================== */
.main { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; }
.section { margin-bottom: 40px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.section-titlewrap { display: flex; align-items: baseline; gap: 10px; }
.section-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; }
.section-sub { font-size: 13px; color: var(--fg2); }
/* ===================== ① 개발 도구 ===================== */
.tools-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.tool-card {
position: relative; text-align: left; background: var(--card); border: 1px solid var(--border-soft);
border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit; display: block;
transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
}
.tool-card:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow-hover); transform: translateY(-3px); }
.tool-card.admin { border-color: var(--admin-bd); }
.tool-icon {
width: 44px; height: 44px; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center;
font-size: 24px; background: var(--tile); border: 1px solid var(--tile-bd);
}
.tool-card.admin .tool-icon { background: var(--admin-bg); border-color: var(--admin-bd); }
.tool-name { display: block; margin-top: 14px; font-size: 14.5px; font-weight: 700; color: var(--fg); }
.tool-desc { display: block; margin-top: 3px; font-size: 12.5px; color: var(--fg2); line-height: 1.45; }
.sso-badge {
position: absolute; top: 13px; right: 13px; font-size: 10px; font-weight: 700; letter-spacing: .06em;
color: var(--accent-fg); background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 4px; padding: 2px 6px;
}
.tool-admin-badge {
display: inline-flex; align-items: center; gap: 5px; margin-top: 11px; font-size: 11px; font-weight: 600;
color: var(--admin-fg); background: var(--admin-bg); border: 1px solid var(--admin-bd); border-radius: 999px; padding: 2px 8px; white-space: nowrap;
}
/* ===================== ② 내 프로젝트 ===================== */
.myproj { position: relative; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px 22px 24px; }
.myproj-accent { position: absolute; left: 0; top: 18px; bottom: 18px; width: 4px; border-radius: 0 4px 4px 0; background: var(--link); }
.myproj-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding-left: 8px; flex-wrap: wrap; }
.myproj-tools { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.filter-tabs { display: inline-flex; align-items: center; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 2px; gap: 2px; }
.filter-tab {
display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 5px 11px;
cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--fg2); white-space: nowrap;
transition: background .14s ease, color .14s ease;
}
.filter-tab.on { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
.new-proj-btn {
display: inline-flex; align-items: center; gap: 6px; background: var(--success);
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 8px; padding: 7px 13px;
cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
}
.new-proj-btn:hover { background: var(--success-hover); }
.myproj-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-left: 8px; }
/* 프로젝트 카드 (내 프로젝트 / 피드 공용 베이스) */
.pcard {
background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 15px 16px 14px;
cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column;
}
.pcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
.pcard.private { background: var(--bg); border: 1.5px dashed var(--border); }
.pcard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pcard-titlewrap { display: flex; align-items: center; gap: 7px; min-width: 0; }
.pcard-repoico { display: inline-flex; color: var(--fg2); flex: 0 0 auto; }
.pcard-title { font-size: 14px; font-weight: 700; color: var(--link); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pcard-desc { margin-top: 8px; font-size: 12.5px; color: var(--fg2); line-height: 1.5; flex: 1; }
.pcard-tags { margin-top: 11px; display: flex; flex-wrap: wrap; gap: 6px; }
.pcard-foot { margin-top: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pcard-stats { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
.pcard-stats .stat { display: inline-flex; align-items: center; gap: 4px; }
.lang-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.ico-heart { display: inline-flex; color: var(--heart); }
.ico-star { display: inline-flex; color: var(--attn); }
.ico-comment { display: inline-flex; color: var(--fg2); }
.vis-badge {
display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; font-size: 11px; font-weight: 600;
border-radius: 999px; padding: 1px 8px 1px 7px; white-space: nowrap;
}
.vis-badge.public { color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd); }
.vis-badge.private { color: var(--fg2); background: var(--panel); border: 1px solid var(--border); }
.toggle-share-btn { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg3); white-space: nowrap; }
.toggle-share-btn:hover { color: var(--fg); }
.private-note { font-size: 11.5px; color: var(--fg3); }
.share-mini-btn {
display: inline-flex; align-items: center; gap: 5px; background: var(--success);
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 7px; padding: 4px 10px;
cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
}
.share-mini-btn:hover { background: var(--success-hover); }
.add-card {
background: transparent; border: 1.5px dashed var(--border); border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--fg2); min-height: 158px; width: 100%;
transition: border-color .16s ease, color .16s ease, background .16s ease;
}
.add-card:hover { border-color: var(--success); color: var(--success); background: var(--card); }
.add-card-plus { width: 38px; height: 38px; border-radius: 50%; background: var(--success-bg); border: 1px solid var(--success-bd); display: inline-flex; align-items: center; justify-content: center; color: var(--success); }
.add-card-title { font-size: 13.5px; font-weight: 700; white-space: nowrap; }
.add-card-sub { font-size: 11.5px; text-align: center; line-height: 1.4; }
.empty { grid-column: 1 / -1; text-align: center; padding: 30px; color: var(--fg2); font-size: 13px; border: 1px dashed var(--border); border-radius: 12px; }
/* ===================== ③ 공유 피드 ===================== */
.feed-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--border-soft); }
.feed-head .section-titlewrap { padding-bottom: 14px; }
.feed-head-right { display: flex; align-items: flex-end; gap: 14px; }
.feed-star-btn {
display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--btn-bd); border-radius: 7px;
padding: 6px 12px; margin-bottom: 8px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap;
transition: background .14s ease, border-color .14s ease, color .14s ease;
}
.feed-star-btn:hover { border-color: var(--attn-bd); color: var(--attn-fg); }
.feed-star-btn.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
.feed-star-btn .ico { display: inline-flex; }
.sort-tabs { display: flex; gap: 2px; }
.sort-tab { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px 14px; color: var(--fg2); transition: color .14s ease; }
.sort-tab.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--tab-underline); }
.feed-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.fcard { background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 18px; cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column; }
.fcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
.fcard-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.fcard-titlewrap { display: flex; align-items: center; gap: 8px; min-width: 0; }
.fcard-title { font-size: 15px; font-weight: 700; color: var(--link); line-height: 1.3; }
.react-btns { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.react-btn {
display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; background: var(--btn-bg); border: 1px solid var(--btn-bd);
border-radius: 7px; padding: 4px 9px; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg);
transition: background .14s ease, border-color .14s ease, color .14s ease;
}
.react-btn .count { font-variant-numeric: tabular-nums; }
.react-btn.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
.react-btn.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
/* 채움(on) / 외곽선(off) 아이콘 토글 — JS 가 .on 클래스만 바꾸면 됨 */
.react-btn .ico, .modal-action .ico { display: inline-flex; }
.react-btn .ico-on, .modal-action .ico-on { display: none; }
.react-btn.on .ico-on, .modal-action.on .ico-on { display: inline-flex; }
.react-btn.on .ico-off, .modal-action.on .ico-off { display: none; }
.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; }
.fcard-tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
.fcard-foot { margin-top: 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.fcard-author { display: flex; align-items: center; gap: 8px; min-width: 0; }
.fcard-author .sabun { font-size: 12.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
.fcard-author .date { font-size: 11.5px; color: var(--fg3); }
.fcard-meta { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
.fcard-meta .stat { display: inline-flex; align-items: center; gap: 5px; }
.bara-link { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); white-space: nowrap; }
.tag { font-size: 11px; font-weight: 500; color: var(--accent-fg); background: var(--accent-bg); border-radius: 999px; padding: 1px 9px; }
/* ===================== 상세 모달 ===================== */
.modal-overlay { position: fixed; inset: 0; z-index: 500; background: var(--overlay); display: flex; align-items: flex-start; justify-content: center; padding: 48px 20px; overflow-y: auto; animation: aidev-fade .16s ease-out; }
.modal-overlay[hidden] { display: none; }
.modal { width: 100%; max-width: 640px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
.modal-head { padding: 22px 24px 18px; border-bottom: 1px solid var(--divider); }
.modal-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.modal-titlewrap { min-width: 0; }
.modal-title-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.modal-repoico { display: inline-flex; color: var(--fg2); }
.modal-title { margin: 0; font-size: 20px; font-weight: 800; color: var(--link); line-height: 1.3; letter-spacing: -.01em; }
.modal-meta { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.modal-meta .sabun { font-size: 13px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
.modal-meta .date { font-size: 12px; color: var(--fg3); }
.modal-meta .lang { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--fg2); margin-left: 4px; }
.modal-close { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; border: 1px solid transparent; background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--fg2); }
.modal-close:hover { background: var(--panel); border-color: var(--border-soft); }
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.modal-desc { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; white-space: pre-wrap; }
.modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.modal-action {
display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px;
padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg);
transition: background .14s ease, border-color .14s ease, color .14s ease;
}
.modal-action .count { font-variant-numeric: tabular-nums; padding-left: 8px; margin-left: 2px; border-left: 1px solid var(--border); }
.modal-action.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
.modal-action.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
.modal-action.primary { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
.modal-action.primary:hover { background: var(--success-hover); }
.modal-action.ghost:hover { background: var(--btn-hover); }
/* 댓글 스레드 */
.modal-body { padding: 18px 24px 4px; }
.comments-title { font-size: 13px; font-weight: 700; color: var(--fg); margin-bottom: 14px; display: flex; align-items: center; gap: 7px; }
.comments-count { font-weight: 600; color: var(--fg2); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 999px; padding: 0 8px; font-size: 12px; font-variant-numeric: tabular-nums; }
.comment-list { display: flex; flex-direction: column; gap: 4px; }
.comment { display: flex; gap: 11px; padding: 11px 0; }
.comment-main { min-width: 0; flex: 1; }
.comment-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comment-author { font-size: 13px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; }
.comment-time { font-size: 11.5px; color: var(--fg3); }
.lgtm { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 800; letter-spacing: .04em; color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd2); border-radius: 5px; padding: 1px 7px; animation: aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2); }
.comment-text { margin-top: 3px; font-size: 13.5px; color: var(--fg); line-height: 1.6; white-space: pre-wrap; }
.comment-actions { margin-top: 5px; display: flex; align-items: center; gap: 12px; }
.comment-link { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg2); display: inline-flex; align-items: center; gap: 5px; }
.comment-link:hover { color: var(--link); }
.comment-del:hover { color: var(--danger); }
.replies { margin-left: 21px; padding-left: 18px; border-left: 2px solid var(--divider); display: flex; flex-direction: column; }
.reply { display: flex; gap: 10px; padding: 10px 0; }
.reply .comment-author { font-size: 12.5px; }
.reply .comment-text { font-size: 13px; }
.modal-foot { padding: 14px 24px 22px; border-top: 1px solid var(--divider); margin-top: 6px; background: var(--bg-soft); }
.reply-banner { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 7px; padding: 6px 10px; }
.reply-banner span { font-size: 12px; color: var(--accent-strong); }
.reply-banner button { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); }
.comment-form { display: flex; gap: 10px; align-items: flex-end; }
.comment-input { flex: 1; resize: none; font-family: inherit; font-size: 13.5px; line-height: 1.5; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; outline: none; min-height: 40px; }
.comment-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
.submit-btn { flex: 0 0 auto; background: var(--success); border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 9px; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 700; color: #fff; align-self: stretch; white-space: nowrap; transition: background .14s ease; }
.submit-btn:hover { background: var(--success-hover); }
.comment-hint { margin-top: 7px; font-size: 11px; color: var(--fg3); padding-left: 38px; }
/* ===================== 공유(등록) 모달 ===================== */
.share-modal { width: 100%; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
.share-head { padding: 20px 24px; border-bottom: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; }
.share-head h3 { margin: 0; font-size: 17px; font-weight: 800; color: var(--fg); }
.share-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 15px; }
.share-label { display: block; font-size: 12.5px; font-weight: 700; color: var(--fg); margin-bottom: 6px; }
.share-label .req { color: var(--danger); }
.share-label .hint { font-weight: 500; color: var(--fg3); }
.share-input { width: 100%; font-family: inherit; font-size: 14px; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none; }
.share-input.mono { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 13px; }
.share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
.vis-toggle { display: inline-flex; background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; }
.vis-opt { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; }
.vis-opt.on-public { background: var(--success-bg); color: var(--success-fg); box-shadow: inset 0 0 0 1px var(--success-bd); }
.vis-opt.on-private { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
.share-vishint { margin-top: 7px; font-size: 11.5px; color: var(--fg3); }
.modal-foot-actions { padding: 16px 24px; border-top: 1px solid var(--divider); background: var(--bg-soft); display: flex; align-items: center; justify-content: flex-end; gap: 9px; }
/* ===================== 토스트 / 날아다니는 이모지 ===================== */
.toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); z-index: 600; background: #1f2328; color: #fff; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 11px 16px; font-size: 13px; font-weight: 500; box-shadow: 0 8px 24px rgba(1,4,9,.4); display: flex; align-items: center; gap: 9px; max-width: 90vw; animation: aidev-toast 2.6s ease forwards; }
.toast .toast-ico { display: inline-flex; color: #3fb950; }
.flyers { position: fixed; inset: 0; z-index: 650; pointer-events: none; overflow: hidden; }
.flyer { position: fixed; pointer-events: none; user-select: none; will-change: transform, opacity; }
/* ===================== 푸터 ===================== */
.footer { border-top: 1px solid var(--border-soft); }
.footer-inner { max-width: 1180px; margin: 0 auto; padding: 20px 24px 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.footer-brand { display: flex; align-items: center; gap: 8px; color: var(--fg3); font-size: 12px; }
.footer-tech { font-size: 11.5px; color: var(--fg3); font-family: 'SF Mono', ui-monospace, Menlo, monospace; }
/* ===================== 버튼/태그 공용 (단독 페이지·로그인) ===================== */
.btn { display: inline-flex; align-items: center; gap: 6px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--fg); transition: background .14s ease; }
.btn:hover { background: var(--btn-hover); }
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
.btn-primary { background: var(--link); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
.btn-primary:hover { filter: brightness(1.06); }
.btn-success { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
.btn-success:hover { background: var(--success-hover); }
.muted { color: var(--fg2); }
.back-link { font-size: 13px; color: var(--fg2); }
.back-link:hover { color: var(--link); }
/* ===================== 로그인 폴백 페이지 ===================== */
.login-wrap { min-height: calc(100vh - 60px); display: flex; align-items: center; justify-content: center; padding: 24px; }
.login-card { width: 100%; max-width: 360px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 8px 24px var(--shadow); padding: 32px 28px; text-align: center; }
.login-logo { margin-bottom: 12px; }
.login-title { font-size: 20px; font-weight: 800; letter-spacing: .06em; margin-bottom: 18px; }
.alert { background: var(--attn-bg); border: 1px solid var(--attn-bd); color: var(--attn-fg); border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 16px; }
/* ===================== 단독 상세 페이지(project.ejs) ===================== */
.detail-wrap { max-width: 760px; margin: 0 auto; padding: 28px 24px 64px; }
/* ===================== 애니메이션 키프레임 ===================== */
@keyframes aidev-flyup { 0%{transform:translateY(0) scale(.5);opacity:0} 12%{opacity:1} 100%{transform:translateY(-180px) scale(1.15);opacity:0} }
@keyframes aidev-sparkle { 0%{transform:scale(0) rotate(0);opacity:1} 100%{transform:scale(1.5) rotate(120deg);opacity:0} }
@keyframes aidev-rocket { 0%{transform:translateX(-40px) rotate(-12deg);opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{transform:translateX(calc(100vw + 60px)) rotate(-12deg);opacity:0} }
@keyframes aidev-shake { 10%,90%{transform:translateX(-1px) rotate(-1deg)} 20%,80%{transform:translateX(2px) rotate(1.5deg)} 30%,50%,70%{transform:translateX(-4px) rotate(-2deg)} 40%,60%{transform:translateX(4px) rotate(2deg)} }
@keyframes aidev-stamp { 0%{transform:scale(2.4) rotate(-22deg);opacity:0} 55%{transform:scale(.86) rotate(-11deg);opacity:1} 100%{transform:scale(1) rotate(-11deg);opacity:1} }
@keyframes aidev-pop { 0%{transform:scale(.96) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
@keyframes aidev-toast { 0%{transform:translateX(-50%) translateY(12px);opacity:0} 12%{transform:translateX(-50%) translateY(0);opacity:1} 88%{transform:translateX(-50%) translateY(0);opacity:1} 100%{transform:translateX(-50%) translateY(8px);opacity:0} }
/* ===================== 반응형 ===================== */
@media (max-width: 900px) {
.tools-grid { grid-template-columns: repeat(2, 1fr); }
.myproj-grid { grid-template-columns: repeat(2, 1fr); }
.feed-grid { grid-template-columns: 1fr; }
.brand-sub { display: none; }
}
@media (max-width: 560px) {
.tools-grid, .myproj-grid { grid-template-columns: 1fr; }
.hdr-search { display: none; }
}

View File

@@ -19,22 +19,37 @@ CREATE TABLE IF NOT EXISTS projects (
repo_url text DEFAULT '', -- Gitea repo repo_url text DEFAULT '', -- Gitea repo
app_url text DEFAULT '', -- 배포된 앱 (*.apps.bokdev.in) app_url text DEFAULT '', -- 배포된 앱 (*.apps.bokdev.in)
tags text DEFAULT '', -- 콤마 구분 tags text DEFAULT '', -- 콤마 구분
is_public boolean NOT NULL DEFAULT true, -- 공개(피드 노출) / 비공개(나만)
lang text DEFAULT '', -- 대표 언어(태그에서 추론, 카드 언어 점)
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
-- 기존 배포 DB 대비 멱등 컬럼 추가
ALTER TABLE projects ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT true;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS lang text DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC); CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC);
-- 코멘트 -- 코멘트 (parent_id 가 null 이면 최상위, 있으면 대댓글 — 1뎁스만 허용: 앱에서 검증)
CREATE TABLE IF NOT EXISTS comments ( CREATE TABLE IF NOT EXISTS comments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
author_sabun text NOT NULL REFERENCES users(sabun), author_sabun text NOT NULL REFERENCES users(sabun),
parent_id bigint REFERENCES comments(id) ON DELETE CASCADE,
body text NOT NULL, body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now() created_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE comments ADD COLUMN IF NOT EXISTS parent_id bigint REFERENCES comments(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, created_at); CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, created_at);
-- 스타(좋아요) -- 좋아요(❤️) — 1인 1좋아요
CREATE TABLE IF NOT EXISTS hearts (
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)
);
-- 즐겨찾기(⭐ Star) — 1인 1즐겨찾기
CREATE TABLE IF NOT EXISTS stars ( CREATE TABLE IF NOT EXISTS stars (
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
sabun text NOT NULL REFERENCES users(sabun), sabun text NOT NULL REFERENCES users(sabun),

View File

@@ -42,10 +42,20 @@ export async function handleCallback(req) {
nonce, nonce,
}); });
const claims = tokenSet.claims(); const claims = tokenSet.claims();
// Keycloak realm 역할: realm_access.roles. (클라이언트 역할은 resource_access[client].roles)
const realmRoles = (claims.realm_access && claims.realm_access.roles) || [];
const clientRoles =
(claims.resource_access &&
claims.resource_access[config.oidc.clientId] &&
claims.resource_access[config.oidc.clientId].roles) ||
[];
const roles = [...new Set([...realmRoles, ...clientRoles])];
return { return {
sabun: claims.preferred_username, // 사번 sabun: claims.preferred_username, // 사번
name: claims.name || claims.given_name || claims.preferred_username, name: claims.name || claims.given_name || claims.preferred_username,
email: claims.email, email: claims.email,
roles,
isAdmin: roles.includes(config.adminRole),
}; };
} }

View File

@@ -13,6 +13,21 @@ export const config = {
// redirect 는 baseUrl + /auth/callback 로 구성 // redirect 는 baseUrl + /auth/callback 로 구성
}, },
// 관리자 판별용 Keycloak 역할 이름. 이 role 을 가진 사용자에게만 인프라 도구 노출.
adminRole: process.env.ADMIN_ROLE || "admin",
// 도구(사이트) URL — 폐쇄망/도메인 변경 대비 환경변수로 덮어쓰기 가능.
tools: {
coder: process.env.CODER_URL || "https://coder.bokdev.in/api/v2/users/oidc/callback",
gitea: process.env.GITEA_URL || "https://gitea.bokdev.in/user/oauth2/keycloak",
kubero: process.env.KUBERO_URL || "https://kubero.bokdev.in",
harbor: process.env.HARBOR_URL || "https://harbor.bokdev.in",
openeverest: process.env.OPENEVEREST_URL || "https://openeverest.bokdev.in",
minio: process.env.MINIO_URL || "https://minioc.bokdev.in",
keycloak: process.env.KEYCLOAK_URL || "https://keycloak.bokdev.in/admin",
litellm: process.env.LITELLM_URL || "https://litellm.bokdev.in",
},
// 포털 표시 이름 (변경 가능) // 포털 표시 이름 (변경 가능)
brand: process.env.PORTAL_BRAND || "AI DEV", brand: process.env.PORTAL_BRAND || "AI DEV",
}; };

132
src/db.js
View File

@@ -17,28 +17,36 @@ export async function upsertUser({ sabun, name, email }) {
); );
} }
// 프로젝트 목록 (스타 수 + 코멘트 수 + 소유자 이름 포함) // 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
export async function listProjects() { // $1 = viewer 사번.
const PROJECT_SELECT = `
SELECT p.*, u.name AS owner_name,
(SELECT count(*) FROM hearts h WHERE h.project_id=p.id)::int AS heart_count,
(SELECT count(*) FROM stars s WHERE s.project_id=p.id)::int AS star_count,
(SELECT count(*) FROM comments c WHERE c.project_id=p.id)::int AS comment_count,
EXISTS(SELECT 1 FROM hearts h WHERE h.project_id=p.id AND h.sabun=$1) AS hearted,
EXISTS(SELECT 1 FROM stars s WHERE s.project_id=p.id AND s.sabun=$1) AS starred
FROM projects p JOIN users u ON u.sabun=p.owner_sabun`;
// 프로젝트 목록 — 공개 프로젝트 + viewer 본인의 비공개 프로젝트.
// (남의 비공개는 절대 반환하지 않음) 피드/내프로젝트 분리는 호출부(server.js)에서.
export async function listProjects(viewer) {
const { rows } = await q( const { rows } = await q(
`SELECT p.*, u.name AS owner_name, `${PROJECT_SELECT}
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count, WHERE p.is_public OR p.owner_sabun=$1
(SELECT count(*) FROM comments c WHERE c.project_id=p.id) AS comment_count ORDER BY p.created_at DESC`,
FROM projects p JOIN users u ON u.sabun=p.owner_sabun [viewer]
ORDER BY p.created_at DESC`
); );
return rows; return rows;
} }
export async function getProject(id) { // 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
const { rows } = await q( export async function getProject(id, viewer) {
`SELECT p.*, u.name AS owner_name, const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
(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]; return rows[0];
} }
// 댓글 목록 (parent_id 포함, 작성 순서). 스레드 구성은 뷰에서.
export async function listComments(projectId) { export async function listComments(projectId) {
const { rows } = await q( const { rows } = await q(
`SELECT c.*, u.name AS author_name `SELECT c.*, u.name AS author_name
@@ -49,30 +57,92 @@ export async function listComments(projectId) {
return rows; return rows;
} }
export async function addProject({ owner, title, description, repoUrl, appUrl, tags }) { // 여러 프로젝트의 댓글을 한 번에 (대시보드 모달 저장소용). parent_id 포함.
export async function listCommentsByProjects(ids) {
if (!ids || !ids.length) return [];
const { rows } = await q( const { rows } = await q(
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags) `SELECT c.*, u.name AS author_name
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, FROM comments c JOIN users u ON u.sabun=c.author_sabun
[owner, title, description || "", repoUrl || "", appUrl || "", tags || ""] WHERE c.project_id = ANY($1::bigint[]) ORDER BY c.created_at ASC`,
[ids]
);
return rows;
}
export async function addProject({ owner, title, description, repoUrl, appUrl, tags, isPublic, lang }) {
const { rows } = await q(
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[owner, title, description || "", repoUrl || "", appUrl || "", tags || "", isPublic !== false, lang || ""]
); );
return rows[0].id; return rows[0].id;
} }
export async function addComment({ projectId, author, body }) { // 본인 프로젝트 삭제. 삭제 행 수 반환(0이면 권한 없음/없음).
await q(`INSERT INTO comments (project_id, author_sabun, body) VALUES ($1,$2,$3)`, export async function deleteProject({ id, sabun }) {
[projectId, author, body]); const { rowCount } = await q(
`DELETE FROM projects WHERE id=$1 AND owner_sabun=$2`,
[id, sabun]
);
return rowCount;
} }
// 공개/비공개 전환 (본인만). 적용 행 수 반환.
export async function setVisibility({ id, sabun, isPublic }) {
const { rowCount } = await q(
`UPDATE projects SET is_public=$3, updated_at=now() WHERE id=$1 AND owner_sabun=$2`,
[id, sabun, !!isPublic]
);
return rowCount;
}
// 댓글/대댓글 작성. parentId 가 있으면 대댓글.
// 1뎁스 강제: 부모 댓글이 같은 프로젝트의 "최상위(parent_id IS NULL)" 일 때만 허용.
// 반환: 새 댓글 행(작성자 이름 포함). 거부 시 null.
export async function addComment({ projectId, author, body, parentId }) {
let parent = null;
if (parentId) {
const { rows } = await q(
`SELECT id FROM comments WHERE id=$1 AND project_id=$2 AND parent_id IS NULL`,
[parentId, projectId]
);
if (!rows[0]) return null; // 대댓글에 또 답글(2뎁스) 또는 잘못된 부모 → 거부
parent = parentId;
}
const { rows } = await q(
`INSERT INTO comments (project_id, author_sabun, parent_id, body)
VALUES ($1,$2,$3,$4) RETURNING *`,
[projectId, author, parent, body]
);
const c = rows[0];
const { rows: u } = await q(`SELECT name FROM users WHERE sabun=$1`, [author]);
c.author_name = u[0] ? u[0].name : author;
return c;
}
// 본인 댓글 삭제 (자식 대댓글은 FK ON DELETE CASCADE 로 함께 삭제). 삭제 행 수 반환.
export async function deleteComment({ id, sabun }) {
const { rowCount } = await q(
`DELETE FROM comments WHERE id=$1 AND author_sabun=$2`,
[id, sabun]
);
return rowCount;
}
// ❤️ 좋아요 토글 → { on, count }
export async function toggleHeart({ projectId, sabun }) {
const { rowCount } = await q(`DELETE FROM hearts WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
const on = rowCount === 0;
if (on) await q(`INSERT INTO hearts (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]);
const { rows } = await q(`SELECT count(*)::int AS c FROM hearts WHERE project_id=$1`, [projectId]);
return { on, count: rows[0].c };
}
// ⭐ 즐겨찾기(Star) 토글 → { on, count }
export async function toggleStar({ projectId, sabun }) { export async function toggleStar({ projectId, sabun }) {
const { rowCount } = await q(`DELETE FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]); const { rowCount } = await q(`DELETE FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
if (rowCount === 0) { const on = rowCount === 0;
await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]); if (on) await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]);
return true; const { rows } = await q(`SELECT count(*)::int AS c FROM stars WHERE project_id=$1`, [projectId]);
} return { on, count: rows[0].c };
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;
} }

98
src/helpers.js Normal file
View File

@@ -0,0 +1,98 @@
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor() 로 사용.
// GitHub Octicon 16x16 패스 모음.
const OCTICONS = {
code: '<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>',
search: '<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>',
sun: '<path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.061a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.751.751 0 0 1-1.061-1.061l1.06-1.06a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.751.751 0 0 1-1.061 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-1.06 1.06l-1.061-1.06a.75.75 0 0 1 0-1.06Z"/>',
moon: '<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/>',
caret: '<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>',
signout: '<path d="M2 2.75C2 1.784 2.784 1 3.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/>',
lock: '<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/>',
globe: '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z"/>',
repo: '<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/>',
starFill: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>',
star: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/>',
heart: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002ZM4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.58 20.58 0 0 0 8 13.393a20.58 20.58 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5Z"/>',
heartFill: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"/>',
comment: '<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>',
plus: '<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>',
x: '<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>',
reply: '<path d="M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"/>',
check: '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>',
ext: '<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>',
trash: '<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/>',
};
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).
export function icon(name, opts = {}) {
const size = opts.size || 16;
const fill = opts.fill || "currentColor";
const path = OCTICONS[name] || OCTICONS.repo;
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" ` +
`viewBox="0 0 16 16" fill="${fill}" style="display:block" aria-hidden="true">${path}</svg>`
);
}
// 언어 → 색(카드의 언어 점).
const LANG_COLORS = {
Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a",
Go: "#00ADD8", Rust: "#dea584", Shell: "#89e051", Java: "#b07219",
};
export function langColor(lang) {
return LANG_COLORS[lang] || "#8b949e";
}
// 태그에서 대표 언어 추론(프로젝트 등록 시 lang 저장용).
export function langFromTags(tags) {
const t = (tags || []).map((x) => String(x).toLowerCase());
if (t.includes("fastapi") || t.includes("python") || t.includes("django")) return "Python";
if (t.includes("react") || t.includes("ts") || t.includes("typescript") || t.includes("next")) return "TypeScript";
if (t.includes("go") || t.includes("golang")) return "Go";
if (t.includes("rust")) return "Rust";
if (t.includes("node") || t.includes("js")) return "JavaScript";
return "TypeScript";
}
// 문자열 → 결정적 해시(아바타/색 시드).
function hashSeed(seed) {
let h = 0;
const s = String(seed);
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각.
export function identicon(seed, size = 28) {
const h = hashSeed(seed);
const hue = h % 360;
const sat = 48 + (h % 22);
const color = `hsl(${hue},${sat}%,52%)`;
const grid = 5;
const on = new Array(25).fill(false);
for (let col = 0; col < 3; col++) {
for (let row = 0; row < 5; row++) {
const bit = (h >> (col * 5 + row)) & 1;
on[row * 5 + col] = !!bit;
on[row * 5 + (4 - col)] = !!bit;
}
}
const inner = Math.round(size * 0.74);
const cell = inner / grid;
let rects = "";
for (let r = 0; r < grid; r++) {
for (let c = 0; c < grid; c++) {
if (on[r * grid + c]) {
rects += `<rect x="${(c * cell).toFixed(2)}" y="${(r * cell).toFixed(2)}" width="${cell.toFixed(2)}" height="${cell.toFixed(2)}"/>`;
}
}
}
const radius = Math.max(5, Math.round(size * 0.2));
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 ${inner} ${inner}" fill="${color}" style="display:block">${rects}</svg>`;
return (
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
);
}

View File

@@ -4,8 +4,9 @@ import cookieParser from "cookie-parser";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import path from "path"; import path from "path";
import { config } from "./config.js"; import { config } from "./config.js";
import { siteGroups } from "./sites.js"; import { toolsForUser } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js"; import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js";
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
import * as db from "./db.js"; import * as db from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -26,22 +27,30 @@ app.use(
}) })
); );
// 모든 뷰 공통 노출 // 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
app.use((req, res, next) => { app.use((req, res, next) => {
res.locals.brand = config.brand; res.locals.brand = config.brand;
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
res.locals.icon = icon;
res.locals.identicon = identicon;
res.locals.langColor = langColor;
next(); next();
}); });
app.get("/healthz", (_req, res) => res.json({ ok: true })); app.get("/healthz", (_req, res) => res.json({ ok: true }));
// fetch(AJAX) 요청인지: app.js 가 X-Requested-With 를 붙인다.
const isAjax = (req) =>
req.get("X-Requested-With") === "fetch" || (req.get("Accept") || "").includes("application/json");
// 로컬 경로만 허용하는 안전한 리다이렉트 대상.
const safeBack = (v, fallback) => (typeof v === "string" && v.startsWith("/") ? v : fallback);
// --- 인증 --- // --- 인증 ---
// SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak(커스텀 테마) 로그인으로 보낸다. // SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak 로그인으로. (실패 시 login.ejs 폴백)
// (OIDC 초기화 실패 등으로 로그인 불가하면 안내용 login.ejs 로 폴백)
app.get("/login", (req, res) => { app.get("/login", (req, res) => {
if (req.session.user) return res.redirect("/"); if (req.session.user) return res.redirect("/");
if (req.query.error) return res.render("login", { error: req.query.error }); if (req.query.error) return res.render("login", { error: req.query.error });
// OIDC 준비됐으면 중간 화면 없이 곧장 Keycloak 로그인 폼으로
if (oidcReady()) return loginRedirect(req, res); if (oidcReady()) return loginRedirect(req, res);
return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." }); return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
}); });
@@ -50,7 +59,7 @@ app.get("/auth/callback", async (req, res) => {
try { try {
const u = await handleCallback(req); const u = await handleCallback(req);
await db.upsertUser(u); await db.upsertUser(u);
req.session.user = u; req.session.user = u; // { sabun, name, email, roles, isAdmin }
res.redirect("/"); res.redirect("/");
} catch (e) { } catch (e) {
console.error("oidc callback error", e); console.error("oidc callback error", e);
@@ -61,49 +70,111 @@ app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/login")); req.session.destroy(() => res.redirect("/login"));
}); });
// --- 대시보드 (로그인 필요) --- // --- 대시보드 ---
app.get("/", requireAuth, async (req, res) => { app.get("/", requireAuth, async (req, res) => {
const projects = await db.listProjects(); const me = req.session.user.sabun;
res.render("dashboard", { siteGroups, projects }); const projects = await db.listProjects(me);
const myProjects = projects.filter((p) => p.owner_sabun === me);
const feedProjects = projects.filter((p) => p.is_public);
// 모달 저장소용: 열람 가능한 모든 프로젝트의 댓글을 한 번에 받아 프로젝트별로 묶는다.
const allComments = await db.listCommentsByProjects(projects.map((p) => p.id));
const commentsByProject = {};
for (const c of allComments) (commentsByProject[c.project_id] ||= []).push(c);
res.render("dashboard", {
tools: toolsForUser(req.session.user.isAdmin),
myProjects,
feedProjects,
allProjects: projects,
commentsByProject,
toast: typeof req.query.toast === "string" ? req.query.toast : "",
egg: typeof req.query.egg === "string" ? req.query.egg : "",
openModal: typeof req.query.open === "string" ? req.query.open : "",
});
}); });
// --- 프로젝트 --- // --- 프로젝트 생성 ---
app.post("/projects", requireAuth, async (req, res) => { app.post("/projects", requireAuth, async (req, res) => {
const { title, description, repo_url, app_url, tags } = req.body; const { title, description, repo_url, app_url, tags } = req.body;
if (!title || !title.trim()) return res.redirect("/"); if (!title || !title.trim()) return res.redirect("/?toast=" + encodeURIComponent("제목을 입력해주세요"));
const tagArr = String(tags || "")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean)
.slice(0, 5);
await db.addProject({ await db.addProject({
owner: req.session.user.sabun, owner: req.session.user.sabun,
title: title.trim(), title: title.trim(),
description, description: (description || "").trim(),
repoUrl: repo_url, repoUrl: (repo_url || "").trim(),
appUrl: app_url, appUrl: (app_url || "").trim(),
tags, tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang: langFromTags(tagArr),
}); });
res.redirect("/"); const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
}); });
// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
app.get("/projects/:id", requireAuth, async (req, res) => { app.get("/projects/:id", requireAuth, async (req, res) => {
const project = await db.getProject(req.params.id); const me = req.session.user.sabun;
if (!project) return res.status(404).send("not found"); const project = await db.getProject(req.params.id, me);
if (!project) return res.status(404).send("프로젝트를 찾을 수 없습니다.");
// 비공개는 본인만 열람
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
const comments = await db.listComments(project.id); 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: project.starred });
res.render("project", { project, comments, starred });
}); });
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
async function reactionHandler(kind, req, res) {
const result = await (kind === "heart"
? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun })
: db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }));
if (isAjax(req)) return res.json(result);
res.redirect(safeBack(req.get("Referer"), "/"));
}
app.post("/projects/:id/heart", requireAuth, (req, res) => reactionHandler("heart", req, res));
app.post("/projects/:id/star", requireAuth, (req, res) => reactionHandler("star", req, res));
// --- 공개/비공개 전환 (본인) ---
app.post("/projects/:id/visibility", requireAuth, async (req, res) => {
const isPublic = req.body.is_public === "1";
await db.setVisibility({ id: req.params.id, sabun: req.session.user.sabun, isPublic });
const msg = isPublic ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
});
// --- 프로젝트 삭제 (본인) ---
app.post("/projects/:id/delete", requireAuth, async (req, res) => {
const n = await db.deleteProject({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect("/?toast=" + encodeURIComponent(n ? "삭제됐습니다" : "삭제 권한이 없습니다"));
});
// --- 댓글/대댓글 작성 ---
app.post("/projects/:id/comments", requireAuth, async (req, res) => { app.post("/projects/:id/comments", requireAuth, async (req, res) => {
if (req.body.body && req.body.body.trim()) { const back = safeBack(req.body.return_to, `/projects/${req.params.id}`);
await db.addComment({ const body = (req.body.body || "").trim();
projectId: req.params.id, if (!body) return res.redirect(back);
author: req.session.user.sabun, const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
body: req.body.body.trim(), await db.addComment({
}); projectId: req.params.id,
} author: req.session.user.sabun,
res.redirect(`/projects/${req.params.id}`); body,
parentId: parentId || null,
});
// 이스터에그 신호
let egg = "";
if (/\blgtm\b/i.test(body)) egg = "lgtm";
else if (body.indexOf("🚀") >= 0 || body.toLowerCase().indexOf(":shipit:") >= 0) egg = "rocket";
const sep = back.includes("?") ? "&" : "?";
res.redirect(egg ? back + sep + "egg=" + egg : back);
}); });
app.post("/projects/:id/star", requireAuth, async (req, res) => { // --- 댓글 삭제 (본인) ---
await db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }); app.post("/comments/:id/delete", requireAuth, async (req, res) => {
res.redirect(`/projects/${req.params.id}`); await db.deleteComment({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
}); });
// --- 부팅 --- // --- 부팅 ---

View File

@@ -1,29 +1,30 @@
// 포털에 모아둘 사이트 카탈로그. 카테고리: 개발 핵심 / AI·데이터. // 대시보드 "개발 도구" 카탈로그.
// icon 은 이모지(추가 의존성 없이 GitHub 감성 카드에 표시).
// //
// url: 각 앱의 'OIDC 로그인 바로 시작' 엔드포인트를 쓰면, Keycloak SSO 세션이 있을 때 // url: 각 앱의 'OIDC 로그인 바로 시작' 엔드포인트를 쓰면, Keycloak SSO 세션이 있을 때
// 로그인 폼 없이 즉시 통과(자동 로그인)된다. 포털도 같은 Keycloak realm 으로 로그인하므로 // 로그인 폼 없이 즉시 통과(자동 로그인)된다. 포털도 같은 Keycloak realm 으로 로그인하므로
// 포털 로그인 후 이 링크를 누르면 바로 들어간다. // 포털 로그인 후 이 링크를 누르면 바로 들어간다.
// - Coder: /api/v2/users/oidc/callback (code 없으면 keycloak authorize 로 307) // - Coder: /api/v2/users/oidc/callback (code 없으면 keycloak authorize 로 307)
// - Gitea: /user/oauth2/keycloak (auth source 명=keycloak, 307 로 keycloak authorize) // - Gitea: /user/oauth2/keycloak (auth source 명=keycloak, 307 로 keycloak authorize)
// SSO 미지원(Kubero=oauth2 미완성, Harbor 등)은 기본 URL 유지. // SSO 미지원(Kubero/Harbor 등)은 기본 URL 유지.
export const siteGroups = [ //
{ // adminOnly: true 인 항목은 관리자(role=admin)에게만 렌더(숨김, disabled 아님).
group: "개발 핵심", // icon: 이모지(폐쇄망에서도 외부 의존 없이 표시). desc: 카드 한 줄 설명.
sites: [ import { config } from "./config.js";
{ name: "Coder", url: "https://coder.bokdev.in/api/v2/users/oidc/callback", icon: "💻", desc: "웹 개발 워크스페이스 (VS Code + AI CLI)", sso: true },
{ name: "Gitea", url: "https://gitea.bokdev.in/user/oauth2/keycloak", icon: "🍵", desc: "사내 Git 저장소", sso: true }, export const sites = [
{ name: "Kubero", url: "https://kubero.bokdev.in", icon: "🚀", desc: "앱 배포 PaaS (*.apps.bokdev.in)" }, // --- 개발 핵심 (모두에게) ---
{ name: "Harbor", url: "https://harbor.bokdev.in", icon: "📦", desc: "컨테이너 이미지 레지스트리" }, { key: "coder", name: "Coder", desc: "클라우드 IDE", url: config.tools.coder, icon: "💻", sso: true },
], { key: "gitea", name: "Gitea", desc: "Git 저장소", url: config.tools.gitea, icon: "🍵", sso: true },
}, { key: "kubero", name: "Kubero", desc: "배포 (PaaS)", url: config.tools.kubero, icon: "🚀" },
{ { key: "harbor", name: "Harbor", desc: "컨테이너 레지스트리", url: config.tools.harbor, icon: "📦" },
group: "AI · 데이터", // --- 인프라/운영 (관리자 전용) ---
sites: [ { key: "openeverest", name: "OpenEverest", desc: "DB 콘솔", url: config.tools.openeverest, icon: "🗄️", adminOnly: true },
{ name: "LiteLLM", url: "https://litellm.bokdev.in", icon: "🤖", desc: "AI 게이트웨이 (모델 키)" }, { key: "minio", name: "MinIO", desc: "오브젝트 스토리지", url: config.tools.minio, icon: "🪣", adminOnly: true },
{ name: "Chat", url: "https://chat.bokdev.in", icon: "💬", desc: "사내 챗 (Ollama)" }, { key: "keycloak", name: "Keycloak", desc: "계정 / SSO 관리", url: config.tools.keycloak, icon: "🔑", adminOnly: true },
{ name: "OpenEverest", url: "https://openeverest.bokdev.in",icon: "🗄️", desc: "DBaaS (DB 프로비저닝)" }, { key: "litellm", name: "LiteLLM", desc: "LLM 게이트웨이", url: config.tools.litellm, icon: "🤖", adminOnly: true },
{ name: "MinIO", url: "https://minioc.bokdev.in", icon: "🪣", desc: "오브젝트 스토리지 콘솔" },
],
},
]; ];
// 역할에 맞는 도구만 추려서 반환. 일반 사용자는 adminOnly 카드를 아예 받지 않는다.
export function toolsForUser(isAdmin) {
return sites.filter((s) => isAdmin || !s.adminOnly);
}

View File

@@ -1,17 +1,61 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko" data-aidev-theme="light">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= brand %></title> <title><%= brand %></title>
<link rel="stylesheet" href="/public/style.css" /> <link rel="stylesheet" href="/public/style.css" />
<script>
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
(function () {
try {
var t = localStorage.getItem("aidev-theme");
if (t === "dark" || t === "light") document.documentElement.setAttribute("data-aidev-theme", t);
} catch (e) {}
})();
</script>
</head> </head>
<body> <body<% if (typeof toast !== "undefined" && toast) { %> data-toast="<%= toast %>"<% } %><% if (typeof egg !== "undefined" && egg) { %> data-egg="<%= egg %>"<% } %><% if (typeof openModal !== "undefined" && openModal) { %> data-open-modal="<%= openModal %>"<% } %>>
<div class="header"> <header class="hdr">
<a class="brand" href="/"><span>🧠</span><%= brand %></a> <div class="hdr-inner">
<div class="spacer"></div> <a class="brand" href="/" id="brandLink">
<% if (user) { %> <span class="brand-mark"><%- icon("code", { size: 22 }) %></span>
<span class="who"><%= user.name %> (<%= user.sabun %>)</span> <span class="brand-name"><%= brand %></span>
<a class="navlink" href="/logout">로그아웃</a> <span class="brand-sub">사내 개발 포털</span>
<% } %> </a>
</div>
<% if (user) { %>
<div class="hdr-search">
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
<input type="search" id="feedSearch" placeholder="프로젝트 검색…" autocomplete="off" />
</div>
<div class="hdr-right">
<button class="icon-btn" id="themeToggle" type="button" title="테마 전환" aria-label="테마 전환">
<span class="theme-ico theme-ico-moon"><%- icon("moon", { size: 16 }) %></span>
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
</button>
<% if (user.isAdmin) { %>
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
<% } %>
<div class="usermenu">
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
<span class="who"><%= user.sabun %></span>
<%- identicon(user.sabun, 26) %>
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
</button>
<div class="usermenu-pop" id="userMenuPop" hidden>
<div class="usermenu-head">
<div class="usermenu-label">로그인 계정</div>
<div class="usermenu-sabun"><%= user.sabun %></div>
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
</div>
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
</div>
</div>
</div>
<% } %>
</div>
</header>

134
views/_project_detail.ejs Normal file
View File

@@ -0,0 +1,134 @@
<%
// 상세(모달/단독 페이지 공용) — locals: p, comments[], me, returnTo, modal(bool)
function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
function reltime(d) {
var s = Math.floor((Date.now() - new Date(d).getTime()) / 1000);
if (s < 60) return "방금 전";
var m = Math.floor(s / 60); if (m < 60) return m + "분 전";
var h = Math.floor(m / 60); if (h < 24) return h + "시간 전";
var dd = Math.floor(h / 24); if (dd < 7) return dd + "일 전";
var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0");
}
function isLgtm(b) { return /\blgtm\b/i.test(b || ""); }
var pdTags = tagList(p.tags);
var tops = comments.filter(function (c) { return !c.parent_id; });
var repliesOf = function (id) { return comments.filter(function (c) { return String(c.parent_id) === String(id); }); };
%>
<div class="modal-head">
<div class="modal-title-row">
<div class="modal-titlewrap">
<div class="modal-title-line">
<span class="modal-repoico"><%- icon("repo", { size: 18 }) %></span>
<h3 class="modal-title"><%= p.title %></h3>
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
</div>
<div class="modal-meta">
<%- identicon(p.owner_sabun, 24) %>
<span class="sabun"><%= p.owner_sabun %></span>
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
</div>
</div>
<% if (modal) { %>
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
<% } else { %>
<a class="back-link" href="/">← 대시보드</a>
<% } %>
</div>
<% if (pdTags.length) { %>
<div class="modal-tags"><% pdTags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<p class="modal-desc"><%= p.description || "설명 없음" %></p>
<div class="modal-actions">
<button type="button" class="modal-action heart<%= p.hearted ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="heart">
<span class="ico ico-on"><%- icon("heartFill", { size: 15 }) %></span><span class="ico ico-off"><%- icon("heart", { size: 15 }) %></span>
<span>좋아요</span><span class="count" data-rcount><%= p.heart_count %></span>
</button>
<button type="button" class="modal-action star<%= p.starred ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="star">
<span class="ico ico-on"><%- icon("starFill", { size: 15 }) %></span><span class="ico ico-off"><%- icon("star", { size: 15 }) %></span>
<span>즐겨찾기</span><span class="count" data-rcount><%= p.star_count %></span>
</button>
<% if (p.app_url) { %>
<a class="modal-action primary" href="<%= p.app_url %>" target="_blank" rel="noopener">바로가기 <%- icon("ext", { size: 12, fill: "#fff" }) %></a>
<% } %>
<% if (p.repo_url) { %>
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
<% } %>
<% if (p.owner_sabun === me) { %>
<form method="post" action="/projects/<%= p.id %>/delete" style="margin-left:auto">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px">프로젝트 삭제</button>
</form>
<% } %>
</div>
</div>
<div class="modal-body">
<div class="comments-title">댓글 <span class="comments-count tabnum"><%= comments.length %></span></div>
<div class="comment-list">
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
<div class="comment-item">
<div class="comment">
<%- identicon(c.author_sabun, 28) %>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author"><%= c.author_sabun %></span>
<span class="comment-time"><%= reltime(c.created_at) %></span>
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
<div class="comment-text"><%= c.body %></div>
<div class="comment-actions">
<button type="button" class="comment-link" data-reply-to="<%= c.id %>" data-reply-name="<%= c.author_sabun %>"><%- icon("reply", { size: 13 }) %>답글</button>
<% if (c.author_sabun === me) { %>
<form method="post" action="/comments/<%= c.id %>/delete" data-stop style="display:inline">
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="comment-link comment-del" data-confirm="댓글을 삭제할까요?">삭제</button>
</form>
<% } %>
</div>
</div>
</div>
<% if (reps.length) { %>
<div class="replies">
<% reps.forEach(function (r) { %>
<div class="reply">
<%- identicon(r.author_sabun, 24) %>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author"><%= r.author_sabun %></span>
<span class="comment-time"><%= reltime(r.created_at) %></span>
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
<div class="comment-text"><%= r.body %></div>
<% if (r.author_sabun === me) { %>
<form method="post" action="/comments/<%= r.id %>/delete" data-stop style="display:inline">
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="comment-link comment-del" data-confirm="댓글을 삭제할까요?" style="margin-top:4px">삭제</button>
</form>
<% } %>
</div>
</div>
<% }); %>
</div>
<% } %>
</div>
<% }); %>
</div>
</div>
<div class="modal-foot">
<form method="post" action="/projects/<%= p.id %>/comments">
<input type="hidden" name="parent_id" value="" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<div class="reply-banner" hidden>
<span><b class="reply-banner-name"></b> 님에게 답글 작성 중</span>
<button type="button" data-cancel-reply>취소</button>
</div>
<div class="comment-form">
<%- identicon(me, 28) %>
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
<button type="submit" class="submit-btn">등록</button>
</div>
<div class="comment-hint">Enter 등록 · Shift+Enter 줄바꿈 · 대댓글은 1뎁스까지만 가능합니다</div>
</form>
</div>

View File

@@ -1,79 +1,246 @@
<%- include("_header") %> <%- include("_header") %>
<div class="container"> <%
<div class="layout"> // 뷰 로컬 헬퍼 (빌드 없이 EJS 스크립틀릿으로)
<!-- 메인: 사이트 모음 + 공유 프로젝트 --> function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
<div> function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
<div class="box"> function isoDate(d) { try { return new Date(d).toISOString().slice(0, 10); } catch (e) { return ""; } }
<div class="box-header">🔗 개발 사이트 바로가기</div> function searchText(p) { return (p.title + " " + (p.description || "") + " " + (p.tags || "") + " " + p.owner_sabun + " " + (p.owner_name || "")).toLowerCase(); }
<div class="box-body"> var myTotal = myProjects.length;
<% siteGroups.forEach(function(g){ %> var mySharedCount = myProjects.filter(function (p) { return p.is_public; }).length;
<div class="group-title"><%= g.group %></div> var myPrivateCount = myTotal - mySharedCount;
<div class="site-grid"> var starredCount = feedProjects.filter(function (p) { return p.starred; }).length;
<% 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 %><% if (s.sso) { %> <span class="sso-badge">SSO 자동로그인</span><% } %></span><br/>
<span class="desc"><%= s.desc %></span>
</span>
</a>
<% }); %>
</div>
<% }); %>
</div>
</div>
<div class="box" style="margin-top:24px"> <main class="main">
<div class="box-header">📂 공유 프로젝트</div>
<% if (projects.length === 0) { %> <!-- ① 개발 도구 -->
<div class="box-body muted">아직 공유된 프로젝트가 없습니다. 오른쪽에서 추가해 보세요.</div> <section class="section">
<% } else { %> <div class="section-head">
<% projects.forEach(function(p){ %> <div class="section-titlewrap">
<div class="proj-item"> <h2 class="section-title">개발 도구</h2>
<a class="proj-title" href="/projects/<%= p.id %>"><%= p.title %></a> <span class="section-sub">SSO로 한 번에 로그인 · 새 탭으로 이동</span>
<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>
<% if (user.isAdmin) { %>
<span class="tool-admin-badge"><%- icon("lock", { size: 11 }) %>관리자 도구 표시 중</span>
<% } %>
</div>
<div class="tools-grid">
<% tools.forEach(function (t) { %>
<a class="tool-card<%= t.adminOnly ? " admin" : "" %>" href="<%= t.url %>" target="_blank" rel="noopener">
<% if (t.sso) { %><span class="sso-badge">SSO</span><% } %>
<span class="tool-icon"><%= t.icon %></span>
<span class="tool-name"><%= t.name %></span>
<span class="tool-desc"><%= t.desc %></span>
<% if (t.adminOnly) { %>
<span class="tool-admin-badge"><%- icon("lock", { size: 11 }) %>관리자 전용</span>
<% } %>
</a>
<% }); %>
</div>
</section>
<!-- 사이드바: 프로젝트 공유 폼 --> <!-- ② 내 프로젝트 -->
<div> <section class="section myproj">
<div class="box"> <span class="myproj-accent"></span>
<div class="box-header"> 프로젝트 공유</div> <div class="myproj-head">
<div class="box-body"> <div class="section-titlewrap">
<form method="post" action="/projects"> <h2 class="section-title">내 프로젝트</h2>
<label>제목 *</label> <span class="section-sub"><%= user.sabun %> 님의 작업 공간</span>
<input type="text" name="title" required placeholder="내 앱 이름" /> </div>
<label>설명</label> <div class="myproj-tools">
<textarea name="description" placeholder="무엇을 만들었나요?"></textarea> <div class="filter-tabs">
<label>Repo URL</label> <button type="button" class="filter-tab on" data-myfilter="all">전체 <%= myTotal %></button>
<input type="url" name="repo_url" placeholder="https://gitea.bokdev.in/..." /> <button type="button" class="filter-tab" data-myfilter="public"><%- icon("globe", { size: 12 }) %>공개 <%= mySharedCount %></button>
<label>앱 URL</label> <button type="button" class="filter-tab" data-myfilter="private"><%- icon("lock", { size: 12 }) %>비공개 <%= myPrivateCount %></button>
<input type="url" name="app_url" placeholder="https://....apps.bokdev.in" /> </div>
<label>태그 (콤마 구분)</label> <button type="button" class="new-proj-btn" data-open-share><%- icon("plus", { size: 15, fill: "#fff" }) %>새 프로젝트</button>
<input type="text" name="tags" placeholder="node, ai" /> </div>
<div style="margin-top:16px"> </div>
<button class="btn btn-primary" style="width:100%" type="submit">공유하기</button>
</div> <div class="myproj-grid" id="myProjGrid">
</form> <% myProjects.forEach(function (p) { var tags = tagList(p.tags); %>
<article class="pcard<%= p.is_public ? "" : " private" %>" data-open-id="<%= p.id %>" data-vis="<%= p.is_public ? "public" : "private" %>">
<div class="pcard-top">
<div class="pcard-titlewrap">
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
<span class="pcard-title"><%= p.title %></span>
</div>
<span class="vis-badge <%= p.is_public ? "public" : "private" %>">
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
</span>
</div> </div>
<div class="pcard-desc"><%= p.description || "설명 없음" %></div>
<% if (tags.length) { %>
<div class="pcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<div class="pcard-foot">
<% if (p.is_public) { %>
<div class="pcard-stats">
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
<span class="stat"><span class="ico-heart"><%- icon("heartFill", { size: 13 }) %></span><span class="tabnum"><%= p.heart_count %></span></span>
<span class="stat"><span class="ico-star"><%- icon("starFill", { size: 13 }) %></span><span class="tabnum"><%= p.star_count %></span></span>
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
</div>
<form method="post" action="/projects/<%= p.id %>/visibility" data-stop style="display:inline">
<input type="hidden" name="is_public" value="0" />
<button type="submit" class="toggle-share-btn" data-stop>비공개로</button>
</form>
<% } else { %>
<span class="private-note">나만 볼 수 있어요</span>
<form method="post" action="/projects/<%= p.id %>/visibility" data-stop style="display:inline">
<input type="hidden" name="is_public" value="1" />
<button type="submit" class="share-mini-btn" data-stop><%- icon("globe", { size: 12, fill: "#fff" }) %>공유하기</button>
</form>
<% } %>
</div>
</article>
<% }); %>
<button type="button" class="add-card" id="addCard" data-open-share>
<span class="add-card-plus"><%- icon("plus", { size: 18, fill: "currentColor" }) %></span>
<span class="add-card-title">새 프로젝트 등록</span>
<span class="add-card-sub">공개·비공개를 정해 등록하세요</span>
</button>
<% if (myTotal === 0) { %>
<div class="empty" style="grid-column:auto">아직 등록한 프로젝트가 없습니다. 첫 프로젝트를 공유해 보세요.</div>
<% } %>
</div>
</section>
<!-- ③ 공유 프로젝트 피드 -->
<section class="section">
<div class="feed-head">
<div class="section-titlewrap">
<h2 class="section-title">공유 프로젝트</h2>
<span class="section-sub">동료들이 만든 결과물 둘러보기</span>
</div>
<div class="feed-head-right">
<button type="button" class="feed-star-btn" id="feedStarFilter" title="내가 즐겨찾기한 프로젝트">
<span class="ico"><%- icon("star", { size: 14 }) %></span>즐겨찾기 <span class="tabnum"><%= starredCount %></span>
</button>
<div class="sort-tabs">
<button type="button" class="sort-tab on" id="sortLatest">최신순</button>
<button type="button" class="sort-tab" id="sortPopular">인기순</button>
</div> </div>
</div> </div>
</div> </div>
<% if (feedProjects.length === 0) { %>
<div class="empty">아직 공유된 프로젝트가 없습니다. 위에서 첫 프로젝트를 공개해 보세요.</div>
<% } %>
<div class="empty" id="feedEmpty" hidden></div>
<div class="feed-grid" id="feedGrid">
<% feedProjects.forEach(function (p) { var tags = tagList(p.tags); %>
<article class="fcard" data-open-id="<%= p.id %>" data-search="<%= searchText(p) %>"
data-created="<%= isoDate(p.created_at) %>" data-pop="<%= p.heart_count %>"
data-starred="<%= p.starred ? "true" : "false" %>">
<div class="fcard-top">
<div class="fcard-titlewrap">
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
<span class="fcard-title"><%= p.title %></span>
</div>
<div class="react-btns">
<button type="button" class="react-btn heart<%= p.hearted ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="heart" title="좋아요">
<span class="ico ico-on"><%- icon("heartFill", { size: 14 }) %></span>
<span class="ico ico-off"><%- icon("heart", { size: 14 }) %></span>
<span class="count" data-rcount><%= p.heart_count %></span>
</button>
<button type="button" class="react-btn star<%= p.starred ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="star" title="즐겨찾기(Star)">
<span class="ico ico-on"><%- icon("starFill", { size: 14 }) %></span>
<span class="ico ico-off"><%- icon("star", { size: 14 }) %></span>
<span class="count" data-rcount><%= p.star_count %></span>
</button>
</div>
</div>
<div class="fcard-desc"><%= p.description || "설명 없음" %></div>
<% if (tags.length) { %>
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<div class="fcard-foot">
<div class="fcard-author">
<%- identicon(p.owner_sabun, 22) %>
<span class="sabun"><%= p.owner_sabun %></span>
<span class="date">· <%= fdate(p.created_at) %></span>
</div>
<div class="fcard-meta">
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
<% if (p.app_url) { %>
<a class="bara-link" href="<%= p.app_url %>" target="_blank" rel="noopener">바로가기 <%- icon("ext", { size: 12 }) %></a>
<% } %>
</div>
</div>
</article>
<% }); %>
</div>
</section>
</main>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand"><span><%- icon("code", { size: 14 }) %></span><%= brand %> · 사내 개발 포털</div>
<div class="footer-tech">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
</div> </div>
</footer>
<!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
<!-- 상세 저장소: 열람 가능한 프로젝트별 모달 내용을 template 으로 보관 -->
<div id="detailStore" style="display:none">
<% allProjects.forEach(function (p) { %>
<template data-detail-id="<%= p.id %>"><%- include("_project_detail", { p: p, comments: commentsByProject[p.id] || [], me: user.sabun, returnTo: "/?open=" + p.id, modal: true }) %></template>
<% }); %>
</div>
<!-- 새 프로젝트 등록 모달 -->
<div class="modal-overlay" id="shareModal" hidden>
<div class="share-modal">
<div class="share-head">
<h3>새 프로젝트 등록</h3>
<button type="button" class="modal-close" data-close-share title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<form method="post" action="/projects">
<div class="share-body">
<label>
<span class="share-label">제목 <span class="req">*</span></span>
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
</label>
<label>
<span class="share-label">한 줄 설명</span>
<input class="share-input" name="description" placeholder="무엇을 하는 프로젝트인가요?" />
</label>
<label>
<span class="share-label">태그 <span class="hint">쉼표로 구분 · 최대 5개</span></span>
<input class="share-input" name="tags" placeholder="infra, react, poc" />
</label>
<label>
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
</label>
<label>
<span class="share-label">배포 URL <span class="hint">선택 · 없으면 바로가기 숨김</span></span>
<input class="share-input mono" name="app_url" placeholder="https://myapp.apps.bokdev.in" />
</label>
<div>
<span class="share-label">공개 범위</span>
<div class="vis-toggle">
<button type="button" class="vis-opt on-public" data-vis="public"><%- icon("globe", { size: 12 }) %>공개</button>
<button type="button" class="vis-opt" data-vis="private"><%- icon("lock", { size: 12 }) %>비공개</button>
</div>
<input type="hidden" name="is_public" value="1" />
<div class="share-vishint">피드에 공개되어 동료가 볼 수 있어요</div>
</div>
</div>
<div class="modal-foot-actions">
<button type="button" class="btn" data-close-share>취소</button>
<button type="submit" class="btn btn-success">등록</button>
</div>
</form>
</div>
</div>
<script src="/public/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,12 +1,12 @@
<%- include("_header") %> <%- include("_header") %>
<div class="login-wrap"> <div class="login-wrap">
<div class="box login-card"> <div class="login-card">
<div class="login-logo">🧠</div> <img class="login-logo" src="/public/logo.svg" width="56" height="56" alt="" />
<div class="login-title"><%= brand %></div> <div class="login-title"><%= brand %></div>
<% if (typeof error !== "undefined" && error) { %> <% if (typeof error !== "undefined" && error) { %>
<div class="alert"><%= error %></div> <div class="alert"><%= error %></div>
<% } %> <% } %>
<a class="btn btn-primary" style="width:100%" href="/auth/login">Keycloak 으로 로그인</a> <a class="btn btn-primary" style="width:100%;justify-content:center" href="/auth/login">Keycloak 으로 로그인</a>
<p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p> <p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p>
</div> </div>
</div> </div>

View File

@@ -1,46 +1,11 @@
<%- include("_header") %> <%- include("_header") %>
<div class="container" style="max-width:760px"> <main class="main">
<a href="/" class="muted">← 대시보드</a> <div class="detail-wrap">
<div class="box" style="margin-top:12px"> <div class="modal" style="margin:0 auto;animation:none">
<div class="box-body"> <%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false }) %>
<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>
</div> </div>
</main>
<script src="/public/app.js"></script>
</body> </body>
</html> </html>