Compare commits

...

55 Commits

Author SHA1 Message Date
355798a506 fix(header,announcements): 헤더 종·관리자 뱃지 순서 교체 + 공지 수정폼 겹침 수정
- 헤더 우측: 알림 종 → 관리자 뱃지 → 프로필 순으로 변경(관리자 뱃지가 프로필 바로 왼쪽)
- 공지 화면 '관리자 공지 모아보기' 서브 문구 제거
- 공지 수정폼이 hidden 이어도 항상 보여 보기/수정이 2단으로 겹치던 버그 수정
  (.ann-form 의 display:flex 가 [hidden] 기본 규칙을 이기던 문제 → .ann-form[hidden]{display:none})

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:08:47 +09:00
a199114453 feat(notifications): 알림 종 + 공지 기능 추가
- 헤더 프로필 왼쪽 알림 종: 드롭다운·안읽음 배지·60초 폴링(최초 폴링 도입)
- 내 프로젝트 좋아요/즐겨찾기/댓글 시 소유자에게 알림(본인 제외·재토글 도배 방지)
- 관리자 공지: /announcements 목록 + 작성/수정/삭제, 등록 시 전 직원에게 fan-out 알림
- notifications/announcements 테이블(멱등) + requireAdmin 가드 + bell/megaphone 아이콘

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:10:04 +09:00
2ca23a00b9 feat(profile): 관리자 뱃지 호버 툴팁 문구 추가
뱃지에 마우스를 올리면 ' Portal 관리자' 가 브라우저 기본 툴팁으로 표시되도록 title 속성(SVG <title> + 래핑 span) 일치시킴. 기존 '관리자 인증' 보다 구체적 문구.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 17:37:46 +09:00
5c76786ada fix(profile): 한줄 소개 편집을 ✓/✕ 아이콘 버튼으로 변경
텍스트 저장/취소 버튼이 줄바꿈되며 프로필 카드를 밀어버리던 문제 수정. input과 같은 줄에 24px check(저장)/x(취소) 아이콘 버튼 2개 배치. JS의 textContent 토글 제거(아이콘 지워지지 않도록 disabled만).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 16:44:47 +09:00
2357dbd152 fix(profile): 한줄 소개 편집 폼이 항상 보이던 버그 수정
.profile-bio-form/.profile-bio-row 의 display:flex 가 HTML hidden 속성의 기본 display:none 을 이겨, 연필 클릭 전에도 폼이 문구와 함께 두 줄로 뜸. [hidden] 규칙을 명시해 숨김 (modal.css 의 reply-banner 패턴과 동일).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 16:01:30 +09:00
d95f968800 feat(profile): 관리자 인증 뱃지 표시 (사번 옆 골드 씰)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:15:04 +09:00
75685e41da feat(responsive): 모바일 대응 — 데스크톱 유지, 반응형 CSS 마지막 로드로 분리
문제: 기존 반응형 @media 가 base.css(2번째 로드)에 있어, 뒤에 로드되는
components/layout 규칙에 덮여 사실상 무력했음(폰에서 그리드가 안 접힘).

- public/css/responsive.css 신설, _header 에서 맨 마지막에 <link>
  → 미디어쿼리가 데스크톱 규칙을 !important 없이 정상 override
- base.css 의 죽은 반응형 블록 제거(responsive.css 로 이관)
- @media 640: 헤더 flex-wrap(검색창 둘째 줄 전체폭·숨김 해제),
  탭 영역 44px, 크롭 스테이지 aspect-ratio 정사각, 입력 16px(iOS 확대 방지),
  좌우 여백/모달 여백 축소, 프로필 히어로 세로 스택
- 데스크톱(>640)은 규칙 미적용 → 기존과 픽셀 동일

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:05:20 +09:00
56430b7701 fix(projects): 등록 폼 '저장소 URL' → 'Git 저장소 URL', README 안내문구 제거
- 라벨을 'Git 저장소 URL' 로 변경
- 'git URL 을 입력하면 README 를 가져옵니다' 인라인 문구 삭제
  (modals.js 는 #repoErr 부재를 안전하게 처리 — 빨간 테두리·포커스 피드백 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:52:09 +09:00
ef3d495878 feat(ui): 푸터 공용화(프로필에도 노출) + 프사 반영 지연 안내
- footer 를 _footer.ejs 파티션으로 분리해 대시보드·프로필 공용으로 include
- footer 문구 '버그 제보:' → '버그 제보 -'
- 프로필 사진 크롭 모달에 '반영까지 최대 몇 분' 안내(연한 힌트) 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:51:52 +09:00
84ce8567e7 feat(profile): 프로필에 '한줄 소개' 추가 — 본인만 인라인 편집
- users.bio 컬럼(멱등) + db.setBio / getUserProfile 조회 포함
- POST /profile/bio (본인만, fetch JSON / 폼 POST 폴백)
- 프로필 히어로: 이름·사번 한 줄로 합치고 한줄 소개를 3줄째로 배치
- 본인은 연필 버튼으로 인라인 편집(저장/취소·Esc), 최대 80자
- word-break: keep-all 로 한글 단어 중간 잘림 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:43:49 +09:00
0ed41849ca docs: README 개행 정렬 2026-07-09 11:11:26 +00:00
b779ed882a fix(dashboard): footer 버그 제보 문구 간결화 — '버그 제보: gitea 이슈'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:27:54 +09:00
ad693304cf fix(sites): 도구 카드 tooltip 문장 끝(.) 뒤 개행 추가 — 4개 tip 두 줄로
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:55:39 +09:00
26cc086414 fix(projects): 상세 모달 열 때 하단 고정 문제 수정
댓글 입력창 focus() 가 브라우저 스크롤을 하단으로 밀어 README 가 길면 상세가 맨 아래에 고정돼 열리던 문제. overlay 스크롤을 맨 위로 되돌리고 focus({ preventScroll: true }) 로 포커스 점프를 막음.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:44:36 +09:00
44cc2af813 fix(sites): LiteLLM 링크를 litellm.bok.or.kr 로 변경
- LiteLLM 만 도메인 접미 bokdev.in → bok.or.kr (서브도메인 litellm 유지)
- 나머지 도구 URL 은 그대로. LITELLM_URL env 미설정이라 기본값 교체로 반영됨

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:01 +09:00
44f7cb4b8b feat(sites): 도구 카드 호버 설명(title) 추가 — Coder·Gitea·Kubero·Coolify
- sites.js 에 tip 필드 추가, dashboard 카드에 title 속성으로 연결(tip 있는 카드만)
- Coder: VS Code Web 개발 환경 / Gitea: 소스 저장·버전 관리·Git 협업
- Kubero: Kubernetes 기반·playground 파이프라인 / Coolify: Docker 기반·aidev 프로젝트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:03:26 +09:00
1f5179cd04 feat(projects): 등록 폼·카드 미리보기·README 가져오기 개선
- 등록 모달: 저장소 URL 필드를 제목 아래(설명 위)로 이동 — README 가져오기 흐름 자연화
- URL 미입력 상태로 README 가져오기 시 입력란에 빨간 인라인 안내 표시(토스트 대신)
- 피드 카드: 설명 미리보기 2줄 클램프 + flex:1 로 태그·푸터를 카드 하단에 정렬
- fetchReadme: CRLF→LF·줄끝공백·연속빈줄 정리로 불필요한 개행 유입 방지
- mdPlain: 개행을 살려(빈 줄만 제거) 카드 미리보기가 README 줄 구조로 보이게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:15:20 +09:00
e21d622ba9 feat(sites): Coolify Keycloak SSO 로그인 시작 URL(/auth/keycloak/redirect) 적용
callback 이 /auth/keycloak/callback 이라 Socialite 시작 경로는 /auth/keycloak/redirect. 포털 SSO 세션이 있으면 폼 없이 통과. COOLIFY_URL 로 덮어쓰기 가능.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:46:26 +09:00
3945992125 feat(sites): 배포 PaaS Coolify 도구 카드 추가 (모두 노출)
Kubero 와 동일한 배포 PaaS. 일반 사용자에게도 노출(adminOnly 아님). URL 은 COOLIFY_URL 환경변수로 덮어쓰기 가능(기본 coolify.bokdev.in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:39:03 +09:00
0c5f10ae18 fix(font): Pretendard 로컬 @font-face 제거 → CDN <link> 로 로드
로컬 /public/fonts/PretendardVariable.woff2 파일이 없어 404가 났다.
인터넷 연결 환경이므로 jsdelivr CDN(pretendard.min.css)으로 불러온다.
미로드 시 base.css 시스템 폰트 스택으로 폴백.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:34:33 +09:00
5e73bfdd28 fix: /favicon.ico 404 제거 — svg 파비콘으로 301 리다이렉트
브라우저가 페이지마다 자동 요청하는 /favicon.ico 라우트가 없어
모든 페이지 콘솔에 404가 떴다. /public/favicon.svg 로 넘긴다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:25:54 +09:00
eb186cbfeb docs: README를 일반 사용자용 포털 사용법 안내로 개편
공개 레포라 비개발자도 보므로 로그인·개발도구·프로젝트 공유/수정·소통
사용법을 앞세우고 매뉴얼 링크 연결. 로그인 용어는 '행번'으로 표기.
개발자용 정보는 하단에 간단히.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:14:47 +09:00
7fe3a8fead feat(projects): 등록한 프로젝트 수정 기능 추가
- 상세(모달/단독) 소유자 영역에 '수정' 버튼 → 등록 모달을 현재 값으로 채워 재사용
- POST /projects/:id/edit 라우트 + db.updateProject(소유자 조건으로 타인 수정 차단)
- 등록/수정 공용 모달을 _project_form_modal.ejs 로 추출(dashboard·project 에서 include)
- pencil 아이콘 추가, modals.js 에 openEdit/openShare 초기화 로직

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:55:09 +09:00
459f37608e chore(egg): 콘솔 환영 문구를 이슈 제보 안내로 변경
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:04:35 +09:00
40f8352a28 feat: 헤더 매뉴얼 버튼 · 프로필 검색 · 상세 뒤로가기 뎁스 정리
- 헤더 프로필 왼쪽에 '매뉴얼' 버튼 추가(MANUAL_URL 환경변수, 기본 gitea MANUAL)
- 검색을 페이지 내 data-search 카드 전체 대상으로 일반화 → 프로필에서 그 사람
  프로젝트도 검색(자리표시자도 페이지별로: 공유/내/○○ 님의 프로젝트)
- 상세 페이지 뒤로가기: ?from= 내부경로 기반으로 프로필→상세는 프로필로 복귀
- '사내 개발 포털' +4pt, 도구 카드 이름·설명 +2pt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:43:32 +09:00
b512ab2143 fix(modal): 상세 모달 폭 확대 + 공개/비공개 배지 클릭 토글
- 모달 max-width 760→880px (min-width 아님: 화면 분할 시 잘림 방지 위해
  반응형 유지).
- 상세 모달의 '공개/비공개' 배지를 소유자가 클릭하면 바로 토글되도록 변경.
  별도 '비공개로 전환' 버튼은 제거(배지가 그 역할). 삭제 버튼은 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:04:19 +09:00
6c2bb888b4 feat: 프로필 사진 업로드·프로필 페이지 + 모달/이름 개선
- 프로필 페이지 /u/:sabun 신설 (본인=비공개 포함, 타인=공개만).
  헤더 "내 프로필", 카드·모달·댓글 작성자 클릭 시 프로필로 이동.
- 프로필 사진 업로드: users.avatar(bytea) 저장, GET /avatar/:sabun
  (업로드본 없으면 identicon SVG 폴백), POST /profile/avatar(express.raw).
  브라우저 canvas 크롭(256px webp) — 서버 이미지 라이브러리/멀터 불필요.
  identicon 6개 렌더 지점을 avatar() 헬퍼로 통일.
- 상세 모달 폭 640→760px, 소유자 액션(비공개 전환·삭제)을 우측 그룹으로
  묶어 한 줄 정렬.
- 표시 이름을 한국식 성+이름(family_name+given_name)으로 교정.
- .project-env.example ADMIN_ROLE→ADMIN_GROUP 정리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:45:47 +09:00
b4c9b4a395 feat(auth): 관리자 판별을 Keycloak 역할(role)에서 그룹(group) 기반으로 변경
- auth.js: realm_access.roles/resource_access 대신 ID 토큰 claims.groups 사용
  (group-membership 매퍼, full.path=false 대비 앞 슬래시 제거 후 비교)
- config.js: adminRole(ADMIN_ROLE, "admin") → adminGroup(ADMIN_GROUP, "DevOps")
- server.js: 세션 user 주석 roles → groups
- CLAUDE.md: 인증 모델·Kubero Env 문서 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:47:34 +09:00
ac545651e6 refactor(tools): Harbor 관리자 전용 · Kubero SSO 표시 · SSO 배지 제거
Harbor 를 관리자 전용(adminOnly)으로 옮기고, Kubero 도 Keycloak SSO 로
로그인 가능하므로 sso 표시를 추가한다. 일반 사용자에게 보이는 도구가
모두 SSO 라 구분 의미가 사라진 SSO 배지(마크업·CSS)를 제거한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:19:49 +09:00
76c82ed24e fix(ui): 푸터를 한 줄로 정리하고 이모지 제거
브랜드 · 버그 제보 안내 · 기술 스택을 한 줄에 배치하고(footer-left 그룹),
버그 제보 문구 끝의 🐛 이모지를 뺐다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:19:18 +09:00
da1653a8ea feat(projects): 설명 마크다운 렌더링 + 저장소 README 가져오기
등록 모달의 '한 줄 설명' input 을 여러 줄 textarea 로 바꾸고, 설명을
markdown-it 로 렌더링한다(html:false 로 raw HTML 이스케이프·javascript:
링크 차단 — XSS 안전). 상세 화면은 마크다운으로, 카드 미리보기는 기호를
걷어낸 순수 텍스트(mdPlain) + 3줄 클램프로 표시.

'저장소 README 가져오기' 버튼과 GET /api/readme 라우트를 추가해 repo_url
의 Gitea README 를 설명란으로 불러올 수 있다(gitea.js fetchReadme).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:18:27 +09:00
4a3e5636c8 feat(projects): 상세 모달에서 공개/비공개 전환 + '비공개로' 버튼 스타일 개선
투명 텍스트라 버튼처럼 안 보이던 .toggle-share-btn 을 테두리·패딩 있는
버튼 모양으로 변경. 프로젝트 상세 모달(소유자)에 '비공개로 전환/공개로
전환' 버튼을 추가하고, 전환 라우트가 return_to 를 존중하도록 해 모달에서
전환하면 모달이 다시 열린 상태로 돌아온다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:15:34 +09:00
761b1a1936 fix(comments): 답글 배너 상시 표시 버그 수정 + 사번 대신 이름 표시
.reply-banner 에 display:flex 가 걸려 [hidden] 기본 규칙을 이겨 배너가
항상 보이던 문제를 .reply-banner[hidden]{display:none} 으로 해결.
댓글/대댓글 작성자·답글 대상·프로젝트 소유자를 사번 대신 이름(author_name/
owner_name)으로 표시하고, 사번은 title 툴팁으로 남긴다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:14:23 +09:00
0310700
3ddf920e56 feat(ui): 푸터에 버그 제보(Gitea 이슈) 링크 추가
대시보드 푸터에 '버그가 있다면 이슈에 제보바랍니다' 안내를 자체 줄로 작게 표시.
링크는 gitea playground/ai-dev-portal issues, 새 탭·rel=noopener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:12:47 +09:00
0310700
1f76329140 fix(reactions): 좋아요·즐겨찾기 값을 카드·통계·모달에서 전역 동기화
같은 프로젝트가 피드 카드 버튼·내 프로젝트 통계 줄·상세 모달(template) 세 곳에
렌더되는데, 기존 syncReaction 은 라이브 data-react 버튼만 갱신해 통계 줄과
모달(inert template 스냅샷)이 옛 값으로 남아 서로 달라졌다.

- reactions.js: 프로젝트별 {on,count} 를 단일 store(Map)로 관리. 로드 시
  detailStore 템플릿에서 서버 원본값을 시드하고, 변경 시 그 값을 그리는 모든
  라이브 DOM(버튼·통계 줄·data-pop)을 자동으로 다시 칠함.
- modals.js: 모달 마운트 직후 applyReactions 로 현재 store 값을 덮어써
  템플릿 스냅샷의 옛 값 대신 최신 값을 표시.
- dashboard.ejs: 내 프로젝트 통계 숫자에 data-rcount/data-id/data-kind 바인딩 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:12:38 +09:00
0310700
6ff8034856 fix(ui): 토스트 표시 후 주소창의 ?toast·?egg·?open 쿼리 제거
initSignals 가 신호를 소비한 뒤 history.replaceState 로 1회성 쿼리만 제거.
POST→Redirect→GET·비-JS 폴백은 유지하고, 새로고침/공유 시 토스트 재출력도 방지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:10:14 +09:00
0310700
12d620449e feat(ui): 파비콘 </> 아이콘 추가
헤더 브랜드와 동일한 codeslash </> 글리프를 브랜드 그라디언트 타일 위에 얹어
탭에서 16px 로도 읽히게 함. 모든 화면(_header include)에 적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:10:14 +09:00
540a170b0d fix(modal): 모달 닫기·프로젝트 삭제 동작 + 세로 가운데 정렬
- 모달 오버레이 마커를 data-open-id → data-open-pid 로 변경
  (열기 감지 셀렉터 [data-open-id] 와 충돌해 모달 내부 클릭이
   닫기/삭제 대신 모달 재오픈으로 잡히던 문제)
- .modal/.share-modal 에 margin:auto → 짧으면 정중앙, 길면 스크롤
- 프로젝트 삭제 버튼 앞에 휴지통 아이콘 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:32:28 +09:00
ebf9599199 feat(projects): 메인 언어 Gitea 자동 판별 + 수동 선택 폴백
- src/gitea.js: repo_url 파싱 + 언어 통계 API(fetchMainLang) 신규
- 등록 폼에 '메인 언어' 드롭다운 추가(기본 '자동 감지')
- 결정 순서: 드롭다운 → Gitea 자동감지 → 태그 추론 → 미표시
- langFromTags 기본값 TypeScript 제거(안 맞으면 미표시)
- GITEA_TOKEN(선택): 비공개 레포 자동감지용 스위치

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:56:00 +09:00
0310700
fdfd7dca98 feat(ui): 헤더 브랜드 로고를 </> (codeslash)로 변경
기존 code 아이콘(<>)은 슬래시가 없어 로그인창 </> 로고와 불일치.
박스 없이 헤더 색 상속하는 codeslash 아이콘 추가해 헤더에 적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:51:51 +09:00
0310700
096f7fbbcb feat(auth): Keycloak RP-initiated 단일 로그아웃(SLO) 적용
feat/sso 작업을 라우트 개편된 현재 main 에 이식.
- handleCallback 이 id_token 반환, 콜백에서 분리해 session.idToken 에만 보관(뷰/DB 비노출)
- logoutUrl(): end_session_endpoint + id_token_hint 로 확인화면 없이 즉시 로그아웃
- /logout: 로컬 세션 destroy 후 Keycloak end-session 으로 리다이렉트해 SSO 세션까지 종료

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:12:42 +09:00
2b74b59f81 refactor(js): app.js 를 브라우저 네이티브 ESM 모듈로 분할
public/js/{util,ui,reactions,modals,comments,feed,main}.js 로 분리하고
dashboard/project 에서 <script type="module" src="/public/js/main.js"> 로 로드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:53:46 +09:00
bc1327a6dc refactor(css): 단일 style.css 를 역할별 파일로 분할 + Pretendard 로컬 호스팅
public/css/{tokens,base,layout,components,modal}.css 로 분리하고 _header 에서 다중 link 로드.
tokens.css 에 Pretendard @font-face(public/fonts/PretendardVariable.woff2, 없으면 시스템 폰트 폴백).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:53:36 +09:00
272b315ab0 docs: 빌드 없는 정리·분할 허용으로 단일 CSS 제약 완화 (CLAUDE.md)
프레임워크/번들러/빌드는 계속 금지하되, 다중 <link> CSS·네이티브 ESM 모듈 분할은 허용.
임시 미리보기 파일(_preview.mjs)을 .gitignore 에 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:53:24 +09:00
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
48 changed files with 6372 additions and 379 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\""
}
]
}
]
}
}

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ npm-debug.log*
dist/ dist/
.DS_Store .DS_Store
HANDOFF.md HANDOFF.md
_preview.mjs
# bkit 플러그인 런타임/감사 상태 (앱과 무관)
.bkit/

View File

@@ -20,3 +20,23 @@ 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 그룹(group)에 속한 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
# (portal 클라이언트의 group-membership 매퍼가 ID 토큰에 groups 클레임을 넣어줌)
ADMIN_GROUP=DevOps
# --- Gitea 언어 자동 판별 (선택) ---
# 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다.
# 공개 레포는 토큰 없이 동작. 비공개 레포까지 판별하려면 read:repository 권한 토큰 필요.
# GITEA_TOKEN=
# --- 도구 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

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이트로 이동**하고, **만든 프로젝트를
공유·코멘트**하는 곳. Keycloak SSO(사번 로그인) + Node(Express+EJS) + Postgres.
## 이 저장소를 다룰 때 (먼저 읽기)
- **사용자는 개발 전문가가 아닌 사내 IT 기획자/직원**이다. 변경은 **단순하게** 유지한다.
- 무거운 도구는 피한다: **프런트엔드 프레임워크(React 등)·번들러·빌드 단계는 도입하지 않는다.**
서버 렌더링(EJS) 유지.
- 단, **빌드가 필요 없는 범위의 정리·분할은 허용**한다 — CSS 를 역할별 파일로 나눠 다중 `<link>`,
JS 를 브라우저 **네이티브 ESM 모듈**(`<script type="module">` + `import`)로 분할 등.
- 새 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, groups, isAdmin }`.
- **관리자 = `groups` 클레임에 `ADMIN_GROUP`(기본 `DevOps`) 포함.** (portal 클라이언트의 group-membership
매퍼가 ID 토큰에 `groups` 를 넣어줌. `full.path=false` 라 앞 슬래시는 떼고 비교.) 인프라 도구(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_GROUP`, (선택) 도구 `*_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`
로그인/도구/내프로젝트/피드/모달/공유/❤️·⭐/댓글·대댓글/삭제/테마/검색 수동 확인.
---
### 진행 메모
- (작업하며 결정·이슈를 여기에 누적)

View File

@@ -1,17 +1,53 @@
# AI DEV — 사내 개발 포털 # AI DEV — 사내 개발 포털
직원이 한 곳에서 **사내 개발 사이트로 이동**하고, **만든 프로젝트를 공유·코멘트**하는 포털. 사내 개발 도구로 **한 번에 이동**하고, 내가 만든 프로젝트를 동료와 **공유·소통**하는 곳입니다.
GitHub 감성 UI. Keycloak SSO(사번 로그인). Node(Express+EJS) + Postgres. 행번으로 로그인하면 바로 쓸 수 있어요.
> 브랜드명 `AI DEV` 는 `PORTAL_BRAND` 환경변수로 바꿀 수 있습니다. 🔗 접속: **https://portal.bokdev.in**
📖 자세한 사용 매뉴얼: **https://gitea.bokdev.in/playground/MANUAL** (포털 헤더의 `매뉴얼` 버튼에서도 열 수 있어요)
## 기능
- **Keycloak OIDC 로그인** (realm `bokdev`, client `portal`, 사번 = preferred_username)
- **개발 사이트 모음** — 개발 핵심(Coder/Gitea/Kubero/Harbor) + AI·데이터(LiteLLM/Chat/OpenEverest/MinIO). 목록은 `src/sites.js`.
- **프로젝트 공유** — repo/앱 URL·설명·태그
- **코멘트 / 스타**
## 로컬 실행
## 처음 오셨나요?
1. **로그인** — 포털에 들어가면 자동으로 사내 로그인(Keycloak) 화면으로 이동합니다.
**행번**과 비밀번호로 로그인하세요. 별도 회원가입은 없습니다.
2. 로그인하면 대시보드가 열립니다. 아래 세 가지를 할 수 있어요.
## 무엇을 할 수 있나요?
### 1. 개발 도구 바로가기
대시보드 맨 위 **개발 도구** 카드를 누르면 새 탭으로 이동합니다.
포털에 로그인돼 있으면 대부분 **다시 로그인 없이(SSO)** 바로 들어갑니다.
- 모두 사용: **Coder**(클라우드 IDE), **Gitea**(Git 저장소), **Kubero**(배포)
- 관리자 전용: Harbor·OpenEverest·MinIO·Keycloak·LiteLLM (권한이 있는 분에게만 보입니다)
### 2. 내 프로젝트 공유
- **새 프로젝트** 버튼으로 등록합니다. 제목·설명(Markdown 지원)·태그·저장소 URL·배포 URL을 넣을 수 있어요.
저장소 URL을 넣고 **README 가져오기**를 누르면 설명을 자동으로 채워줍니다.
- **공개/비공개**를 고를 수 있습니다. 공개하면 동료들의 피드에 보이고, 비공개는 나만 봅니다(언제든 전환 가능).
- 등록한 프로젝트는 상세 화면의 **✏️ 수정** 버튼으로 내용을 고치거나 삭제할 수 있습니다.
### 3. 동료 프로젝트 둘러보기
- **공유 프로젝트** 피드에서 동료들이 만든 결과물을 봅니다. 최신순·인기순 정렬, 헤더 검색으로 찾기.
- ❤️ **좋아요**, ⭐ **즐겨찾기**, 💬 **댓글**(답글·LGTM)로 응원과 피드백을 남겨보세요.
- 이름/아바타를 누르면 그 사람의 **프로필**로 이동해 그가 만든 프로젝트를 모아 볼 수 있습니다.
### 그 외
- **프로필 사진**을 올릴 수 있어요(내 프로필 → 사진의 + 버튼).
- 헤더에서 **다크/라이트 테마**를 전환할 수 있습니다.
## 문제가 있거나 아이디어가 있다면
버그·건의는 이슈로 남겨 주세요 — 언제든 환영합니다.
👉 https://gitea.bokdev.in/playground/ai-dev-portal/issues
## 개발자용 (참고)
서버 렌더링 기반의 가벼운 웹앱입니다. **Node(Express + EJS) + Postgres**, 로그인은 **Keycloak OIDC**.
프런트 프레임워크·번들러 없이 정적 CSS/바닐라 JS로 동작합니다.
```bash ```bash
cp .project-env.example .project-env # 값 채우기 (OIDC_CLIENT_SECRET 등) cp .project-env.example .project-env # 값 채우기 (OIDC_CLIENT_SECRET 등)
set -a; . ./.project-env; set +a set -a; . ./.project-env; set +a
@@ -19,17 +55,7 @@ npm install
npm run db:init # schema.sql 적용 (최초 1회) npm run db:init # schema.sql 적용 (최초 1회)
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
``` ```
- 로컬에서 OIDC 콜백을 받으려면 Keycloak `portal` client redirect 에
`http://localhost:3000/auth/callback` 이 등록돼 있어야 합니다(이미 등록됨).
## 배포 (Kubero) - 배포 Kubero(NodeJS buildpack), 도메인 `portal.bokdev.in`.
- NodeJS buildpack (`npm install``node index.js`). Dockerfile 불필요. - 브랜드명 `AI DEV``PORTAL_BRAND` 환경변수로 바꿀 수 있습니다.
- Kubero Env: `BASE_URL=https://portal.bokdev.in`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`. - 코드 구조·작업 규약·환경변수 등 자세한 내용은 **`CLAUDE.md`** 를 참고하세요.
- 도메인: `portal.bokdev.in` (게이트웨이/라우트 별도 구성 필요) 또는 `<app>.apps.bokdev.in`.
- 배포 도메인이 정해지면 Keycloak `portal` client redirect 에 `https://<도메인>/auth/callback` 추가.
## 연결 정보
- **DB**: OpenEverest `postgresql-6ox``appdb`, schema `portal`, role `portal_app`.
- **Issuer**: `https://keycloak.bokdev.in/realms/bokdev`
자세한 작업 규약은 `CLAUDE.md`.

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

86
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"markdown-it": "^14.3.0",
"openid-client": "^5.7.0", "openid-client": "^5.7.0",
"pg": "^8.13.1" "pg": "^8.13.1"
}, },
@@ -32,6 +33,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -242,6 +249,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -562,6 +581,25 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -574,6 +612,33 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -583,6 +648,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -898,6 +969,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -1135,6 +1215,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uid-safe": { "node_modules/uid-safe": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",

View File

@@ -17,6 +17,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"markdown-it": "^14.3.0",
"openid-client": "^5.7.0", "openid-client": "^5.7.0",
"pg": "^8.13.1" "pg": "^8.13.1"
} }

59
public/css/base.css Normal file
View File

@@ -0,0 +1,59 @@
/* 리셋 · 타이포 · 공용 유틸(.btn/.tag/.avatar) · 로그인 · 키프레임 · 반응형. */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
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); }
a { color: inherit; text-decoration: none; }
svg { display: block; }
.tabnum { font-variant-numeric: tabular-nums; }
/* 공통 아바타 래퍼 (identicon 헬퍼가 사용) */
.avatar {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
overflow: hidden; background: var(--avatar-bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
}
.tag { font-size: 11px; font-weight: 500; color: var(--accent-fg); background: var(--accent-bg); border-radius: 999px; padding: 1px 9px; }
/* 버튼/링크 공용 (단독 페이지·로그인) */
.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} }
/* 반응형 규칙은 public/css/responsive.css 로 이동(로드 순서상 마지막이어야 실제 적용됨). */

220
public/css/components.css Normal file
View File

@@ -0,0 +1,220 @@
/* 콘텐츠 컴포넌트 — 개발 도구 · 내 프로젝트 · 공유 피드 카드. */
/* ===================== ① 개발 도구 ===================== */
.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: 16.5px; font-weight: 700; color: var(--fg); }
.tool-desc { display: block; margin-top: 3px; font-size: 14.5px; color: var(--fg2); line-height: 1.45; }
.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; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; }
.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); }
/* 배지 자체를 클릭하면 공개/비공개 토글(소유자). 버튼 기본 스타일 초기화 + 클릭 어포던스. */
.vis-badge-form { display: inline-flex; }
.vis-badge-btn { cursor: pointer; font-family: inherit; line-height: 1.4; transition: filter .14s ease, box-shadow .14s ease; }
.vis-badge-btn:hover { filter: brightness(1.05); box-shadow: 0 0 0 3px var(--focus-ring); }
.toggle-share-btn {
display: inline-flex; align-items: center; gap: 5px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 7px; padding: 4px 10px;
cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease, border-color .14s ease;
}
.toggle-share-btn:hover { background: var(--btn-hover); color: var(--fg); border-color: var(--border); }
.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; }
/* flex:1 로 설명 영역이 남는 높이를 채워, 태그+푸터를 카드 하단에 정렬한다(짧은 카드의 큰 빈 공간 방지). 미리보기는 2줄까지만. */
.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; flex: 1; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; }
.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; }
.fcard .fcard-foot > .date { font-size: 11.5px; color: var(--fg3); margin-left: auto; }
/* ===================== 프로필 링크(아바타+이름) ===================== */
/* 카드/모달/댓글의 작성자 아바타·이름을 프로필로 잇는 링크. 클릭 시 카드 모달 대신 이동. */
.fcard-author:hover .sabun { text-decoration: underline; }
.meta-author { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.meta-author:hover .sabun { text-decoration: underline; }
.cavatar { display: inline-flex; flex: 0 0 auto; }
.comment-author:hover { text-decoration: underline; }
/* ===================== 프로필 페이지 ===================== */
.profile-hero { display: flex; align-items: center; gap: 20px; }
.profile-avatar-wrap { position: relative; flex: 0 0 auto; }
.profile-avatar { width: 96px; height: 96px; border-radius: 24px; object-fit: cover; background: var(--avatar-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent); display: block; }
.profile-avatar-edit { position: absolute; right: -4px; bottom: -4px; width: 30px; height: 30px; border-radius: 50%; border: 2px solid var(--card); background: var(--link); color: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px var(--shadow); }
.profile-avatar-edit:hover { filter: brightness(1.08); }
.profile-info { min-width: 0; }
.profile-idline { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.admin-badge-wrap { display: inline-flex; align-self: center; margin-left: -2px; cursor: default; }
.profile-name { margin: 0; font-size: 24px; font-weight: 800; color: var(--fg); letter-spacing: -.01em; }
.profile-sabun { font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
.profile-metaline { margin-top: 6px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }
/* 한줄 소개 */
.profile-bio-row { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
/* hidden 속성이 켜져 있으면 확실히 숨긴다 (display:flex 가 [hidden] 기본 규칙을 이겨 항상 보이던 버그 방지). */
.profile-bio-row[hidden] { display: none; }
.profile-bio { margin: 0; font-size: 13.5px; color: var(--fg2); line-height: 1.5; word-break: keep-all; overflow-wrap: break-word; }
.profile-bio.is-empty { color: var(--fg3); font-style: italic; }
.profile-bio-edit { flex: 0 0 auto; width: 24px; height: 24px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--fg3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.profile-bio-edit:hover { color: var(--fg); border-color: var(--fg3); }
.profile-bio-save { color: var(--link); border-color: var(--link); }
.profile-bio-save:hover { background: var(--link); color: #fff; border-color: var(--link); }
.profile-bio-form { margin-top: 10px; display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
/* hidden 속성이 켜져 있으면 확실히 숨긴다 (display:flex 가 [hidden] 기본 규칙을 이겨 항상 보이던 버그 방지). */
.profile-bio-form[hidden] { display: none; }
.profile-bio-form input { flex: 1 1 auto; min-width: 0; padding: 6px 10px; font-size: 13.5px; color: var(--fg); background: var(--card); border: 1px solid var(--border); border-radius: 8px; }
.profile-bio-form input:focus { outline: none; border-color: var(--link); }
/* ===================== 프로필 사진 크롭 모달 ===================== */
.crop-modal { width: 100%; max-width: 420px; margin: auto; 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; }
.crop-head { padding: 16px 20px; border-bottom: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; }
.crop-head h3 { margin: 0; font-size: 16px; font-weight: 800; color: var(--fg); }
.crop-body { padding: 18px 20px; }
.crop-stage { position: relative; width: 300px; height: 300px; max-width: 100%; margin: 0 auto; border-radius: 12px; overflow: hidden; background: var(--inset); touch-action: none; cursor: grab; user-select: none; }
.crop-stage:active { cursor: grabbing; }
#cropImg { position: absolute; top: 0; left: 0; max-width: none; transform-origin: 0 0; will-change: transform; pointer-events: none; }
.crop-veil { position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent); border-radius: 12px; }
.crop-zoom { margin-top: 14px; display: flex; align-items: center; gap: 10px; color: var(--fg2); }
.crop-zoom input[type=range] { flex: 1; accent-color: var(--link); }
.crop-hint { margin: 10px 0 0; font-size: 11.5px; color: var(--fg3); line-height: 1.5; }
/* ===================== 공지사항(📢) 페이지 ===================== */
/* 작성/수정 공용 폼 */
.ann-form { display: flex; flex-direction: column; gap: 10px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; padding: 16px; }
/* hidden 속성이 켜져 있으면 확실히 숨긴다 (display:flex 가 [hidden] 기본 규칙을 이겨 수정폼이 항상 보이던 버그 방지). */
.ann-form[hidden] { display: none; }
.ann-form-new { margin-bottom: 24px; }
.ann-input-title, .ann-input-body {
width: 100%; box-sizing: border-box; font-family: inherit; font-size: 14px; color: var(--fg);
background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none;
}
.ann-input-title { font-weight: 600; }
.ann-input-body { resize: vertical; line-height: 1.55; }
.ann-input-title:focus, .ann-input-body:focus { border-color: var(--link); }
.ann-form-actions { display: flex; align-items: center; gap: 8px; }
.ann-empty { padding: 28px; text-align: center; color: var(--fg2); font-size: 14px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; }
.ann-list { display: flex; flex-direction: column; gap: 14px; }
.ann-item { background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 18px 20px; }
.ann-item-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.ann-item-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fg); word-break: break-word; }
.ann-item-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.ann-del:hover { color: var(--danger); border-color: var(--danger); }
.ann-meta { margin-top: 5px; font-size: 12px; color: var(--fg2); }
.ann-body { margin-top: 12px; }
.ann-item .ann-edit { margin-top: 4px; }

118
public/css/layout.css Normal file
View File

@@ -0,0 +1,118 @@
/* 골격 — 헤더 · 메인/섹션 · 푸터. */
/* ===================== 헤더 ===================== */
.hdr { position: sticky; top: 0; z-index: 300; background: var(--hbg); border-bottom: 1px solid var(--hbd); }
.hdr-inner { max-width: 1180px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 16px; }
.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: 15px; 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; }
.hdr-search-ico { position: absolute; left: 10px; display: inline-flex; color: var(--hfg2); pointer-events: none; }
.hdr-search input {
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;
}
.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); }
/* 헤더 텍스트 링크 버튼(매뉴얼 등) — 아이콘 + 글자 */
.hdr-link {
display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px;
border-radius: 8px; border: 1px solid var(--hbd); background: transparent;
color: var(--hfg2); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap;
transition: background .14s ease, color .14s ease;
}
.hdr-link: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); }
/* ===================== 알림(🔔) 종 ===================== */
.notif { position: relative; }
.notif-btn { position: relative; } /* 배지 기준 */
/* 안 읽은 수 배지 — 종 우상단 빨강 pill */
.notif-badge {
position: absolute; top: -4px; right: -4px; min-width: 16px; height: 16px; padding: 0 4px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--danger); color: #fff; font-size: 10px; font-weight: 700; line-height: 1;
border-radius: 999px; border: 1px solid var(--hbg); box-sizing: border-box;
}
.notif-badge[hidden] { display: none; }
/* 드롭다운 패널 — 사용자 메뉴 팝오버와 동일 패턴(더 넓게) */
.notif-pop {
position: absolute; top: 46px; right: 0; width: min(340px, calc(100vw - 32px));
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;
}
.notif-pop[hidden] { display: none; }
.notif-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 8px; border-bottom: 1px solid var(--divider); margin-bottom: 4px; }
.notif-title { font-size: 13px; font-weight: 700; color: var(--fg); }
.notif-readall { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; color: var(--link); padding: 2px 4px; }
.notif-readall:hover { text-decoration: underline; }
.notif-list { max-height: 360px; overflow-y: auto; }
.notif-item { display: flex; gap: 9px; align-items: flex-start; padding: 9px 10px; border-radius: 8px; text-decoration: none; color: var(--fg); }
.notif-item:hover { background: var(--panel); }
.notif-item.unread { background: var(--attn-bg); }
.notif-item-ico { flex: 0 0 auto; font-size: 15px; line-height: 1.3; }
.notif-item-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.notif-item-text { font-size: 12.5px; line-height: 1.4; color: var(--fg); word-break: break-word; }
.notif-item-time { font-size: 11px; color: var(--fg3); }
.notif-empty { padding: 22px 10px; text-align: center; color: var(--fg3); font-size: 12.5px; }
.notif-foot { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px; margin-top: 4px; border-top: 1px solid var(--divider); font-size: 12.5px; font-weight: 600; color: var(--link); text-decoration: none; }
.notif-foot:hover { background: var(--panel); border-radius: 6px; }
/* ===================== 메인 / 섹션 ===================== */
.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); }
/* ===================== 푸터 ===================== */
.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-left { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.footer-brand { display: inline-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; }
/* 버그 제보 안내 — 브랜드 옆에 한 줄로 이어 표시 */
.footer-bug { font-size: 11.5px; color: var(--fg3); }
.footer-bug a { color: var(--link); text-decoration: none; }
.footer-bug a:hover { text-decoration: underline; }

128
public/css/modal.css Normal file
View File

@@ -0,0 +1,128 @@
/* 모달 — 상세 모달 · 댓글 스레드 · 공유(등록) 모달 · 토스트 · 플라이어. */
/* ===================== 상세 모달 ===================== */
.modal-overlay { position: fixed; inset: 0; z-index: 500; background: var(--overlay); display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; overflow-y: auto; animation: aidev-fade .16s ease-out; }
.modal-overlay[hidden] { display: none; }
/* margin:auto → 내용이 짧으면 화면 정중앙, 길면 위에 붙어 스크롤(잘림 방지). */
.modal { width: 100%; max-width: 880px; margin: auto; 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-desc-empty { color: var(--fg3); }
/* 설명(README) 마크다운 본문 */
.md-body { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; word-break: break-word; }
.md-body > :first-child { margin-top: 0; }
.md-body > :last-child { margin-bottom: 0; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { margin: 18px 0 8px; font-weight: 800; line-height: 1.3; letter-spacing: -.01em; }
.md-body h1 { font-size: 19px; padding-bottom: 6px; border-bottom: 1px solid var(--divider); }
.md-body h2 { font-size: 16.5px; padding-bottom: 5px; border-bottom: 1px solid var(--divider); }
.md-body h3 { font-size: 15px; }
.md-body h4 { font-size: 14px; }
.md-body p { margin: 10px 0; }
.md-body ul, .md-body ol { margin: 10px 0; padding-left: 22px; }
.md-body li { margin: 3px 0; }
.md-body a { color: var(--link); text-decoration: none; }
.md-body a:hover { text-decoration: underline; }
.md-body code { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 12.5px; background: var(--inset); border: 1px solid var(--border-soft); border-radius: 5px; padding: 1px 5px; }
.md-body pre { background: var(--inset); border: 1px solid var(--border-soft); border-radius: 9px; padding: 12px 14px; overflow-x: auto; margin: 12px 0; }
.md-body pre code { background: transparent; border: 0; padding: 0; font-size: 12.5px; }
.md-body blockquote { margin: 12px 0; padding: 2px 14px; border-left: 3px solid var(--border); color: var(--fg2); }
.md-body hr { border: 0; border-top: 1px solid var(--divider); margin: 16px 0; }
.md-body table { border-collapse: collapse; margin: 12px 0; display: block; overflow-x: auto; }
.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 12px; font-size: 13px; }
.md-body th { background: var(--panel); font-weight: 700; }
.md-body img { max-width: 100%; border-radius: 8px; }
.modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* 소유자 액션(비공개 전환 · 삭제)을 우측에 한 덩어리로 묶어, 개별 버튼이 홀로 줄바꿈되지 않게 한다. */
.modal-actions-owner { margin-left: auto; 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; }
/* hidden 속성이 켜져 있으면 확실히 숨긴다 (display:flex 가 [hidden] 기본 규칙을 이겨 항상 보이던 버그 방지). */
.reply-banner[hidden] { display: none; }
.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; margin: auto; 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-textarea { resize: vertical; min-height: 96px; line-height: 1.6; }
.share-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.readme-btn {
display: inline-flex; align-items: center; gap: 5px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 7px; padding: 4px 9px;
cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease;
}
.readme-btn:hover { background: var(--btn-hover); color: var(--fg); }
.readme-btn:disabled { opacity: .6; cursor: default; }
.share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
/* README 가져오기 실패(URL 미입력) 시 빨간 안내 + 입력란 강조 */
.share-err { display: block; margin-top: 6px; font-size: 12px; font-weight: 600; color: var(--danger); }
.share-input.err { border-color: var(--danger); }
.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; }

60
public/css/responsive.css Normal file
View File

@@ -0,0 +1,60 @@
/* 반응형(모바일) — 데스크톱 규칙을 덮어써야 하므로 반드시 다른 CSS 이후 마지막에 로드한다.
(_header.ejs 의 <link> 순서가 바뀌면 미디어쿼리가 무력화됨. 특히 헤더 검색창 재노출이 로드 순서에 의존.)
데스크톱(>640px 등)은 아래 규칙이 적용되지 않아 기존과 완전히 동일하다. */
/* ===================== 그리드 단계 축소 ===================== */
/* (기존 base.css 에 있었으나 로드 순서 때문에 무력했던 규칙 — 여기로 옮겨 실제 작동) */
@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; }
}
/* ===================== 모바일 레이아웃 · 터치 다듬기 ===================== */
@media (max-width: 640px) {
/* 헤더: 고정 높이 해제 → 줄바꿈 허용. 검색창은 둘째 줄 전체 폭으로. */
.hdr-inner { flex-wrap: wrap; height: auto; min-height: 56px; padding: 8px 16px; row-gap: 8px; }
.brand { flex: 1 1 auto; min-width: 0; }
.hdr-right { margin-left: auto; } /* 브랜드와 같은 첫 줄, 우측 정렬 유지 */
.hdr-search { display: flex; order: 10; flex-basis: 100%; max-width: none; } /* base 의 display:none 을 로드순서로 이김 → 둘째 줄 */
/* 터치 영역 ≥44px */
.icon-btn, .modal-close { width: 44px; height: 44px; }
.hdr-link, .usermenu-btn { min-height: 44px; }
.profile-bio-edit { width: 40px; height: 40px; } /* 배지형이라 44는 과함 */
.profile-bio-form .profile-bio-edit { width: 32px; height: 32px; } /* 폼 안 check/x 는 인풋과 한 줄 유지 */
.profile-avatar-edit { width: 36px; height: 36px; }
.filter-tab, .sort-tab, .vis-opt, .react-btn, .readme-btn,
.toggle-share-btn, .share-mini-btn, .new-proj-btn, .feed-star-btn,
.comment-link, .modal-action, .btn { min-height: 44px; }
/* 크롭 스테이지 정사각 유지(profile.js 의 clientWidth==clientHeight 가정 충족) */
.crop-stage { width: 100%; height: auto; aspect-ratio: 1 / 1; }
/* 입력창 16px — iOS 포커스 시 자동확대 방지 */
.hdr-search input, .share-input, .share-input.mono,
.comment-input, .profile-bio-form input { font-size: 16px; }
/* 좌우 여백 축소 (24px → 16px) */
.main { padding: 20px 16px 48px; }
.detail-wrap { padding: 20px 16px 48px; } /* project.ejs 단독 상세 */
.footer-inner { padding: 16px; }
/* 줄바꿈 / 프로필 히어로 세로 스택 */
.section-head, .feed-head, .feed-head-right { flex-wrap: wrap; }
.profile-hero { flex-direction: column; align-items: center; text-align: center; gap: 14px; }
.profile-idline, .profile-metaline, .profile-bio-row, .profile-bio-form { justify-content: center; }
/* 모달 여백 축소 */
.modal-overlay { padding: 16px 12px; }
.modal-head { padding: 16px 16px 14px; }
.modal-body { padding: 14px 16px 4px; }
.modal-foot { padding: 12px 16px 18px; }
.modal-foot-actions{ padding: 12px 16px; }
.share-head, .share-body, .crop-body { padding: 16px; }
.crop-head { padding: 14px 16px; }
}

39
public/css/tokens.css Normal file
View File

@@ -0,0 +1,39 @@
/* 디자인 토큰 — 폰트 + CSS 변수(라이트/다크). 가장 먼저 로드. */
/* Pretendard — CDN(jsdelivr)에서 로드. views/_header 의 <link> 로 불러온다(인터넷 연결 환경).
미로드 시 base.css 의 시스템 폰트 스택으로 폴백. */
: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;
}

14
public/favicon.svg Normal file
View File

@@ -0,0 +1,14 @@
<!-- 파비콘 — 헤더 브랜드와 동일한 codeslash </> 글리프(흰색)를 브랜드 그라디언트 타일 위에.
탭에서 16px 로 작게 보여도 </> 가 읽히도록 배경 대비를 준다. -->
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="&lt;/&gt;">
<defs>
<linearGradient id="fav" 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(#fav)"/>
<g transform="translate(6 6) scale(1.25)" fill="#fff">
<path d="M10.478 1.647a.5.5 0 1 0-.956-.294l-4 13a.5.5 0 0 0 .956.294l4-13zM4.854 4.146a.5.5 0 0 1 0 .708L1.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm6.292 0a.5.5 0 0 0 0 .708L14.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1015 B

View File

@@ -0,0 +1,31 @@
// 공지 페이지 — 항목별 인라인 수정 폼 토글. (삭제 확인은 comments.js 의 [data-confirm] 핸들러가 처리)
// 문서 위임이라 공지 페이지가 아닌 곳에서 로드돼도 해당 요소가 없으면 무동작 → 전 페이지 안전.
import { $ } from "./util.js";
export function initAnnouncements() {
document.addEventListener("click", (e) => {
// 수정 열기: 보기 숨기고 수정 폼 표시.
const eb = e.target.closest("[data-ann-edit]");
if (eb) {
const id = eb.getAttribute("data-ann-edit");
const view = $('[data-ann-view="' + id + '"]');
const form = $('[data-ann-editform="' + id + '"]');
if (view) view.hidden = true;
if (form) {
form.hidden = false;
const t = $(".ann-input-title", form);
if (t) t.focus();
}
return;
}
// 수정 취소: 폼 숨기고 보기 복원.
const cb = e.target.closest("[data-ann-cancel]");
if (cb) {
const id = cb.getAttribute("data-ann-cancel");
const view = $('[data-ann-view="' + id + '"]');
const form = $('[data-ann-editform="' + id + '"]');
if (form) form.hidden = true;
if (view) view.hidden = false;
}
});
}

43
public/js/comments.js Normal file
View File

@@ -0,0 +1,43 @@
// 댓글 — 답글 타깃 설정 · Enter 등록 · 삭제 확인. (모달/단독 페이지 공용)
import { $ } from "./util.js";
export function initComments() {
document.addEventListener("click", (e) => {
const rb = e.target.closest("[data-reply-to]");
if (rb) {
e.preventDefault();
const scope = rb.closest(".modal") || document;
const parentInput = $('input[name="parent_id"]', scope);
const banner = $(".reply-banner", scope);
const ta = $(".comment-input", scope);
if (parentInput) parentInput.value = rb.getAttribute("data-reply-to");
if (banner) {
banner.hidden = false;
const 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();
const scope = e.target.closest(".modal") || document;
const pi = $('input[name="parent_id"]', scope); if (pi) pi.value = "";
const bn = $(".reply-banner", scope); if (bn) bn.hidden = true;
const ta = $(".comment-input", scope); if (ta) ta.placeholder = "댓글을 입력하세요…";
return;
}
const del = e.target.closest("[data-confirm]");
if (del && !window.confirm(del.getAttribute("data-confirm"))) e.preventDefault();
});
// Enter 등록 / Shift+Enter 줄바꿈
document.addEventListener("keydown", (e) => {
const ta = e.target;
if (ta && ta.classList && ta.classList.contains("comment-input") && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const form = ta.closest("form");
if (form && ta.value.trim()) form.submit();
}
});
}

65
public/js/feed.js Normal file
View File

@@ -0,0 +1,65 @@
// 피드 검색·정렬·즐겨찾기 필터 + 내 프로젝트 필터 (모두 클라이언트 처리).
import { $, $$ } from "./util.js";
function applyFeed() {
// 검색 대상 = 현재 페이지의 검색 가능한 카드 전체(대시보드=공유 프로젝트, 프로필=그 사람 프로젝트).
const cards = $$(".fcard[data-search]");
if (!cards.length) return;
const q = (($("#feedSearch") && $("#feedSearch").value) || "").trim().toLowerCase();
const starOnly = $("#feedStarFilter") && $("#feedStarFilter").classList.contains("on");
let visible = 0;
cards.forEach((c) => {
const hit = !q || (c.getAttribute("data-search") || "").indexOf(q) >= 0;
const st = !starOnly || c.getAttribute("data-starred") === "true";
const show = hit && st;
c.style.display = show ? "" : "none";
if (show) visible++;
});
const empty = $("#feedEmpty");
if (empty) {
empty.hidden = visible !== 0;
empty.textContent = starOnly && visible === 0
? "아직 즐겨찾기한 프로젝트가 없어요 · 카드의 ⭐ 버튼으로 추가하세요"
: "“" + (($("#feedSearch") && $("#feedSearch").value) || "") + "” 와 일치하는 프로젝트가 없습니다.";
}
}
function sortFeed(mode) {
const grid = $("#feedGrid"); if (!grid) return;
const cards = $$(".fcard", grid);
cards.sort((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((c) => grid.appendChild(c));
}
export function initFeed() {
const search = $("#feedSearch");
if (search) search.addEventListener("input", applyFeed);
const sl = $("#sortLatest"), sp = $("#sortPopular");
if (sl) sl.addEventListener("click", () => { sl.classList.add("on"); if (sp) sp.classList.remove("on"); sortFeed("latest"); });
if (sp) sp.addEventListener("click", () => { sp.classList.add("on"); if (sl) sl.classList.remove("on"); sortFeed("popular"); });
const sf = $("#feedStarFilter");
if (sf) sf.addEventListener("click", () => { sf.classList.toggle("on"); applyFeed(); });
}
export function initMyFilter() {
const tabs = $$("[data-myfilter]");
if (!tabs.length) return;
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const f = tab.getAttribute("data-myfilter"); // all | public | private
tabs.forEach((t) => t.classList.toggle("on", t === tab));
$$("#myProjGrid .pcard").forEach((c) => {
const vis = c.getAttribute("data-vis");
c.style.display = (f === "all" || vis === f) ? "" : "none";
});
const add = $("#addCard");
if (add) add.style.display = f === "all" ? "" : "none";
});
});
}

62
public/js/main.js Normal file
View File

@@ -0,0 +1,62 @@
// 엔트리 — 각 모듈 초기화 + 서버 시작 신호(토스트/이스터에그/모달 열기) 처리.
// _header/dashboard/project 에서 <script type="module" src="/public/js/main.js"> 로 로드.
import { EGGS_ON, toast, burstThumbs, burstRocket } from "./util.js";
import { initTheme, initUserMenu, initLogoEgg } from "./ui.js";
import { initReactions } from "./reactions.js";
import { initModals, openDetail } from "./modals.js";
import { initComments } from "./comments.js";
import { initFeed, initMyFilter } from "./feed.js";
import { initNotifications } from "./notifications.js";
import { initAnnouncements } from "./announcements.js";
// 서버가 <body data-*> 로 전달한 1회성 신호 처리
function initSignals() {
const b = document.body;
if (b.getAttribute("data-toast")) toast(b.getAttribute("data-toast"));
const egg = b.getAttribute("data-egg");
if (egg === "lgtm") { burstThumbs(); toast("LGTM 👍 — Looks Good To Me!"); }
else if (egg === "rocket") burstRocket();
const open = b.getAttribute("data-open-modal");
if (open) openDetail(open);
stripSignalParams();
}
// 1회성 신호 쿼리(toast/egg/open)는 표시 후 주소창에서 지운다.
// history.replaceState 라 새 히스토리 항목 없이 URL 만 정리 → 새로고침/공유 시 토스트가 다시 뜨지 않는다.
function stripSignalParams() {
try {
const url = new URL(window.location.href);
const before = url.search;
["toast", "egg", "open"].forEach((k) => url.searchParams.delete(k));
if (url.search !== before) history.replaceState(null, "", url.pathname + url.search + url.hash);
} catch (e) {}
}
function consoleArt() {
if (!EGGS_ON) 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이슈는 https://gitea.bokdev.in/playground/ai-dev-portal/issues에 제보해주세요! 언제든 환영합니다.", "color:#656d76;font-size:12px");
console.log("%c댓글에 LGTM을 쳐보세요.", "color:#8b949e;font-size:11px");
} catch (e) {}
}
function boot() {
initTheme();
initUserMenu();
initLogoEgg();
initReactions();
initModals();
initComments();
initFeed();
initMyFilter();
initNotifications();
initAnnouncements();
initSignals();
consoleArt();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else boot();

163
public/js/modals.js Normal file
View File

@@ -0,0 +1,163 @@
// 상세 모달 · 공유(등록) 모달 — 열기/닫기 및 공개범위 토글.
import { $, $$, toast } from "./util.js";
import { applyReactions } from "./reactions.js";
// 저장소 URL 입력란의 빨간 안내(README 가져오기 전제)를 지운다.
function clearRepoErr(m) {
const err = $("#repoErr", m);
const repoInput = $('input[name="repo_url"]', m);
if (err) err.hidden = true;
if (repoInput) repoInput.classList.remove("err");
}
// 등록 모달의 "저장소 README 가져오기" — repo_url 의 README 를 받아 설명란을 채운다.
async function importReadme() {
const m = $("#shareModal"); if (!m) return;
const btn = $("#readmeImport", m);
const repoInput = $('input[name="repo_url"]', m);
const err = $("#repoErr", m);
const ta = $('textarea[name="description"]', m);
const repo = ((repoInput && repoInput.value) || "").trim();
// URL 미입력 → 입력란 옆에 빨간 안내를 띄우고 포커스만 준다(토스트 대신 인라인).
if (!repo) { if (err) err.hidden = false; if (repoInput) { repoInput.classList.add("err"); repoInput.focus(); } return; }
clearRepoErr(m);
if (ta && ta.value.trim() && !window.confirm("현재 설명 내용을 README 로 덮어쓸까요?")) return;
const label = btn ? btn.innerHTML : "";
if (btn) { btn.disabled = true; btn.textContent = "가져오는 중…"; }
try {
const res = await fetch("/api/readme?repo_url=" + encodeURIComponent(repo), { headers: { "X-Requested-With": "fetch" } });
const data = res.ok ? await res.json() : null;
if (data && data.content && ta) { ta.value = data.content; toast("README 를 가져왔어요"); ta.focus(); }
else toast("README 를 찾지 못했어요 · URL 을 확인해 주세요");
} catch (e) {
toast("가져오기에 실패했어요");
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = label; }
}
}
export function openDetail(id) {
const tpl = $('#detailStore template[data-detail-id="' + id + '"]');
const overlay = $("#detailModal");
if (!tpl || !overlay) { window.location.href = "/projects/" + id; return; }
const mount = $(".modal", overlay) || overlay;
mount.innerHTML = tpl.innerHTML;
// 템플릿은 로드 시점 스냅샷이라 그동안 바뀐 반응 수가 반영돼 있지 않다.
// 마운트 직후 현재 store 값으로 덮어써 카드와 항상 같은 값을 보이게 한다.
applyReactions(mount);
overlay.hidden = false;
document.body.style.overflow = "hidden";
// 주의: 열기 감지 셀렉터가 [data-open-id] 라서 같은 이름을 쓰면 모달 내부 클릭(닫기/삭제)이
// 다시 "열기"로 잡힌다. 마커는 반드시 다른 속성명(data-open-pid)으로 둔다.
overlay.setAttribute("data-open-pid", id);
// 스크롤 컨테이너(overflow-y:auto 인 .modal-overlay)를 맨 위로 되돌린다.
overlay.scrollTop = 0;
// 주의: 그냥 focus() 하면 브라우저가 하단 댓글창을 보이게 하려고 모달을 맨 아래로
// 스크롤한다 → README 가 길면 상세가 하단에 고정돼 열린다. preventScroll 로 막는다.
const ta = $(".comment-input", overlay);
if (ta) ta.focus({ preventScroll: true });
}
function closeDetail() {
const overlay = $("#detailModal");
if (!overlay || overlay.hidden) return;
overlay.hidden = true;
overlay.removeAttribute("data-open-pid");
document.body.style.overflow = "";
}
// 폼 모달의 공개범위 토글을 지정 값으로 맞춘다(등록/수정 공용).
function setVis(m, isPublic) {
const input = $('input[name="is_public"]', m);
if (input) input.value = isPublic ? "1" : "0";
$$("[data-vis]", m).forEach((b) => {
const pub = b.getAttribute("data-vis") === "public";
b.classList.toggle("on-public", pub && isPublic);
b.classList.toggle("on-private", !pub && !isPublic);
});
const hint = $(".share-vishint", m);
if (hint) hint.textContent = isPublic ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능";
}
function fieldSet(m, name, value) {
const el = $('[name="' + name + '"]', m);
if (el) el.value = value == null ? "" : value;
}
// 등록 모드로 초기화해서 연다.
function openShare() {
const m = $("#shareModal"); if (!m) return;
const form = $("form", m); if (form) form.setAttribute("action", "/projects");
const h = $("#shareTitle", m); if (h) h.textContent = "새 프로젝트 등록";
const sub = $("#shareSubmit", m); if (sub) sub.textContent = "등록";
["title", "description", "tags", "repo_url", "app_url", "lang", "return_to"].forEach((n) => fieldSet(m, n, ""));
clearRepoErr(m);
setVis(m, true);
m.hidden = false; document.body.style.overflow = "hidden";
const t = $('input[name="title"]', m); if (t) t.focus();
}
// 수정 모드: 상세의 '수정' 버튼 data-* 로 폼을 채우고 연다.
function openEdit(btn) {
const m = $("#shareModal"); if (!m || !btn) return;
const form = $("form", m);
if (form) form.setAttribute("action", "/projects/" + btn.getAttribute("data-id") + "/edit");
const h = $("#shareTitle", m); if (h) h.textContent = "프로젝트 수정";
const sub = $("#shareSubmit", m); if (sub) sub.textContent = "저장";
fieldSet(m, "title", btn.getAttribute("data-title"));
fieldSet(m, "description", btn.getAttribute("data-desc"));
fieldSet(m, "tags", btn.getAttribute("data-tags"));
fieldSet(m, "repo_url", btn.getAttribute("data-repo"));
fieldSet(m, "app_url", btn.getAttribute("data-app"));
fieldSet(m, "lang", btn.getAttribute("data-lang"));
fieldSet(m, "return_to", btn.getAttribute("data-return"));
setVis(m, btn.getAttribute("data-public") === "1");
clearRepoErr(m);
m.hidden = false; document.body.style.overflow = "hidden";
const t = $('input[name="title"]', m); if (t) { t.focus(); t.select(); }
}
function closeShare() {
const m = $("#shareModal"); if (!m || m.hidden) return;
m.hidden = true; document.body.style.overflow = "";
}
export function initModals() {
document.addEventListener("click", (e) => {
// 상세 모달 열기 (카드 클릭 — 반응 버튼/링크/data-stop 은 제외)
const 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; }
if (e.target.closest("[data-open-share]")) { e.preventDefault(); openShare(); return; }
const editBtn = e.target.closest("[data-open-edit]");
if (editBtn) { e.preventDefault(); openEdit(editBtn); return; }
if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; }
if (e.target.closest("#readmeImport")) { e.preventDefault(); importReadme(); return; }
const detail = $("#detailModal");
if (detail && !detail.hidden && e.target === detail) { closeDetail(); return; }
const share = $("#shareModal");
if (share && !share.hidden && e.target === share) { closeShare(); return; }
// 공유 모달 공개/비공개 토글
const vo = e.target.closest("[data-vis]");
if (vo && share) {
const val = vo.getAttribute("data-vis");
const input = $('input[name="is_public"]', share);
if (input) input.value = val === "public" ? "1" : "0";
$$("[data-vis]", share).forEach((b) => {
const pub = b.getAttribute("data-vis") === "public";
b.classList.toggle("on-public", pub && val === "public");
b.classList.toggle("on-private", !pub && val === "private");
});
const hint = $(".share-vishint", share);
if (hint) hint.textContent = val === "public" ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능";
}
});
// 저장소 URL 을 입력하기 시작하면 빨간 안내를 즉시 지운다.
document.addEventListener("input", (e) => {
if (e.target && e.target.name === "repo_url") { const m = $("#shareModal"); if (m) clearRepoErr(m); }
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeShare(); } });
}

150
public/js/notifications.js Normal file
View File

@@ -0,0 +1,150 @@
// 알림(🔔) 종 — 드롭다운 열기/닫기 · 목록 fetch · 안 읽은 배지 · 60초 폴링.
// 서버 API: GET /api/notifications → { unread, items }, POST /api/notifications/read-all.
import { $, toast } from "./util.js";
const POLL_MS = 60000; // 배지 폴링 주기(60초)
// 종류별 아이콘(가벼운 이모지) · 알림 문구 · 이동 링크.
const ICONS = { heart: "❤️", star: "⭐", comment: "💬", announcement: "📢" };
function itemText(n) {
const who = n.actor_name || "누군가";
const proj = n.project_title || "프로젝트";
if (n.kind === "heart") return who + '님이 «' + proj + '»에 좋아요를 남겼어요';
if (n.kind === "star") return who + '님이 «' + proj + '»을(를) 즐겨찾기했어요';
if (n.kind === "comment") return who + '님이 «' + proj + '»에 댓글을 남겼어요';
if (n.kind === "announcement") return "새 공지: " + (n.announcement_title || "");
return "새 알림";
}
function itemHref(n) {
if (n.kind === "announcement") return "/announcements";
if (n.project_id) return "/projects/" + n.project_id;
return "#";
}
// 상대 시간(방금/N분 전/N시간 전/N일 전/날짜).
function relTime(iso) {
const t = new Date(iso).getTime();
if (!t) return "";
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return "방금";
if (s < 3600) return Math.floor(s / 60) + "분 전";
if (s < 86400) return Math.floor(s / 3600) + "시간 전";
if (s < 7 * 86400) return Math.floor(s / 86400) + "일 전";
const d = new Date(t);
return d.getFullYear() + "." + String(d.getMonth() + 1).padStart(2, "0") + "." + String(d.getDate()).padStart(2, "0");
}
// 목록 렌더 — textContent 로만 채워 XSS 방지.
function renderList(listEl, items) {
listEl.textContent = "";
if (!items || !items.length) {
const empty = document.createElement("div");
empty.className = "notif-empty";
empty.textContent = "아직 알림이 없어요";
listEl.appendChild(empty);
return;
}
items.forEach((n) => {
const a = document.createElement("a");
a.className = "notif-item" + (n.is_read ? "" : " unread");
a.href = itemHref(n);
const ico = document.createElement("span");
ico.className = "notif-item-ico";
ico.textContent = ICONS[n.kind] || "🔔";
const body = document.createElement("span");
body.className = "notif-item-body";
const text = document.createElement("span");
text.className = "notif-item-text";
text.textContent = itemText(n);
const time = document.createElement("span");
time.className = "notif-item-time";
time.textContent = relTime(n.created_at);
body.appendChild(text);
body.appendChild(time);
a.appendChild(ico);
a.appendChild(body);
listEl.appendChild(a);
});
}
function setBadge(badgeEl, n) {
const count = +n || 0;
if (count > 0) {
badgeEl.textContent = count > 99 ? "99+" : String(count);
badgeEl.hidden = false;
} else {
badgeEl.hidden = true;
}
}
function apiGet() {
return fetch("/api/notifications", { headers: { "X-Requested-With": "fetch", "Accept": "application/json" } })
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)));
}
function apiReadAll() {
return fetch("/api/notifications/read-all", {
method: "POST",
headers: { "X-Requested-With": "fetch", "Accept": "application/json" },
}).then((r) => (r.ok ? r.json() : Promise.reject(r.status)));
}
export function initNotifications() {
const btn = $("#notifBtn"), pop = $("#notifPop"), badge = $("#notifBadge"),
list = $("#notifList"), readAll = $("#notifReadAll");
if (!btn || !pop || !badge || !list) return; // 비로그인 등 종이 없으면 무동작
// 드롭다운 열 때: 목록을 받아 렌더한 뒤 모두 읽음 처리 → 배지 초기화.
function openPanel() {
apiGet()
.then((data) => {
renderList(list, data.items);
setBadge(badge, data.unread);
if (data.unread > 0) apiReadAll().then(() => setBadge(badge, 0)).catch(() => {});
})
.catch(() => {
list.textContent = "";
const err = document.createElement("div");
err.className = "notif-empty";
err.textContent = "알림을 불러오지 못했어요";
list.appendChild(err);
});
}
btn.addEventListener("click", (e) => {
e.stopPropagation();
const willOpen = pop.hidden;
pop.hidden = !willOpen;
btn.setAttribute("aria-expanded", willOpen ? "true" : "false");
if (willOpen) openPanel();
});
// 바깥 클릭 시 닫기(사용자 메뉴와 동일 패턴).
document.addEventListener("click", (e) => {
if (!pop.hidden && !pop.contains(e.target) && !btn.contains(e.target)) {
pop.hidden = true;
btn.setAttribute("aria-expanded", "false");
}
});
if (readAll) {
readAll.addEventListener("click", (e) => {
e.stopPropagation();
apiReadAll()
.then(() => {
setBadge(badge, 0);
list.querySelectorAll(".notif-item.unread").forEach((el) => el.classList.remove("unread"));
})
.catch(() => toast("처리에 실패했어요. 다시 시도해 주세요."));
});
}
// 최초 1회 + 주기적 폴링: 배지 숫자만 갱신(드롭다운이 닫혀 있을 때).
function refreshBadge() {
apiGet().then((data) => { if (pop.hidden) setBadge(badge, data.unread); }).catch(() => {});
}
refreshBadge();
setInterval(refreshBadge, POLL_MS);
}

192
public/js/profile.js Normal file
View File

@@ -0,0 +1,192 @@
// 프로필 사진 업로드 — 파일 선택 → 정사각 크롭(드래그 이동 + 확대) → 256px webp 로 서버 전송.
// 서버 이미지 라이브러리 없이 브라우저 canvas 에서 자르고 축소한다.
import { $, toast } from "./util.js";
const OUT = 256; // 저장 이미지 한 변(px)
// 한줄 소개 편집 — 텍스트/편집버튼을 폼으로 토글, 저장은 fetch 로 즉시 반영.
function initBio() {
const row = $("#bioRow");
const form = $("#bioForm");
const text = $("#bioText");
const editBtn = $("#bioEditBtn");
const input = $("#bioInput");
const cancel = $("#bioCancel");
if (!row || !form || !text || !editBtn || !input) return; // 본인 프로필에서만 동작
function show(editing) {
row.hidden = editing;
form.hidden = !editing;
if (editing) { input.focus(); input.select(); }
}
editBtn.addEventListener("click", () => show(true));
if (cancel) cancel.addEventListener("click", () => { input.value = text.classList.contains("is-empty") ? "" : text.textContent; show(false); });
input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); cancel.click(); } });
form.addEventListener("submit", async (e) => {
e.preventDefault();
const bio = input.value.trim().slice(0, 80);
const saveBtn = $("#bioSave");
if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch("/profile/bio", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" },
body: JSON.stringify({ bio }),
});
if (!res.ok) throw new Error("save failed");
text.textContent = bio || "한줄 소개를 남겨보세요";
text.classList.toggle("is-empty", !bio);
show(false);
toast("한줄 소개가 저장됐어요");
} catch (err) {
toast("저장에 실패했어요");
} finally {
if (saveBtn) saveBtn.disabled = false;
}
});
}
export function initProfile() {
initBio();
const editBtn = $("#avatarEditBtn");
const modal = $("#cropModal");
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음
const stage = $("#cropStage");
const img = $("#cropImg");
const zoom = $("#cropZoom");
const fileInput = $("#avatarFile");
const saveBtn = $("#cropSave");
const pickBtn = $("#cropPick");
const closeBtn = $("#cropClose");
const avatarEls = document.querySelectorAll('[data-avatar], #profileAvatar');
// 크롭 상태: 스테이지(정사각)를 뷰포트로 보고, 그 안에 이미지가 항상 뷰포트를 덮도록 배치.
const st = { nw: 0, nh: 0, base: 1, zoom: 1, tx: 0, ty: 0, S: 0 };
function stageSize() { return stage.clientWidth; } // 정사각(= clientHeight)
function scale() { return st.base * st.zoom; }
// 이미지가 뷰포트를 항상 덮도록 tx,ty 를 제한.
function clamp() {
const dispW = st.nw * scale();
const dispH = st.nh * scale();
st.tx = Math.min(0, Math.max(st.S - dispW, st.tx));
st.ty = Math.min(0, Math.max(st.S - dispH, st.ty));
}
function render() {
const dispW = st.nw * scale();
const dispH = st.nh * scale();
img.style.width = dispW + "px";
img.style.height = dispH + "px";
img.style.transform = `translate(${st.tx}px, ${st.ty}px)`;
}
function loadImage(src) {
img.onload = () => {
st.nw = img.naturalWidth;
st.nh = img.naturalHeight;
st.S = stageSize();
// cover: 짧은 변이 스테이지를 채우도록.
st.base = st.S / Math.min(st.nw, st.nh);
st.zoom = 1;
if (zoom) zoom.value = "1";
// 가운데 정렬
st.tx = (st.S - st.nw * scale()) / 2;
st.ty = (st.S - st.nh * scale()) / 2;
clamp();
render();
};
img.src = src;
}
function openModal() { modal.hidden = false; document.body.style.overflow = "hidden"; }
function closeModal() { modal.hidden = true; document.body.style.overflow = ""; }
editBtn.addEventListener("click", () => fileInput.click());
if (pickBtn) pickBtn.addEventListener("click", () => fileInput.click());
if (closeBtn) closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
fileInput.addEventListener("change", () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
if (!/^image\/(png|jpeg|webp)$/.test(f.type)) { toast("PNG·JPG·WEBP 이미지만 올릴 수 있어요"); return; }
const reader = new FileReader();
reader.onload = () => { loadImage(reader.result); openModal(); };
reader.readAsDataURL(f);
});
// 확대 슬라이더 — 스테이지 중심을 고정한 채 확대/축소.
if (zoom) zoom.addEventListener("input", () => {
const cx = st.S / 2, cy = st.S / 2;
const prev = scale();
const ix = (cx - st.tx) / prev; // 중심에 대응하는 이미지 좌표
const iy = (cy - st.ty) / prev;
st.zoom = parseFloat(zoom.value) || 1;
st.tx = cx - ix * scale();
st.ty = cy - iy * scale();
clamp();
render();
});
// 드래그 이동 (포인터 이벤트)
let dragging = false, px = 0, py = 0;
stage.addEventListener("pointerdown", (e) => {
dragging = true; px = e.clientX; py = e.clientY;
stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", (e) => {
if (!dragging) return;
st.tx += e.clientX - px; st.ty += e.clientY - py;
px = e.clientX; py = e.clientY;
clamp(); render();
});
const endDrag = () => { dragging = false; };
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
saveBtn.addEventListener("click", async () => {
if (!st.nw) return;
const sc = scale();
// 스테이지 뷰포트(0..S) 를 이미지 원본 좌표로 역변환.
const sx = (0 - st.tx) / sc;
const sy = (0 - st.ty) / sc;
const sSize = st.S / sc;
const canvas = document.createElement("canvas");
canvas.width = OUT; canvas.height = OUT;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, OUT, OUT);
const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.85));
if (!blob) { toast("이미지 변환에 실패했어요"); return; }
saveBtn.disabled = true; saveBtn.textContent = "저장 중…";
try {
const res = await fetch("/profile/avatar", {
method: "POST",
headers: { "Content-Type": "image/webp", "X-Requested-With": "fetch" },
body: blob,
});
if (!res.ok) throw new Error("upload failed");
// 캐시 무력화를 위해 모든 내 아바타 src 에 타임스탬프 부여.
const bust = "?t=" + Date.now();
avatarEls.forEach((el) => {
const base = (el.getAttribute("src") || "").split("?")[0];
if (base) el.setAttribute("src", base + bust);
});
toast("프로필 사진이 변경됐어요");
closeModal();
} catch (e) {
toast("업로드에 실패했어요");
} finally {
saveBtn.disabled = false; saveBtn.textContent = "저장";
}
});
}
initProfile();

99
public/js/reactions.js Normal file
View File

@@ -0,0 +1,99 @@
// ❤️ 좋아요 / ⭐ 즐겨찾기 — 반응 상태를 한 곳(store)에서 관리한다.
// 같은 프로젝트가 여러 위치(피드 카드 버튼·내 프로젝트 통계 줄·상세 모달)에 렌더되므로,
// 값이 바뀌면 그 프로젝트를 그리는 '모든' DOM 을 자동으로 다시 칠해 항상 같은 값을 유지한다.
// 프레임워크 없이: 프로젝트별 {on,count} 를 Map 에 두고 변경 시 관련 DOM 만 갱신(점진적 향상).
import { $, $$, toast, burstSparkle } from "./util.js";
let starTaps = [];
// key = id + "|" + kind → { id, kind, on:boolean, count:number }
const store = new Map();
const keyOf = (id, kind) => id + "|" + kind;
function getState(id, kind) {
return store.get(keyOf(id, kind)) || { id, kind, on: false, count: 0 };
}
// 한 (프로젝트, 종류)의 현재 상태를 그것을 표시하는 모든 라이브 DOM 에 반영.
// document 조회라 <template> 안(inert)은 대상이 아니다 — 모달은 마운트 시 applyReactions 로 칠한다.
function paint(id, kind) {
const st = getState(id, kind);
// 1) 토글 버튼(피드/열려 있는 모달): on 클래스 + 내부 카운트
$$('[data-react][data-id="' + id + '"][data-kind="' + kind + '"]').forEach((b) => {
b.classList.toggle("on", st.on);
const c = $("[data-rcount]", b);
if (c) c.textContent = st.count;
// 인기순 정렬 기준(data-pop)도 좋아요 수에 맞춰 최신화 → 재정렬 시 순서가 맞는다.
if (kind === "heart") {
const card = b.closest("[data-pop]");
if (card) card.setAttribute("data-pop", st.count);
}
});
// 2) 표시 전용 카운터(내 프로젝트 통계 줄 등): 숫자만 갱신
$$('[data-rcount][data-id="' + id + '"][data-kind="' + kind + '"]').forEach((c) => {
c.textContent = st.count;
});
}
// 상태를 갱신하고 즉시 다시 칠한다. (서버 응답의 확정 값으로 호출)
function setState(id, kind, on, count) {
store.set(keyOf(id, kind), { id, kind, on: !!on, count: Number(count) || 0 });
paint(id, kind);
}
// 로드 시 상세 모달 템플릿에서 모든 프로젝트의 서버 원본값(on/count)을 시드한다.
// 템플릿은 렌더되지 않지만 content 는 조회 가능 → 열람 가능한 전 프로젝트를 빠짐없이 담는다.
function seed() {
$$("#detailStore template").forEach((tpl) => {
$$("[data-react]", tpl.content).forEach((b) => {
const id = b.getAttribute("data-id");
const kind = b.getAttribute("data-kind");
const c = $("[data-rcount]", b);
store.set(keyOf(id, kind), {
id, kind,
on: b.classList.contains("on"),
count: c ? parseInt(c.textContent, 10) || 0 : 0,
});
});
});
// 시드 값으로 라이브 DOM 을 한 번 정렬 — 통계 줄 등 초기 불일치가 있으면 여기서 맞춰진다.
store.forEach((st) => paint(st.id, st.kind));
}
// 새로 마운트한 서브트리(상세 모달)의 반응 표시를 현재 store 값으로 칠한다.
// openDetail 이 템플릿을 복제해 넣은 직후 호출되어, 모달이 항상 최신 값을 보이게 한다.
export function applyReactions(root) {
if (!root) return;
$$("[data-react]", root).forEach((b) => {
const st = getState(b.getAttribute("data-id"), b.getAttribute("data-kind"));
b.classList.toggle("on", st.on);
const c = $("[data-rcount]", b);
if (c) c.textContent = st.count;
});
$$("[data-rcount][data-id][data-kind]", root).forEach((c) => {
c.textContent = getState(c.getAttribute("data-id"), c.getAttribute("data-kind")).count;
});
}
export function initReactions() {
seed();
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-react]");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = btn.getAttribute("data-id");
const kind = btn.getAttribute("data-kind"); // heart | star
if (kind === "star") {
starTaps = starTaps.filter((t) => 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((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((data) => setState(id, kind, data.on, data.count))
.catch(() => toast("처리에 실패했습니다. 다시 시도해 주세요."));
});
}

59
public/js/ui.js Normal file
View File

@@ -0,0 +1,59 @@
// 헤더 UI — 테마 토글 · 사용자 메뉴 · 로고 이스터에그.
import { $, toast, burstThumbs } from "./util.js";
function applyTheme(t) { document.documentElement.setAttribute("data-aidev-theme", t); }
export function initTheme() {
const btn = $("#themeToggle");
if (!btn) return;
btn.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-aidev-theme") === "dark" ? "dark" : "light";
const next = cur === "dark" ? "light" : "dark";
applyTheme(next);
try { localStorage.setItem("aidev-theme", next); } catch (e) {}
});
}
export function initUserMenu() {
const btn = $("#userMenuBtn"), pop = $("#userMenuPop");
if (!btn || !pop) return;
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = pop.hidden;
pop.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", (e) => {
if (!pop.hidden && !pop.contains(e.target) && !btn.contains(e.target)) {
pop.hidden = true; btn.setAttribute("aria-expanded", "false");
}
});
}
// 로고를 4번 빠르게 클릭하면 흔들림 + 👍 (단일 클릭은 잠시 뒤 홈 이동)
export function initLogoEgg() {
const logo = $("#brandLink");
if (!logo) return;
let clicks = 0, timer = null, navTimer = null;
const mark = $(".brand-mark", logo);
logo.addEventListener("click", (e) => {
e.preventDefault();
clicks++;
clearTimeout(timer);
timer = setTimeout(() => { clicks = 0; }, 700);
if (navTimer) clearTimeout(navTimer);
if (clicks >= 4) {
clicks = 0;
if (mark) {
mark.style.animation = "aidev-shake .5s ease";
const old = mark.style.filter;
mark.style.filter = "hue-rotate(180deg) saturate(1.5)";
setTimeout(() => { mark.style.animation = ""; mark.style.filter = old; }, 520);
}
burstThumbs();
toast('🌈 git commit -m "found an easter egg"');
return;
}
navTimer = setTimeout(() => { window.location.href = logo.getAttribute("href") || "/"; }, 320);
});
}

68
public/js/util.js Normal file
View File

@@ -0,0 +1,68 @@
// 공용 DOM 헬퍼 · 토스트 · 이스터에그(이모지 날리기). 다른 모듈이 import 해서 쓴다.
export const $ = (s, r) => (r || document).querySelector(s);
export const $$ = (s, r) => Array.prototype.slice.call((r || document).querySelectorAll(s));
export const EGGS_ON = true;
const 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>';
let toastTimer = null;
export function toast(msg) {
if (!msg) return;
const old = $(".toast"); if (old) old.remove();
const 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(() => { if (t.parentNode) t.remove(); }, 2700);
}
let flyId = 0;
function flyLayer() {
let l = $(".flyers");
if (!l) { l = document.createElement("div"); l.className = "flyers"; document.body.appendChild(l); }
return l;
}
function addFlyer(content, style, ttl) {
const span = document.createElement("span");
span.className = "flyer"; span.textContent = content;
Object.keys(style).forEach((k) => { span.style[k] = style[k]; });
flyLayer().appendChild(span);
span._id = ++flyId;
setTimeout(() => { if (span.parentNode) span.remove(); }, ttl);
}
export function burstThumbs() {
if (!EGGS_ON) return;
const cx = window.innerWidth / 2;
for (let i = 0; i < 6; i++) {
const 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);
}
}
export 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);
}
export function burstSparkle(x, y) {
if (!EGGS_ON) return;
const chars = ["✨", "⭐", "💫"];
for (let i = 0; i < 7; i++) {
const 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);
}
}

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 +0,0 @@
/* GitHub 감성 (Primer 팔레트 근사). 라이트 테마. */
:root {
--canvas: #ffffff;
--canvas-subtle: #f6f8fa;
--border: #d0d7de;
--border-muted: #d8dee4;
--fg: #1f2328;
--fg-muted: #656d76;
--accent: #0969da;
--accent-emphasis: #0969da;
--success: #1a7f37;
--btn-primary: #1f883d;
--btn-primary-hover: #1a7f37;
--header-bg: #24292f;
--star: #9a6700;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", Helvetica, Arial, sans-serif;
color: var(--fg);
background: var(--canvas-subtle);
font-size: 14px;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* 헤더 */
.header {
background: var(--header-bg);
color: #fff;
padding: 0 16px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
}
.header .brand { font-weight: 600; font-size: 16px; color: #fff; display: flex; align-items: center; gap: 8px; }
.header .brand:hover { text-decoration: none; }
.header .spacer { flex: 1; }
.header .who { color: #d1d9e0; font-size: 13px; }
.header a.navlink { color: #fff; font-size: 14px; }
/* 컨테이너 */
.container { max-width: 1012px; margin: 24px auto; padding: 0 16px; }
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; }
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
/* 카드 */
.box {
background: var(--canvas);
border: 1px solid var(--border);
border-radius: 6px;
}
.box-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
background: var(--canvas-subtle);
border-radius: 6px 6px 0 0;
}
.box-body { padding: 16px; }
.box-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border-muted);
display: flex;
align-items: center;
gap: 12px;
}
.box-row:last-child { border-bottom: 0; }
/* 사이트 그리드 */
.site-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
@media (max-width: 540px) { .site-grid { grid-template-columns: 1fr; } }
.site-card {
display: flex; gap: 12px; align-items: flex-start;
border: 1px solid var(--border); border-radius: 6px;
padding: 12px; background: var(--canvas);
}
.site-card:hover { border-color: var(--accent); text-decoration: none; }
.site-card .ico { font-size: 22px; line-height: 1; }
.site-card .name { font-weight: 600; color: var(--fg); }
.site-card .desc { color: var(--fg-muted); font-size: 12px; }
.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 {
width: 28px; height: 28px; border-radius: 50%;
background: var(--accent); color: #fff; display: inline-flex;
align-items: center; justify-content: center; font-size: 12px; font-weight: 600;
}
/* 코멘트 */
.comment { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; }
.comment-head { background: var(--canvas-subtle); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
.comment-body { padding: 12px; white-space: pre-wrap; }
/* 로그인 */
.login-wrap { max-width: 340px; margin: 80px auto; text-align: center; }
.login-card { padding: 32px 24px; }
.login-logo { font-size: 40px; }
.login-title { font-size: 24px; font-weight: 300; margin: 12px 0 24px; }
.alert { background: #ffebe9; border: 1px solid #ff818266; color: #cf222e; padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
.muted { color: var(--fg-muted); font-size: 12px; }

View File

@@ -5,10 +5,18 @@ CREATE TABLE IF NOT EXISTS users (
sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username) sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username)
name text NOT NULL, name text NOT NULL,
email text, email text,
avatar_seed text, -- 아바타 색/이니셜용 avatar_seed text, -- 아바타 색/이니셜용(미업로드 시 identicon 시드)
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
last_login timestamptz last_login timestamptz
); );
-- 업로드한 프로필 사진(1:1 크롭·256px webp 등, 브라우저에서 축소). 없으면 identicon 폴백.
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar bytea;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_mime text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
-- 한줄 소개(프로필에 표시). 본인만 편집.
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text DEFAULT '';
-- 관리자 여부. Keycloak 그룹 기준으로 로그인 시마다 갱신(소스 오브 트루스는 Keycloak).
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유) -- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
@@ -19,25 +27,68 @@ 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),
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project_id, sabun) PRIMARY KEY (project_id, sabun)
); );
-- 공지 (관리자가 작성해 전 직원에게 알림으로 전달. 별도 모아보기 페이지 /announcements)
CREATE TABLE IF NOT EXISTS announcements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_sabun text NOT NULL REFERENCES users(sabun),
title text NOT NULL,
body text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_announcements_created ON announcements(created_at DESC);
-- 알림 (통합 인박스) — 반응(❤️/⭐)·댓글·공지를 한 테이블로.
-- recipient_sabun: 받는 사람 / actor_sabun: 행위자(공지는 작성 관리자, 시스템성이면 NULL 가능)
-- kind: 'heart' | 'star' | 'comment' | 'announcement'
-- project_id / announcement_id: 알림이 가리키는 대상(각각 삭제 시 CASCADE 로 정리)
CREATE TABLE IF NOT EXISTS notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
recipient_sabun text NOT NULL REFERENCES users(sabun),
actor_sabun text REFERENCES users(sabun),
kind text NOT NULL,
project_id bigint REFERENCES projects(id) ON DELETE CASCADE,
announcement_id bigint REFERENCES announcements(id) ON DELETE CASCADE,
is_read boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 종 드롭다운/배지 조회: 받는 사람별 최신순.
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_sabun, created_at DESC);

View File

@@ -42,13 +42,36 @@ export async function handleCallback(req) {
nonce, nonce,
}); });
const claims = tokenSet.claims(); const claims = tokenSet.claims();
// 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
// full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, ""));
// 표시 이름: 한국식 성+이름 순서(예: 홍길동). Keycloak `name` 클레임은 보통 이름+성("길동 홍")이라
// family_name(성)+given_name(이름) 을 공백 없이 조합해 교정한다. 둘 다 없으면 name/사번으로 폴백.
const fam = (claims.family_name || "").trim();
const giv = (claims.given_name || "").trim();
const displayName = fam || giv ? fam + giv : claims.name || claims.preferred_username;
return { return {
sabun: claims.preferred_username, // 사번 sabun: claims.preferred_username, // 사번
name: claims.name || claims.given_name || claims.preferred_username, name: displayName,
email: claims.email, email: claims.email,
groups,
isAdmin: groups.includes(config.adminGroup),
// SSO 로그아웃(end-session) 시 id_token_hint 로 사용
id_token: tokenSet.id_token,
}; };
} }
// SSO 로그아웃 — Keycloak end_session_endpoint 로 보내 로컬뿐 아니라
// Keycloak SSO 세션까지 종료한다. id_token_hint 가 있으면 확인 화면 없이 즉시 로그아웃.
// 종료 후 post_logout_redirect_uri 로 복귀.
export function logoutUrl(idToken) {
if (!client) return `${config.baseUrl}/login`;
return client.endSessionUrl({
id_token_hint: idToken,
post_logout_redirect_uri: `${config.baseUrl}/login`,
});
}
// 로그인 필수 가드 // 로그인 필수 가드
export function requireAuth(req, res, next) { export function requireAuth(req, res, next) {
if (req.session.user) return next(); if (req.session.user) return next();
@@ -59,3 +82,12 @@ export function requireAuth(req, res, next) {
// OIDC 미준비(discovery 실패)면 안내 페이지로 폴백 // OIDC 미준비(discovery 실패)면 안내 페이지로 폴백
return res.redirect("/login?error=" + encodeURIComponent("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.")); return res.redirect("/login?error=" + encodeURIComponent("로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."));
} }
// 관리자 전용 가드. requireAuth 뒤(또는 로그인 상태)에서 관리자만 통과.
// 관리자 판별은 Keycloak 그룹 기준(req.session.user.isAdmin). 공지 작성/수정/삭제 등에 사용.
export function requireAdmin(req, res, next) {
if (req.session.user && req.session.user.isAdmin) return next();
if (!req.session.user) return requireAuth(req, res, next); // 미로그인은 로그인부터
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "forbidden" });
return res.redirect("/?toast=" + encodeURIComponent("관리자만 접근할 수 있습니다."));
}

View File

@@ -13,6 +13,30 @@ export const config = {
// redirect 는 baseUrl + /auth/callback 로 구성 // redirect 는 baseUrl + /auth/callback 로 구성
}, },
// 관리자 판별용 Keycloak 그룹 이름. 이 그룹에 속한 사용자에게만 인프라 도구 노출.
// (groups 클레임은 portal 클라이언트의 group-membership 매퍼가 ID 토큰에 넣어줌)
adminGroup: process.env.ADMIN_GROUP || "DevOps",
// 프로젝트 등록 시 Gitea 언어 통계 API 로 메인 언어 자동 판별용 토큰(선택).
// 공개 레포는 없어도 되고, 비공개 레포 조회에만 필요.
giteaToken: process.env.GITEA_TOKEN || "",
// 도구(사이트) 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",
coolify: process.env.COOLIFY_URL || "https://coolify.bokdev.in/auth/keycloak/redirect",
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.bok.or.kr",
},
// 포털 표시 이름 (변경 가능) // 포털 표시 이름 (변경 가능)
brand: process.env.PORTAL_BRAND || "AI DEV", brand: process.env.PORTAL_BRAND || "AI DEV",
// 포털 사용 매뉴얼 링크(헤더 '매뉴얼' 버튼). 도메인 변경 대비 환경변수로 덮어쓰기 가능.
manualUrl: process.env.MANUAL_URL || "https://gitea.bokdev.in/playground/MANUAL",
}; };

299
src/db.js
View File

@@ -6,39 +6,90 @@ export const pool = new pg.Pool({ connectionString: config.databaseUrl });
export const q = (text, params) => pool.query(text, params); export const q = (text, params) => pool.query(text, params);
// 로그인 시 사용자 upsert // 로그인 시 사용자 upsert. is_admin 은 Keycloak 그룹 판별값을 매 로그인마다 갱신.
export async function upsertUser({ sabun, name, email }) { export async function upsertUser({ sabun, name, email, isAdmin }) {
await q( await q(
`INSERT INTO users (sabun, name, email, avatar_seed, last_login) `INSERT INTO users (sabun, name, email, avatar_seed, is_admin, last_login)
VALUES ($1,$2,$3,$1, now()) VALUES ($1,$2,$3,$1,$4, now())
ON CONFLICT (sabun) DO UPDATE ON CONFLICT (sabun) DO UPDATE
SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`, SET name=EXCLUDED.name, email=EXCLUDED.email, is_admin=EXCLUDED.is_admin, last_login=now()`,
[sabun, name, email || null] [sabun, name, email || null, !!isAdmin]
); );
} }
// 프로젝트 목록 (스타 수 + 코멘트 수 + 소유자 이름 포함) // 프로필 사진 저장(본인). 브라우저에서 1:1 크롭·축소한 작은 이미지 버퍼를 그대로 보관.
export async function listProjects() { export async function setAvatar({ sabun, buf, mime }) {
await q(
`UPDATE users SET avatar=$2, avatar_mime=$3, avatar_updated_at=now() WHERE sabun=$1`,
[sabun, buf, mime]
);
}
// 한줄 소개 저장(본인). 80자 이내로 잘라 보관.
export async function setBio({ sabun, bio }) {
await q(`UPDATE users SET bio=$2 WHERE sabun=$1`, [sabun, String(bio || "").slice(0, 80)]);
}
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
export async function getAvatar(sabun) {
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
return rows[0] || null;
}
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
export async function getUserProfile(sabun) {
const { rows } = await q( const { rows } = await q(
`SELECT p.*, u.name AS owner_name, `SELECT u.sabun, u.name, u.email, u.created_at, u.bio, u.is_admin,
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count, (u.avatar IS NOT NULL) AS has_avatar,
(SELECT count(*) FROM comments c WHERE c.project_id=p.id) AS comment_count (SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
FROM projects p JOIN users u ON u.sabun=p.owner_sabun FROM users u WHERE u.sabun=$1`,
ORDER BY p.created_at DESC` [sabun]
);
return rows[0] || null;
}
// 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
// $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(
`${PROJECT_SELECT}
WHERE p.is_public OR p.owner_sabun=$1
ORDER BY p.created_at DESC`,
[viewer]
); );
return rows; return rows;
} }
export async function getProject(id) { // 특정 사용자(owner)의 프로젝트 — 프로필 페이지용.
// 본인($1===owner)이면 비공개 포함 전체, 타인이면 공개 프로젝트만.
export async function listProjectsByOwner(owner, 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.owner_sabun=$2 AND (p.is_public OR p.owner_sabun=$1)
FROM projects p JOIN users u ON u.sabun=p.owner_sabun WHERE p.id=$1`, ORDER BY p.created_at DESC`,
[id] [viewer, owner]
); );
return rows;
}
// 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
export async function getProject(id, viewer) {
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, 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 +100,212 @@ 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 updateProject({ id, sabun, title, description, repoUrl, appUrl, tags, isPublic, lang }) {
[projectId, author, body]); const { rowCount } = await q(
`UPDATE projects
SET title=$3, description=$4, repo_url=$5, app_url=$6, tags=$7, is_public=$8, lang=$9, updated_at=now()
WHERE id=$1 AND owner_sabun=$2`,
[id, sabun, title, description || "", repoUrl || "", appUrl || "", tags || "", isPublic !== false, lang || ""]
);
return rowCount;
} }
// 본인 프로젝트 삭제. 삭제 행 수 반환(0이면 권한 없음/없음).
export async function deleteProject({ id, sabun }) {
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; // 프로젝트에 반응(❤️/⭐)·댓글이 달리면 프로젝트 주인에게 알림.
// 본인 행위(주인===행위자)엔 알림하지 않는다. 같은 (받는사람,행위자,프로젝트,종류)의
// "안 읽은" 알림이 이미 있으면 skip → 좋아요 껐다 켜기 반복 도배 방지.
export async function notifyProjectOwner({ projectId, actor, kind }) {
const { rows } = await q(`SELECT owner_sabun FROM projects WHERE id=$1`, [projectId]);
const owner = rows[0] && rows[0].owner_sabun;
if (!owner || owner === actor) return;
await q(
`INSERT INTO notifications (recipient_sabun, actor_sabun, kind, project_id)
SELECT $1, $2, $3, $4
WHERE NOT EXISTS (
SELECT 1 FROM notifications
WHERE recipient_sabun=$1 AND actor_sabun=$2 AND kind=$3 AND project_id=$4 AND NOT is_read
)`,
[owner, actor, kind, projectId]
);
}
// 종 드롭다운용 최근 알림 목록. 행위자 이름/프로젝트 제목/공지 제목을 함께 조인.
export async function listNotifications(sabun, limit = 20) {
const { rows } = await q(
`SELECT n.*, a.name AS actor_name, p.title AS project_title, an.title AS announcement_title
FROM notifications n
LEFT JOIN users a ON a.sabun = n.actor_sabun
LEFT JOIN projects p ON p.id = n.project_id
LEFT JOIN announcements an ON an.id = n.announcement_id
WHERE n.recipient_sabun=$1
ORDER BY n.created_at DESC
LIMIT $2`,
[sabun, limit]
);
return rows;
}
// 안 읽은 알림 수(배지).
export async function countUnread(sabun) {
const { rows } = await q(
`SELECT count(*)::int AS c FROM notifications WHERE recipient_sabun=$1 AND NOT is_read`,
[sabun]
);
return rows[0].c;
}
// 안 읽은 알림 모두 읽음 처리. 처리 행 수 반환.
export async function markAllRead(sabun) {
const { rowCount } = await q(
`UPDATE notifications SET is_read=true WHERE recipient_sabun=$1 AND NOT is_read`,
[sabun]
);
return rowCount;
}
// ===================== 공지(📢) =====================
// 공지 목록(최신순). 작성자 이름 조인.
export async function listAnnouncements(limit = 100) {
const { rows } = await q(
`SELECT an.*, u.name AS author_name
FROM announcements an JOIN users u ON u.sabun=an.author_sabun
ORDER BY an.created_at DESC
LIMIT $1`,
[limit]
);
return rows;
}
// 단일 공지.
export async function getAnnouncement(id) {
const { rows } = await q(
`SELECT an.*, u.name AS author_name
FROM announcements an JOIN users u ON u.sabun=an.author_sabun
WHERE an.id=$1`,
[id]
);
return rows[0] || null;
}
// 공지 등록 → 새 id 반환. 이어서 작성자를 제외한 전 직원에게 알림 fan-out.
export async function addAnnouncement({ author, title, body }) {
const { rows } = await q(
`INSERT INTO announcements (author_sabun, title, body) VALUES ($1,$2,$3) RETURNING id`,
[author, title, body || ""]
);
const id = rows[0].id;
await q(
`INSERT INTO notifications (recipient_sabun, actor_sabun, kind, announcement_id)
SELECT sabun, $1, 'announcement', $2 FROM users WHERE sabun <> $1`,
[author, id]
);
return id;
}
// 공지 수정(관리자). 적용 행 수 반환.
export async function updateAnnouncement({ id, title, body }) {
const { rowCount } = await q(
`UPDATE announcements SET title=$2, body=$3, updated_at=now() WHERE id=$1`,
[id, title, body || ""]
);
return rowCount;
}
// 공지 삭제(관리자). 연결된 notifications 는 FK ON DELETE CASCADE 로 함께 삭제. 삭제 행 수 반환.
export async function deleteAnnouncement({ id }) {
const { rowCount } = await q(`DELETE FROM announcements WHERE id=$1`, [id]);
return rowCount;
} }

92
src/gitea.js Normal file
View File

@@ -0,0 +1,92 @@
// Gitea 언어 통계 조회 — 프로젝트의 repo_url 로 메인 언어를 자동 판별.
// 공개 레포는 토큰 없이도 조회되고, 비공개 레포는 GITEA_TOKEN 이 있어야 한다.
// 네트워크 실패/타임아웃/비-Gitea URL 이면 빈 문자열을 반환해 호출부가 폴백하도록 한다.
import { config } from "./config.js";
// repo_url → { base, owner, repo } 파싱. URL 형태가 아니거나 owner/repo 가 없으면 null.
// 예) https://gitea.bokdev.in/hong/my-app(.git) → { base, owner: hong, repo: my-app }
export function parseRepo(repoUrl) {
if (!repoUrl) return null;
let u;
try {
u = new URL(repoUrl);
} catch {
return null;
}
const parts = u.pathname.replace(/\.git$/, "").split("/").filter(Boolean);
if (parts.length < 2) return null;
return { base: `${u.protocol}//${u.host}`, owner: parts[0], repo: parts[1] };
}
// 레포의 메인 언어(바이트 최다) 1개를 반환. 실패/없음이면 "".
export async function fetchMainLang(repoUrl) {
const r = parseRepo(repoUrl);
if (!r) return "";
const url = `${r.base}/api/v1/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/languages`;
const headers = { Accept: "application/json" };
if (config.giteaToken) headers.Authorization = `token ${config.giteaToken}`;
// 등록 응답이 Gitea 응답을 기다리다 멈추지 않도록 3초 타임아웃.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
try {
const res = await fetch(url, { headers, signal: ctrl.signal });
if (!res.ok) return "";
const data = await res.json(); // { "JavaScript": 12345, "CSS": 678, ... }
let top = "";
let max = -1;
for (const [lang, bytes] of Object.entries(data || {})) {
if (typeof bytes === "number" && bytes > max) {
max = bytes;
top = lang;
}
}
return top;
} catch {
return "";
} finally {
clearTimeout(timer);
}
}
// raw README 의 개행을 정리한다. Gitea 화면(렌더링)에는 없지만 raw 원문에 남아 있는
// 군더더기 개행이 설명란에 "없던 엔터"처럼 들어오는 문제를 막는다.
// - CRLF/CR → LF 로 통일(Windows 개행의 \r 제거)
// - 줄 끝 공백 제거
// - 3줄 이상 연속 빈 줄 → 빈 줄 1개(2개 개행)로 축소 (마크다운상 동일)
// - 문서 앞뒤 공백/개행 제거
function normalizeReadme(text) {
return String(text)
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
// 레포의 README 원문(마크다운)을 반환. 실패/없음이면 "".
// Gitea raw 엔드포인트로 기본 브랜치의 README 파일을 후보 순서대로 시도한다.
export async function fetchReadme(repoUrl) {
const r = parseRepo(repoUrl);
if (!r) return "";
const headers = {};
if (config.giteaToken) headers.Authorization = `token ${config.giteaToken}`;
const base = `${r.base}/api/v1/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/raw`;
const candidates = ["README.md", "README.MD", "readme.md", "README", "README.markdown"];
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
try {
for (const name of candidates) {
const res = await fetch(`${base}/${name}`, { headers, signal: ctrl.signal });
if (!res.ok) continue;
const text = normalizeReadme(await res.text());
// 너무 큰 README 는 앞부분만(설명란이 감당할 범위). 20KB 컷.
return text.length > 20000 ? text.slice(0, 20000) : text;
}
return "";
} catch {
return "";
} finally {
clearTimeout(timer);
}
}

212
src/helpers.js Normal file
View File

@@ -0,0 +1,212 @@
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor()/renderMarkdown() 로 사용.
import MarkdownIt from "markdown-it";
// 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"/>',
// </> (꺾쇠 + 슬래시) — 박스 없이 헤더 색 상속. Bootstrap code-slash 패스.
codeslash: '<path d="M10.478 1.647a.5.5 0 1 0-.956-.294l-4 13a.5.5 0 0 0 .956.294l4-13zM4.854 4.146a.5.5 0 0 1 0 .708L1.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm6.292 0a.5.5 0 0 0 0 .708L14.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/>',
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"/>',
book: '<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>',
pencil: '<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>',
// 종(알림) — Octicon bell-16
bell: '<path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/>',
// 확성기(공지) — Octicon megaphone-16
megaphone: '<path d="M3.75 8H2.5A1.5 1.5 0 0 1 1 6.5v-1A1.5 1.5 0 0 1 2.5 4h1.25l6.767-2.71a.75.75 0 0 1 1.03.696v8.028a.75.75 0 0 1-1.03.696L3.75 8Zm-1.25-2.5v1c0 .009.007.016.016.016H3.5V4.484h-.984A.016.016 0 0 0 2.5 4.5Zm2.5.06v.88l5.75 2.302V3.258L5 5.56ZM4.5 9.25a.75.75 0 0 1 .728.568l.542 2.17c.048.19.09.38.09.512 0 .8-.649 1.5-1.5 1.5h-.375A1.5 1.5 0 0 1 3 12.5c0-.283.036-.55.09-.762l.542-2.17a.75.75 0 0 1 .728-.568h.14Z"/>',
};
// 인라인 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>`
);
}
// 관리자 인증 뱃지 — 골드 스캘럽 씰 + 흰 체크(다색이라 icon() 대신 전용 SVG).
// 관리자 판별은 Keycloak 그룹 기준(users.is_admin 에 로그인 시 저장). 프로필 사번 옆에 표시.
function scallopPath(cx, cy, n, rValley, rOuter) {
// 계곡점(rValley)들을 바깥으로 볼록한 원호로 이어 씰 톱니를 만든다.
const half = Math.PI / n;
const sag = rOuter - rValley * Math.cos(half); // 볼록 높이(sagitta)
const c = 2 * rValley * Math.sin(half); // 현 길이
const r = (sag * sag + (c / 2) * (c / 2)) / (2 * sag); // 원호 반지름
let d = "";
for (let i = 0; i < n; i++) {
const a0 = (i / n) * 2 * Math.PI - Math.PI / 2;
const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2;
const x0 = (cx + rValley * Math.cos(a0)).toFixed(2), y0 = (cy + rValley * Math.sin(a0)).toFixed(2);
const x1 = (cx + rValley * Math.cos(a1)).toFixed(2), y1 = (cy + rValley * Math.sin(a1)).toFixed(2);
if (i === 0) d += `M ${x0} ${y0} `;
d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${x1} ${y1} `;
}
return d + "Z";
}
export function adminBadge(opts = {}) {
const size = opts.size || 15;
const title = opts.title || "관리자 인증";
const scallop = scallopPath(8, 8, 11, 5.7, 7.5);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" ` +
`class="admin-badge" role="img" aria-label="${title}" style="display:block"><title>${title}</title>` +
`<defs>` +
`<linearGradient id="aidevAdminGold" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0" stop-color="#fbe58c"/><stop offset="0.45" stop-color="#eabf34"/><stop offset="1" stop-color="#c68f16"/>` +
`</linearGradient>` +
`<radialGradient id="aidevAdminGloss" cx="0.5" cy="0.32" r="0.65">` +
`<stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/><stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>` +
`</radialGradient>` +
`</defs>` +
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGold)"/>` +
`<path d="${scallop}" fill="url(#aidevAdminGold)" stroke="#a9790f" stroke-opacity="0.55" stroke-width="0.5"/>` +
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGloss)"/>` +
`<path d="M4.8 8.25 L6.85 10.25 L11.15 5.55" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>` +
`</svg>`
);
}
// 프로젝트 설명(README 형식) 마크다운 렌더링.
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
// 마크다운 링크는 새 탭으로 열고 noopener 를 붙인다.
const defaultLinkOpen =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet("target", "_blank");
tokens[idx].attrSet("rel", "noopener nofollow");
return defaultLinkOpen(tokens, idx, options, env, self);
};
export function renderMarkdown(src) {
return md.render(String(src || ""));
}
// 카드 미리보기용 — 마크다운 기호를 걷어낸 순수 텍스트. 줄바꿈은 살린다.
// (제목 #, 목록 -, 강조 *_`, 링크 [텍스트](url) → 텍스트, 코드펜스/이미지/배지 제거)
// 개행을 공백으로 뭉개지 않고 유지해, 2줄 클램프 미리보기가 README 앞부분의
// 줄 구조 그대로 보이도록 한다(제목 줄 + 첫 설명 줄 등). 빈 줄은 없애 줄 낭비를 막는다.
export function mdPlain(src) {
return String(src || "")
.replace(/```[\s\S]*?```/g, " ") // 코드펜스 블록 제거
.replace(/`([^`]*)`/g, "$1") // 인라인 코드
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // 이미지
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 링크(배지 포함) → 텍스트
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // 제목 마커
.replace(/^\s{0,3}>\s?/gm, "") // 인용 마커
.replace(/^\s*[-*+]\s+/gm, "") // 목록 마커
.replace(/[*_~]{1,3}/g, "") // 강조 기호
.replace(/[ \t]+/g, " ") // 줄 안의 연속 공백만 축소(개행은 유지)
.replace(/ *\n */g, "\n") // 줄 앞뒤 공백 제거
.replace(/\n{2,}/g, "\n") // 빈 줄 제거 → 내용 줄만 남김
.trim();
}
// 언어 → 색(카드의 언어 점).
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 ""; // 매칭되는 언어 태그가 없으면 언어 미표시
}
// 문자열 → 결정적 해시(아바타/색 시드).
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 대칭 격자)의 rects+color 계산 코어.
function identiconCore(seed) {
const h = hashSeed(seed);
const color = `hsl(${h % 360},${48 + (h % 22)}%,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 cell = 100 / grid; // viewBox 0..100 기준
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)}"/>`;
}
}
}
return { color, rects };
}
// 사번 기반 identicon 아바타(인라인 span). 디자인과 동일한 시각.
export function identicon(seed, size = 28) {
const { color, rects } = identiconCore(seed);
const inner = Math.round(size * 0.74);
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 100 100" fill="${color}" style="display:block">${rects}</svg>`;
return (
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
);
}
// /avatar/:sabun 엔드포인트가 업로드 사진이 없을 때 응답하는 독립 SVG 문서 문자열.
export function identiconSvg(seed, size = 128) {
const { color, rects } = identiconCore(seed);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100" fill="${color}">${rects}</svg>`;
}
// 프로필 사진 <img>. 실제 이미지는 /avatar/:sabun 엔드포인트가 업로드본 또는 identicon SVG 로 응답한다.
// (모든 아바타 렌더 지점을 이 헬퍼로 통일 → 뷰마다 업로드 여부를 조회할 필요가 없다.)
function escapeAttr(s) {
return String(s).replace(/[&"'<>]/g, (c) => ({ "&": "&amp;", '"': "&quot;", "'": "&#39;", "<": "&lt;", ">": "&gt;" }[c]));
}
export function avatar(sabun, size = 28) {
const radius = Math.max(5, Math.round(size * 0.2));
const src = `/avatar/${encodeURIComponent(sabun)}`;
return (
`<img class="avatar" src="${src}" alt="" loading="lazy" width="${size}" height="${size}" ` +
`style="width:${size}px;height:${size}px;border-radius:${radius}px;object-fit:cover;display:block" ` +
`data-avatar="${escapeAttr(sabun)}" />`
);
}

View File

@@ -4,8 +4,10 @@ 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, requireAdmin, oidcReady, logoutUrl } from "./auth.js";
import { icon, identicon, identiconSvg, avatar, adminBadge, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
import { fetchMainLang, fetchReadme } from "./gitea.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 +28,38 @@ app.use(
}) })
); );
// 모든 뷰 공통 노출 // 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
app.use((req, res, next) => { app.use((req, res, next) => {
res.locals.brand = config.brand; res.locals.brand = config.brand;
res.locals.manualUrl = config.manualUrl;
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
res.locals.icon = icon;
res.locals.identicon = identicon;
res.locals.avatar = avatar;
res.locals.adminBadge = adminBadge;
res.locals.langColor = langColor;
res.locals.renderMarkdown = renderMarkdown;
res.locals.mdPlain = mdPlain;
next(); next();
}); });
app.get("/healthz", (_req, res) => res.json({ ok: true })); app.get("/healthz", (_req, res) => res.json({ ok: true }));
// 브라우저가 페이지마다 자동 요청하는 /favicon.ico → svg 파비콘으로 넘긴다(콘솔 404 제거).
app.get("/favicon.ico", (_req, res) => res.redirect(301, "/public/favicon.svg"));
// 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: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
}); });
@@ -49,8 +67,11 @@ app.get("/auth/login", (req, res) => loginRedirect(req, res));
app.get("/auth/callback", async (req, res) => { app.get("/auth/callback", async (req, res) => {
try { try {
const u = await handleCallback(req); const u = await handleCallback(req);
await db.upsertUser(u); // id_token 은 SSO 로그아웃용으로만 세션에 따로 보관(뷰/DB 에 노출 안 함)
req.session.user = u; const { id_token, ...user } = u;
req.session.idToken = id_token;
await db.upsertUser(user);
req.session.user = user; // { sabun, name, email, groups, isAdmin }
res.redirect("/"); res.redirect("/");
} catch (e) { } catch (e) {
console.error("oidc callback error", e); console.error("oidc callback error", e);
@@ -58,52 +79,279 @@ app.get("/auth/callback", async (req, res) => {
} }
}); });
app.get("/logout", (req, res) => { app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/login")); const idToken = req.session.idToken;
// 로컬 세션을 먼저 destroy 한 뒤 Keycloak end-session 으로 보내
// SSO 세션까지 종료한다. (로컬만 지우면 재접속 시 자동 재로그인됨)
req.session.destroy(() => {
if (oidcReady()) return res.redirect(logoutUrl(idToken));
return res.redirect("/login");
});
}); });
// --- 대시보드 (로그인 필요) --- // --- 아바타 이미지 ---
// 업로드한 프로필 사진이 있으면 그 bytes 를, 없으면 사번 기반 identicon SVG 로 응답.
app.get("/avatar/:sabun", requireAuth, async (req, res) => {
const sabun = req.params.sabun;
try {
const row = await db.getAvatar(sabun);
if (row && row.avatar) {
res.set("Content-Type", row.avatar_mime || "image/webp");
res.set("Cache-Control", "private, max-age=300");
return res.send(row.avatar);
}
} catch (e) {
console.error("avatar load error", e);
}
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "private, max-age=300");
res.send(identiconSvg(sabun, 128));
});
// --- 프로필 사진 업로드(본인) ---
// 브라우저에서 1:1 크롭·축소한 작은 이미지를 raw body 로 받는다(멀터/이미지 라이브러리 불필요).
app.post(
"/profile/avatar",
requireAuth,
express.raw({ type: ["image/webp", "image/jpeg", "image/png"], limit: "700kb" }),
async (req, res) => {
const buf = req.body;
const mime = (req.get("Content-Type") || "").split(";")[0].trim();
if (!Buffer.isBuffer(buf) || buf.length === 0) return res.status(400).json({ ok: false, error: "empty" });
if (!["image/webp", "image/jpeg", "image/png"].includes(mime)) return res.status(415).json({ ok: false, error: "type" });
await db.setAvatar({ sabun: req.session.user.sabun, buf, mime });
res.json({ ok: true });
}
);
// --- 한줄 소개 저장(본인) ---
// JS 있으면 fetch(JSON), 없으면 폼 POST → 프로필로 리다이렉트(점진적 향상).
app.post("/profile/bio", requireAuth, async (req, res) => {
const sabun = req.session.user.sabun;
const bio = String(req.body.bio || "").trim().slice(0, 80);
await db.setBio({ sabun, bio });
if (isAjax(req)) return res.json({ ok: true, bio });
res.redirect("/u/" + encodeURIComponent(sabun));
});
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
app.get("/u/:sabun", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const profile = await db.getUserProfile(req.params.sabun);
if (!profile) return res.status(404).send("사용자를 찾을 수 없습니다.");
const projects = await db.listProjectsByOwner(profile.sabun, me);
const searchPlaceholder = profile.sabun === me
? "내 프로젝트 검색…"
: `${profile.name} 님의 프로젝트 검색…`;
res.render("profile", { profile, projects, isSelf: profile.sabun === me, searchPlaceholder });
});
// --- 대시보드 ---
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);
const repoUrl = (repo_url || "").trim();
// 메인 언어: 사용자가 직접 고르면 그 값, '자동 감지'면 Gitea 언어 통계 → 태그 추론 순으로 폴백.
const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr);
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,
appUrl: app_url, appUrl: (app_url || "").trim(),
tags, tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang,
}); });
res.redirect("/"); const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
}); });
app.get("/projects/:id", requireAuth, async (req, res) => { // --- 프로젝트 수정 (본인) --- 등록과 동일한 필드 파싱. 소유자 조건은 db.updateProject 에서 강제.
const project = await db.getProject(req.params.id); app.post("/projects/:id/edit", requireAuth, async (req, res) => {
if (!project) return res.status(404).send("not found"); const { title, description, repo_url, app_url, tags } = req.body;
const comments = await db.listComments(project.id); const back = safeBack(req.body.return_to, "/");
const starred = await db.isStarred({ projectId: project.id, sabun: req.session.user.sabun }); const sep = back.includes("?") ? "&" : "?";
res.render("project", { project, comments, starred }); if (!title || !title.trim()) {
}); return res.redirect(back + sep + "toast=" + encodeURIComponent("제목을 입력해주세요"));
app.post("/projects/:id/comments", requireAuth, async (req, res) => {
if (req.body.body && req.body.body.trim()) {
await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body: req.body.body.trim(),
});
} }
res.redirect(`/projects/${req.params.id}`); const tagArr = String(tags || "")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean)
.slice(0, 5);
const repoUrl = (repo_url || "").trim();
const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr);
const n = await db.updateProject({
id: req.params.id,
sabun: req.session.user.sabun,
title: title.trim(),
description: (description || "").trim(),
repoUrl,
appUrl: (app_url || "").trim(),
tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang,
});
const msg = n ? "수정되었습니다" : "수정 권한이 없습니다";
res.redirect(back + sep + "toast=" + encodeURIComponent(msg));
}); });
app.post("/projects/:id/star", requireAuth, async (req, res) => { // --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
await db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }); app.get("/projects/:id", requireAuth, async (req, res) => {
res.redirect(`/projects/${req.params.id}`); const me = req.session.user.sabun;
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);
// 뒤로가기 대상: ?from= 으로 넘어온 내부 경로(프로필 등)가 있으면 그곳, 없으면 대시보드.
// (뎁스 순서: 프로필 → 프로젝트 상세 로 들어왔으면 프로필로 돌아간다)
const from = req.query.from;
const safeFrom = typeof from === "string" && /^\/[^/]/.test(from) ? from : null; // 내부 절대경로만 허용
const back = safeFrom && safeFrom.startsWith("/u/")
? { href: safeFrom, label: "프로필" }
: { href: "/", label: "대시보드" };
res.render("project", { project, comments, starred: project.starred, back });
});
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
async function reactionHandler(kind, req, res) {
const me = req.session.user.sabun;
const result = await (kind === "heart"
? db.toggleHeart({ projectId: req.params.id, sabun: me })
: db.toggleStar({ projectId: req.params.id, sabun: me }));
// 새로 켠 경우에만 프로젝트 주인에게 알림(본인 프로젝트/중복은 db 에서 걸러짐).
if (result.on) await db.notifyProjectOwner({ projectId: req.params.id, actor: me, kind });
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 ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
// 모달에서 전환하면 return_to(=/?open=<id>) 로 돌아가 모달을 다시 연다. 카드에서는 "/".
const back = safeBack(req.body.return_to, "/");
const sep = back.includes("?") ? "&" : "?";
res.redirect(back + sep + "toast=" + encodeURIComponent(msg));
});
// --- README 가져오기 (등록 모달) — repo_url 의 Gitea README 를 마크다운 원문으로 반환 ---
app.get("/api/readme", requireAuth, async (req, res) => {
const content = await fetchReadme((req.query.repo_url || "").toString().trim());
if (!content) return res.status(404).json({ ok: false });
res.json({ ok: true, content });
});
// --- 프로젝트 삭제 (본인) ---
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) => {
const back = safeBack(req.body.return_to, `/projects/${req.params.id}`);
const body = (req.body.body || "").trim();
if (!body) return res.redirect(back);
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
const created = await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body,
parentId: parentId || null,
});
// 댓글이 실제로 등록됐으면 프로젝트 주인에게 알림(본인 댓글/중복은 db 에서 걸러짐).
if (created) await db.notifyProjectOwner({ projectId: req.params.id, actor: req.session.user.sabun, kind: "comment" });
// 이스터에그 신호
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("/comments/:id/delete", requireAuth, async (req, res) => {
await db.deleteComment({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
});
// --- 알림(🔔) API — 종 드롭다운/폴링 공용 ---
// 최근 알림 목록 + 안 읽은 수. app.js 가 X-Requested-With:fetch 로 호출.
app.get("/api/notifications", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const [items, unread] = await Promise.all([db.listNotifications(me, 20), db.countUnread(me)]);
res.json({ unread, items });
});
// 안 읽은 알림 모두 읽음 처리 → { unread: 0 }
app.post("/api/notifications/read-all", requireAuth, async (req, res) => {
await db.markAllRead(req.session.user.sabun);
res.json({ unread: 0 });
});
// --- 공지(📢) 모아보기 페이지 (전체 열람 가능, 작성 폼은 관리자에게만) ---
app.get("/announcements", requireAuth, async (req, res) => {
const announcements = await db.listAnnouncements();
res.render("announcements", {
announcements,
toast: typeof req.query.toast === "string" ? req.query.toast : "",
});
});
// --- 공지 등록 (관리자) ---
app.post("/announcements", requireAdmin, async (req, res) => {
const title = String(req.body.title || "").trim();
const body = String(req.body.body || "").trim();
if (!title) return res.redirect("/announcements?toast=" + encodeURIComponent("제목을 입력해주세요"));
await db.addAnnouncement({ author: req.session.user.sabun, title, body });
res.redirect("/announcements?toast=" + encodeURIComponent("공지가 등록되었습니다"));
});
// --- 공지 수정 (관리자) ---
app.post("/announcements/:id/update", requireAdmin, async (req, res) => {
const title = String(req.body.title || "").trim();
const body = String(req.body.body || "").trim();
if (!title) return res.redirect("/announcements?toast=" + encodeURIComponent("제목을 입력해주세요"));
const n = await db.updateAnnouncement({ id: req.params.id, title, body });
res.redirect("/announcements?toast=" + encodeURIComponent(n ? "공지가 수정되었습니다" : "공지를 찾을 수 없습니다"));
});
// --- 공지 삭제 (관리자) ---
app.post("/announcements/:id/delete", requireAdmin, async (req, res) => {
const n = await db.deleteAnnouncement({ id: req.params.id });
res.redirect("/announcements?toast=" + encodeURIComponent(n ? "공지가 삭제되었습니다" : "공지를 찾을 수 없습니다"));
}); });
// --- 부팅 --- // --- 부팅 ---

View File

@@ -1,29 +1,35 @@
// 포털에 모아둘 사이트 카탈로그. 카테고리: 개발 핵심 / 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 유지. // - Coolify: /auth/keycloak/redirect (Laravel Socialite, keycloak authorize 로 302)
export const siteGroups = [ // SSO 미지원(Kubero/Harbor 등)은 기본 URL 유지.
{ //
group: "개발 핵심", // adminOnly: true 인 항목은 관리자(role=admin)에게만 렌더(숨김, disabled 아님).
sites: [ // icon: 이모지(폐쇄망에서도 외부 의존 없이 표시). desc: 카드 한 줄 설명. tip: 호버 시 뜨는 상세 설명(title 속성).
{ name: "Coder", url: "https://coder.bokdev.in/api/v2/users/oidc/callback", icon: "💻", desc: "웹 개발 워크스페이스 (VS Code + AI CLI)", sso: true }, import { config } from "./config.js";
{ name: "Gitea", url: "https://gitea.bokdev.in/user/oauth2/keycloak", icon: "🍵", desc: "사내 Git 저장소", sso: true },
{ name: "Kubero", url: "https://kubero.bokdev.in", icon: "🚀", desc: "앱 배포 PaaS (*.apps.bokdev.in)" }, export const sites = [
{ name: "Harbor", url: "https://harbor.bokdev.in", icon: "📦", desc: "컨테이너 이미지 레지스트리" }, // --- 개발 핵심 (모두에게) ---
], { key: "coder", name: "Coder", desc: "클라우드 IDE", tip: "브라우저에서 바로 코딩하는 개발 환경.\n내 PC 설치 없이 VS Code Web으로 개발·실행합니다.", url: config.tools.coder, icon: "💻", sso: true },
}, { key: "gitea", name: "Gitea", desc: "Git 저장소", tip: "소스 코드 저장 및 버전 관리.\n다른 사람과 Git으로 협업할 수 있습니다.", url: config.tools.gitea, icon: "🍵", sso: true },
{ // Kubero: Keycloak SSO 로 로그인 가능(단, 랜딩에서 "OAuth 로 로그인"을 한 번 더 눌러야 함). Kubernetes 기반.
group: "AI · 데이터", { key: "kubero", name: "Kubero", desc: "배포 (PaaS)", tip: "Kubernetes 기반 배포 도구입니다.\nplayground 파이프라인에 앱을 추가해 서버에 올립니다.", url: config.tools.kubero, icon: "🚀", sso: true },
sites: [ // Coolify: Kubero 와 같은 배포 PaaS. Docker 기반. Keycloak SSO 지원 →
{ name: "LiteLLM", url: "https://litellm.bokdev.in", icon: "🤖", desc: "AI 게이트웨이 (모델 키)" }, // /auth/keycloak/redirect (로그인 시작, code 없으면 keycloak authorize 로 302). callback 은 /auth/keycloak/callback.
{ name: "Chat", url: "https://chat.bokdev.in", icon: "💬", desc: "사내 챗 (Ollama)" }, { key: "coolify", name: "Coolify", desc: "배포 (PaaS)", tip: "Docker 기반 배포 도구입니다.\naidev 프로젝트에 앱을 추가해 서버에 올립니다.", url: config.tools.coolify, icon: "", sso: true },
{ name: "OpenEverest", url: "https://openeverest.bokdev.in",icon: "🗄️", desc: "DBaaS (DB 프로비저닝)" }, // --- 인프라/운영 (관리자 전용) ---
{ name: "MinIO", url: "https://minioc.bokdev.in", icon: "🪣", desc: "오브젝트 스토리지 콘솔" }, { key: "harbor", name: "Harbor", desc: "컨테이너 레지스트리", url: config.tools.harbor, icon: "📦", adminOnly: true },
], { key: "openeverest", name: "OpenEverest", desc: "DB 콘솔", url: config.tools.openeverest, icon: "🗄️", adminOnly: true },
}, { key: "minio", name: "MinIO", desc: "오브젝트 스토리지", url: config.tools.minio, icon: "🪣", adminOnly: true },
{ key: "keycloak", name: "Keycloak", desc: "계정 / SSO 관리", url: config.tools.keycloak, icon: "🔑", adminOnly: true },
{ key: "litellm", name: "LiteLLM", desc: "LLM 게이트웨이", url: config.tools.litellm, icon: "🤖", adminOnly: true },
]; ];
// 역할에 맞는 도구만 추려서 반환. 일반 사용자는 adminOnly 카드를 아예 받지 않는다.
export function toolsForUser(isAdmin) {
return sites.filter((s) => isAdmin || !s.adminOnly);
}

12
views/_footer.ejs Normal file
View File

@@ -0,0 +1,12 @@
<%
// 공용 푸터. 대시보드·프로필 등 모든 화면에서 include 해서 재사용.
%>
<footer class="footer">
<div class="footer-inner">
<div class="footer-left">
<span class="footer-brand"><span><%- icon("code", { size: 14 }) %></span><%= brand %></span>
<span class="footer-bug">· 버그 제보 - <a href="https://gitea.bokdev.in/playground/ai-dev-portal/issues" target="_blank" rel="noopener">gitea 이슈</a></span>
</div>
<div class="footer-tech">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
</div>
</footer>

View File

@@ -1,17 +1,99 @@
<!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="icon" href="/public/favicon.svg" type="image/svg+xml" />
<!-- Pretendard 폰트 (CDN · 인터넷 연결 환경) -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="/public/css/tokens.css" />
<link rel="stylesheet" href="/public/css/base.css" />
<link rel="stylesheet" href="/public/css/layout.css" />
<link rel="stylesheet" href="/public/css/components.css" />
<link rel="stylesheet" href="/public/css/modal.css" />
<!-- 반응형(모바일) — 데스크톱 규칙을 덮어써야 하므로 반드시 맨 마지막에 로드. 순서 바꾸지 말 것. -->
<link rel="stylesheet" href="/public/css/responsive.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("codeslash", { 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="<%= typeof searchPlaceholder !== 'undefined' && searchPlaceholder ? searchPlaceholder : '공유 프로젝트 검색…' %>" 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>
<a class="hdr-link" href="/announcements" title="공지사항">
<%- icon("megaphone", { size: 15 }) %><span>공지</span>
</a>
<a class="hdr-link" href="<%= manualUrl %>" target="_blank" rel="noopener" title="포털 사용 매뉴얼">
<%- icon("book", { size: 15 }) %><span>매뉴얼</span>
</a>
<!-- 알림(🔔) 종 — 관리자 뱃지 왼쪽. 목록/배지는 app.js(notifications.js)가 fetch 로 채운다. -->
<div class="notif">
<button class="icon-btn notif-btn" id="notifBtn" type="button" aria-haspopup="true" aria-expanded="false" title="알림" aria-label="알림">
<%- icon("bell", { size: 16 }) %>
<span class="notif-badge" id="notifBadge" hidden></span>
</button>
<div class="notif-pop" id="notifPop" hidden>
<div class="notif-head">
<span class="notif-title">알림</span>
<button type="button" class="notif-readall" id="notifReadAll">모두 읽음</button>
</div>
<div class="notif-list" id="notifList">
<div class="notif-empty">불러오는 중…</div>
</div>
<a class="notif-foot" href="/announcements"><%- icon("megaphone", { size: 14 }) %>공지 전체 보기</a>
</div>
</div>
<!-- 관리자 뱃지 — 프로필 바로 왼쪽 -->
<% 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>
<%- avatar(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="/u/<%= user.sabun %>"><span class="usermenu-ico"><%- icon("repo", { size: 15 }) %></span>내 프로필</a>
<a class="usermenu-item" href="/announcements"><span class="usermenu-ico"><%- icon("megaphone", { size: 15 }) %></span>공지사항</a>
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
</div>
</div>
</div>
<% } %>
</div>
</header>

162
views/_project_detail.ejs Normal file
View File

@@ -0,0 +1,162 @@
<%
// 상세(모달/단독 페이지 공용) — 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>
<% if (p.owner_sabun === me) { %>
<form method="post" action="/projects/<%= p.id %>/visibility" class="vis-badge-form" data-stop>
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="vis-badge vis-badge-btn <%= p.is_public ? "public" : "private" %>" title="클릭하면 <%= p.is_public ? "비공개" : "공개" %>로 전환">
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
</button>
</form>
<% } else { %>
<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">
<a class="meta-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
<%- avatar(p.owner_sabun, 24) %>
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
</a>
<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="<%= typeof back !== 'undefined' && back ? back.href : '/' %>">← <%= typeof back !== 'undefined' && back ? back.label : '대시보드' %></a>
<% } %>
</div>
<% if (pdTags.length) { %>
<div class="modal-tags"><% pdTags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<% if (p.description && p.description.trim()) { %>
<div class="modal-desc md-body"><%- renderMarkdown(p.description) %></div>
<% } else { %>
<p class="modal-desc modal-desc-empty">설명 없음</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) { %>
<div class="modal-actions-owner">
<button type="button" class="comment-link" data-open-edit style="font-size:12.5px"
data-id="<%= p.id %>"
data-title="<%= p.title %>"
data-desc="<%= p.description || '' %>"
data-tags="<%= p.tags || '' %>"
data-repo="<%= p.repo_url || '' %>"
data-app="<%= p.app_url || '' %>"
data-lang="<%= p.lang || '' %>"
data-public="<%= p.is_public ? '1' : '0' %>"
data-return="<%= returnTo %>"><%- icon("pencil", { size: 13 }) %>수정</button>
<form method="post" action="/projects/<%= p.id %>/delete">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</button>
</form>
</div>
<% } %>
</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">
<a class="cavatar" href="/u/<%= c.author_sabun %>" title="<%= c.author_name || c.author_sabun %> 프로필"><%- avatar(c.author_sabun, 28) %></a>
<div class="comment-main">
<div class="comment-head">
<a class="comment-author" href="/u/<%= c.author_sabun %>"><%= c.author_name || c.author_sabun %></a>
<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_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">
<a class="cavatar" href="/u/<%= r.author_sabun %>" title="<%= r.author_name || r.author_sabun %> 프로필"><%- avatar(r.author_sabun, 24) %></a>
<div class="comment-main">
<div class="comment-head">
<a class="comment-author" href="/u/<%= r.author_sabun %>"><%= r.author_name || r.author_sabun %></a>
<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">
<%- avatar(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

@@ -0,0 +1,66 @@
<%
// 프로젝트 등록/수정 공용 모달. 기본은 '등록'(action=/projects)이고,
// 상세의 '수정' 버튼을 누르면 modals.js 가 필드/제목/버튼/action 을 수정 모드로 바꿔 연다.
%>
<div class="modal-overlay" id="shareModal" hidden>
<div class="share-modal">
<div class="share-head">
<h3 id="shareTitle">새 프로젝트 등록</h3>
<button type="button" class="modal-close" data-close-share title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<form method="post" action="/projects">
<input type="hidden" name="return_to" value="" />
<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">Git 저장소 URL <span class="hint">선택 · 먼저 넣으면 README·언어를 자동으로 가져와요</span></span>
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
</label>
<label>
<span class="share-label share-label-row">
<span>설명 <span class="hint">Markdown 지원 · 제목·목록·코드·링크</span></span>
<button type="button" class="readme-btn" id="readmeImport"><%- icon("repo", { size: 12 }) %>저장소 README 가져오기</button>
</span>
<textarea class="share-input share-textarea" name="description" rows="5" placeholder="무엇을 하는 프로젝트인가요?&#10;&#10;## 예시&#10;- Markdown 문법을 그대로 쓸 수 있어요&#10;- 위 저장소 URL 을 넣고 'README 가져오기' 를 누르면 채워져요"></textarea>
</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">메인 언어 <span class="hint">선택 · 기본은 저장소에서 자동 감지</span></span>
<select class="share-input" name="lang">
<option value="">자동 감지</option>
<option value="Python">Python</option>
<option value="JavaScript">JavaScript</option>
<option value="TypeScript">TypeScript</option>
<option value="Go">Go</option>
<option value="Java">Java</option>
<option value="Rust">Rust</option>
<option value="Shell">Shell</option>
</select>
</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" id="shareSubmit">등록</button>
</div>
</form>
</div>
</div>

75
views/announcements.ejs Normal file
View File

@@ -0,0 +1,75 @@
<%- include("_header") %>
<%
// 뷰 로컬 헬퍼 (빌드 없이 EJS 스크립틀릿으로)
function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
function edited(a) { try { return (+new Date(a.updated_at) - +new Date(a.created_at)) > 1000; } catch (e) { return false; } }
%>
<main class="main">
<section class="section">
<div class="section-head">
<div class="section-titlewrap">
<h2 class="section-title">공지사항</h2>
</div>
</div>
<% if (user.isAdmin) { %>
<!-- 관리자 전용 새 공지 작성 폼 -->
<form class="ann-form ann-form-new" method="post" action="/announcements">
<input class="ann-input-title" type="text" name="title" maxlength="120" placeholder="공지 제목" required />
<textarea class="ann-input-body" name="body" rows="4" placeholder="공지 내용 (마크다운 지원)"></textarea>
<div class="ann-form-actions">
<button class="btn btn-primary" type="submit"><%- icon("megaphone", { size: 15 }) %>공지 등록</button>
</div>
</form>
<% } %>
<% if (!announcements.length) { %>
<div class="ann-empty">아직 등록된 공지가 없습니다.</div>
<% } %>
<div class="ann-list">
<% announcements.forEach(function (a) { %>
<article class="ann-item" id="ann-<%= a.id %>">
<div class="ann-view" data-ann-view="<%= a.id %>">
<div class="ann-item-head">
<h3 class="ann-item-title"><%= a.title %></h3>
<% if (user.isAdmin) { %>
<div class="ann-item-actions">
<button type="button" class="btn btn-sm" data-ann-edit="<%= a.id %>"><%- icon("pencil", { size: 13 }) %>수정</button>
<form method="post" action="/announcements/<%= a.id %>/delete" style="display:inline">
<button type="submit" class="btn btn-sm ann-del" data-confirm="이 공지를 삭제할까요?"><%- icon("trash", { size: 13 }) %>삭제</button>
</form>
</div>
<% } %>
</div>
<div class="ann-meta">
<%= a.author_name %> · <%= fdate(a.created_at) %><% if (edited(a)) { %> · 수정됨<% } %>
</div>
<% if (a.body && a.body.trim()) { %>
<div class="ann-body md-body"><%- renderMarkdown(a.body) %></div>
<% } %>
</div>
<% if (user.isAdmin) { %>
<!-- 인라인 수정 폼 (수정 버튼으로 토글) -->
<form class="ann-form ann-edit" method="post" action="/announcements/<%= a.id %>/update" data-ann-editform="<%= a.id %>" hidden>
<input class="ann-input-title" type="text" name="title" maxlength="120" value="<%= a.title %>" required />
<textarea class="ann-input-body" name="body" rows="4"><%= a.body %></textarea>
<div class="ann-form-actions">
<button class="btn btn-primary btn-sm" type="submit"><%- icon("check", { size: 14 }) %>저장</button>
<button class="btn btn-sm" type="button" data-ann-cancel="<%= a.id %>">취소</button>
</div>
</form>
<% } %>
</article>
<% }); %>
</div>
</section>
</main>
<%- include("_footer") %>
<script type="module" src="/public/js/main.js"></script>
</body>
</html>

View File

@@ -1,79 +1,196 @@
<%- 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.tip) { %> title="<%= t.tip %>"<% } %>>
<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"><%= mdPlain(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" data-rcount data-id="<%= p.id %>" data-kind="heart"><%= p.heart_count %></span></span>
<span class="stat"><span class="ico-star"><%- icon("starFill", { size: 13 }) %></span><span class="tabnum" data-rcount data-id="<%= p.id %>" data-kind="star"><%= 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>
</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"><%= mdPlain(p.description) || "설명 없음" %></div>
<% if (tags.length) { %>
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<div class="fcard-foot">
<a class="fcard-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
<%- avatar(p.owner_sabun, 22) %>
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
<span class="date">· <%= fdate(p.created_at) %></span>
</a>
<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>
<%- include("_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>
<!-- 새 프로젝트 등록/수정 공용 모달 -->
<%- include("_project_form_modal") %>
<script type="module" src="/public/js/main.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>

127
views/profile.ejs Normal file
View File

@@ -0,0 +1,127 @@
<%- include("_header") %>
<%
// 뷰 로컬 헬퍼
function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
function searchText(p) { return (p.title + " " + (p.description || "") + " " + (p.tags || "")).toLowerCase(); }
%>
<main class="main">
<!-- 프로필 히어로 -->
<section class="section">
<div class="profile-hero">
<div class="profile-avatar-wrap">
<img class="profile-avatar" id="profileAvatar" src="/avatar/<%= profile.sabun %>" alt="<%= profile.name %>" width="96" height="96" />
<% if (isSelf) { %>
<button type="button" class="profile-avatar-edit" id="avatarEditBtn" title="프로필 사진 변경" aria-label="프로필 사진 변경"><%- icon("plus", { size: 15, fill: "#fff" }) %></button>
<% } %>
</div>
<div class="profile-info">
<div class="profile-idline">
<h1 class="profile-name"><%= profile.name %></h1>
<span class="profile-sabun"><%= profile.sabun %></span>
<% if (profile.is_admin) { const _adminTitle = `${brand} Portal 관리자`; %><span class="admin-badge-wrap" title="<%= _adminTitle %>"><%- adminBadge({ size: 16, title: _adminTitle }) %></span><% } %>
</div>
<div class="profile-metaline">
<span><%= profile.project_count %>개 프로젝트</span>
<span>· 가입 <%= fdate(profile.created_at) %></span>
</div>
<% if (profile.bio || isSelf) { %>
<div class="profile-bio-row" id="bioRow">
<p class="profile-bio<%= profile.bio ? "" : " is-empty" %>" id="bioText"><%= profile.bio || "한줄 소개를 남겨보세요" %></p>
<% if (isSelf) { %>
<button type="button" class="profile-bio-edit" id="bioEditBtn" title="한줄 소개 편집" aria-label="한줄 소개 편집"><%- icon("pencil", { size: 13 }) %></button>
<% } %>
</div>
<% if (isSelf) { %>
<form class="profile-bio-form" id="bioForm" method="post" action="/profile/bio" hidden>
<input type="text" name="bio" id="bioInput" maxlength="80" value="<%= profile.bio || "" %>" placeholder="한줄 소개 (최대 80자)" autocomplete="off" />
<button type="submit" class="profile-bio-edit profile-bio-save" id="bioSave" title="저장" aria-label="한줄 소개 저장"><%- icon("check", { size: 14 }) %></button>
<button type="button" class="profile-bio-edit" id="bioCancel" title="취소" aria-label="한줄 소개 편집 취소"><%- icon("x", { size: 14 }) %></button>
</form>
<% } %>
<% } %>
</div>
</div>
</section>
<!-- 프로젝트 목록 -->
<section class="section">
<div class="section-head">
<div class="section-titlewrap">
<h2 class="section-title"><%= isSelf ? "내 프로젝트" : (profile.name + " 님의 프로젝트") %></h2>
<span class="section-sub"><%= isSelf ? "공개·비공개 모두 표시" : "공개된 프로젝트만 표시" %></span>
</div>
</div>
<% if (!projects.length) { %>
<div class="empty"><%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %></div>
<% } %>
<div class="empty" id="feedEmpty" hidden></div>
<div class="feed-grid">
<% projects.forEach(function (p) { var tags = tagList(p.tags); %>
<a class="fcard" href="/projects/<%= p.id %>?from=/u/<%= encodeURIComponent(profile.sabun) %>" data-search="<%= searchText(p) %>">
<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>
<span class="vis-badge <%= p.is_public ? "public" : "private" %>">
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
</span>
</div>
<div class="fcard-desc"><%= mdPlain(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-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-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>
<span class="date"><%= fdate(p.created_at) %></span>
</div>
</a>
<% }); %>
</div>
</section>
</main>
<%- include("_footer") %>
<% if (isSelf) { %>
<!-- 프로필 사진 크롭 모달 -->
<div class="modal-overlay" id="cropModal" hidden>
<div class="crop-modal">
<div class="crop-head">
<h3>프로필 사진</h3>
<button type="button" class="modal-close" id="cropClose" title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<div class="crop-body">
<div class="crop-stage" id="cropStage">
<img id="cropImg" alt="" draggable="false" />
<div class="crop-veil"></div>
</div>
<label class="crop-zoom">
<span><%- icon("search", { size: 13 }) %></span>
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
</label>
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.<br>변경한 사진이 모든 화면에 반영되기까지 최대 몇 분 걸릴 수 있어요.</p>
</div>
<div class="modal-foot-actions">
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
<button type="button" class="btn" id="cropPick">다른 사진 선택</button>
<button type="button" class="btn btn-success" id="cropSave">저장</button>
</div>
</div>
</div>
<% } %>
<script type="module" src="/public/js/main.js"></script>
<% if (isSelf) { %><script type="module" src="/public/js/profile.js"></script><% } %>
</body>
</html>

View File

@@ -1,46 +1,15 @@
<%- 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, back: back }) %>
<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>
<!-- 프로젝트 수정 모달 (상세의 '수정' 버튼이 채워서 연다) -->
<%- include("_project_form_modal") %>
<script type="module" src="/public/js/main.js"></script>
</body> </body>
</html> </html>