Compare commits
44 Commits
e174fccef2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 355798a506 | |||
| a199114453 | |||
| 2ca23a00b9 | |||
| 5c76786ada | |||
| 2357dbd152 | |||
| d95f968800 | |||
| 75685e41da | |||
| 56430b7701 | |||
| ef3d495878 | |||
| 84ce8567e7 | |||
| 0ed41849ca | |||
| b779ed882a | |||
| ad693304cf | |||
| 26cc086414 | |||
| 44cc2af813 | |||
| 44f7cb4b8b | |||
| 1f5179cd04 | |||
| e21d622ba9 | |||
| 3945992125 | |||
| 0c5f10ae18 | |||
| 5e73bfdd28 | |||
| eb186cbfeb | |||
| 7fe3a8fead | |||
| 459f37608e | |||
| 40f8352a28 | |||
| b512ab2143 | |||
| 6c2bb888b4 | |||
| b4c9b4a395 | |||
| ac545651e6 | |||
| 76c82ed24e | |||
| da1653a8ea | |||
| 4a3e5636c8 | |||
| 761b1a1936 | |||
|
|
3ddf920e56 | ||
|
|
1f76329140 | ||
|
|
6ff8034856 | ||
|
|
12d620449e | ||
| 540a170b0d | |||
| ebf9599199 | |||
|
|
fdfd7dca98 | ||
|
|
096f7fbbcb | ||
| 2b74b59f81 | |||
| bc1327a6dc | |||
| 272b315ab0 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ npm-debug.log*
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
|
_preview.mjs
|
||||||
|
|
||||||
|
# bkit 플러그인 런타임/감사 상태 (앱과 무관)
|
||||||
|
.bkit/
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ OIDC_CLIENT_ID=portal
|
|||||||
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
||||||
|
|
||||||
# --- 권한 ---
|
# --- 권한 ---
|
||||||
# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
# 이 Keycloak 그룹(group)에 속한 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
||||||
ADMIN_ROLE=admin
|
# (portal 클라이언트의 group-membership 매퍼가 ID 토큰에 groups 클레임을 넣어줌)
|
||||||
|
ADMIN_GROUP=DevOps
|
||||||
|
|
||||||
|
# --- Gitea 언어 자동 판별 (선택) ---
|
||||||
|
# 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다.
|
||||||
|
# 공개 레포는 토큰 없이 동작. 비공개 레포까지 판별하려면 read:repository 권한 토큰 필요.
|
||||||
|
# GITEA_TOKEN=
|
||||||
|
|
||||||
# --- 도구 URL (기본값 있음 · 도메인/폐쇄망 대응으로 덮어쓰기) ---
|
# --- 도구 URL (기본값 있음 · 도메인/폐쇄망 대응으로 덮어쓰기) ---
|
||||||
# CODER_URL=https://coder.bokdev.in/api/v2/users/oidc/callback
|
# CODER_URL=https://coder.bokdev.in/api/v2/users/oidc/callback
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -5,7 +5,10 @@ AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이
|
|||||||
|
|
||||||
## 이 저장소를 다룰 때 (먼저 읽기)
|
## 이 저장소를 다룰 때 (먼저 읽기)
|
||||||
- **사용자는 개발 전문가가 아닌 사내 IT 기획자/직원**이다. 변경은 **단순하게** 유지한다.
|
- **사용자는 개발 전문가가 아닌 사내 IT 기획자/직원**이다. 변경은 **단순하게** 유지한다.
|
||||||
- 빌드 단계·프런트엔드 프레임워크(React 등)·번들러 도입 **금지**. 서버 렌더링(EJS) + 단일 CSS 유지.
|
- 무거운 도구는 피한다: **프런트엔드 프레임워크(React 등)·번들러·빌드 단계는 도입하지 않는다.**
|
||||||
|
서버 렌더링(EJS) 유지.
|
||||||
|
- 단, **빌드가 필요 없는 범위의 정리·분할은 허용**한다 — CSS 를 역할별 파일로 나눠 다중 `<link>`,
|
||||||
|
JS 를 브라우저 **네이티브 ESM 모듈**(`<script type="module">` + `import`)로 분할 등.
|
||||||
- 새 npm 의존성은 꼭 필요할 때만, 이유와 함께 추가한다.
|
- 새 npm 의존성은 꼭 필요할 때만, 이유와 함께 추가한다.
|
||||||
- **UI 문구와 코드 주석은 한국어**로 쓴다.
|
- **UI 문구와 코드 주석은 한국어**로 쓴다.
|
||||||
- **비밀값은 코드에 넣지 않는다.** 환경변수(`.project-env` 로컬 / Kubero Env 배포)로만 다룬다.
|
- **비밀값은 코드에 넣지 않는다.** 환경변수(`.project-env` 로컬 / Kubero Env 배포)로만 다룬다.
|
||||||
@@ -42,8 +45,9 @@ AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이
|
|||||||
- `.claude/` — Claude Code 훅·설정
|
- `.claude/` — Claude Code 훅·설정
|
||||||
|
|
||||||
## 인증 모델
|
## 인증 모델
|
||||||
- **사번 = Keycloak `preferred_username`.** 세션에 `req.session.user = { sabun, name, email, roles, isAdmin }`.
|
- **사번 = Keycloak `preferred_username`.** 세션에 `req.session.user = { sabun, name, email, groups, isAdmin }`.
|
||||||
- **관리자 = `realm_access.roles` 에 `ADMIN_ROLE`(기본 `admin`) 포함.** 인프라 도구(OpenEverest/MinIO/
|
- **관리자 = `groups` 클레임에 `ADMIN_GROUP`(기본 `DevOps`) 포함.** (portal 클라이언트의 group-membership
|
||||||
|
매퍼가 ID 토큰에 `groups` 를 넣어줌. `full.path=false` 라 앞 슬래시는 떼고 비교.) 인프라 도구(OpenEverest/MinIO/
|
||||||
Keycloak/LiteLLM)는 관리자에게만 렌더(`sites.js` 의 `adminOnly` + `toolsForUser`).
|
Keycloak/LiteLLM)는 관리자에게만 렌더(`sites.js` 의 `adminOnly` + `toolsForUser`).
|
||||||
- 미인증으로 보호 페이지 진입 시 중간 화면 없이 곧장 Keycloak 로그인으로 리다이렉트
|
- 미인증으로 보호 페이지 진입 시 중간 화면 없이 곧장 Keycloak 로그인으로 리다이렉트
|
||||||
(`requireAuth` → `loginRedirect`). OIDC discovery 실패 시에만 `login.ejs` 폴백.
|
(`requireAuth` → `loginRedirect`). OIDC discovery 실패 시에만 `login.ejs` 폴백.
|
||||||
@@ -56,7 +60,7 @@ AI DEV — 사내 개발 포털. 직원이 한 곳에서 **사내 개발 사이
|
|||||||
- `Dockerfile` 은 Kubero buildpack 의 harbor push 실패를 우회하는 **수동 push 용**.
|
- `Dockerfile` 은 Kubero buildpack 의 harbor push 실패를 우회하는 **수동 push 용**.
|
||||||
- 도메인 `portal.bokdev.in` (`deploy/portal-route.yaml`). 도메인 추가 시 Keycloak `portal`
|
- 도메인 `portal.bokdev.in` (`deploy/portal-route.yaml`). 도메인 추가 시 Keycloak `portal`
|
||||||
client redirect 에 `https://<도메인>/auth/callback` 등록 필요.
|
client redirect 에 `https://<도메인>/auth/callback` 등록 필요.
|
||||||
- Kubero Env: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_ROLE`, (선택) 도구 `*_URL`.
|
- Kubero Env: `BASE_URL`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`, `ADMIN_GROUP`, (선택) 도구 `*_URL`.
|
||||||
|
|
||||||
## 컨벤션
|
## 컨벤션
|
||||||
- 라우트는 `src/server.js` 에, DB 접근은 `src/db.js` 의 함수로만 (인라인 SQL 산재 금지).
|
- 라우트는 `src/server.js` 에, DB 접근은 `src/db.js` 의 함수로만 (인라인 SQL 산재 금지).
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -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`.
|
|
||||||
|
|||||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
385
public/app.js
385
public/app.js
@@ -1,385 +0,0 @@
|
|||||||
/* AI DEV 포털 — 클라이언트 상호작용 (프레임워크 없음, 점진적 향상).
|
|
||||||
* JS 가 없어도 폼 POST 로 동작하고, 있으면 여기서 즉시 반영/모달/이펙트를 더한다.
|
|
||||||
*
|
|
||||||
* DOM 계약 (대시보드 EJS 가 맞춰 렌더):
|
|
||||||
* - 카드: <article class="fcard|pcard" data-open-id="3" data-search="텍스트"
|
|
||||||
* data-created="2026-06-25" data-pop="31" data-starred="true|false"
|
|
||||||
* data-vis="public|private"> ... </article>
|
|
||||||
* - 반응 버튼: <button data-react data-id="3" data-kind="heart|star" class="...on?">
|
|
||||||
* <span class="ico ico-on">…</span><span class="ico ico-off">…</span>
|
|
||||||
* <span class="count" data-rcount>19</span></button>
|
|
||||||
* - 상세 모달 내용 저장소: <div id="detailStore"><template data-detail-id="3">…</template></div>
|
|
||||||
* - 답글 버튼: <button data-reply-to="11" data-reply-name="0298211">
|
|
||||||
* - 토스트/이펙트 신호: body[data-toast], body[data-egg]
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
|
||||||
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
|
|
||||||
var EGGS_ON = true;
|
|
||||||
|
|
||||||
/* ---------------- 테마 ---------------- */
|
|
||||||
function applyTheme(t) { document.documentElement.setAttribute("data-aidev-theme", t); }
|
|
||||||
function initTheme() {
|
|
||||||
var btn = $("#themeToggle");
|
|
||||||
if (!btn) return;
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
var cur = document.documentElement.getAttribute("data-aidev-theme") === "dark" ? "dark" : "light";
|
|
||||||
var next = cur === "dark" ? "light" : "dark";
|
|
||||||
applyTheme(next);
|
|
||||||
try { localStorage.setItem("aidev-theme", next); } catch (e) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 사용자 메뉴 ---------------- */
|
|
||||||
function initUserMenu() {
|
|
||||||
var btn = $("#userMenuBtn"), pop = $("#userMenuPop");
|
|
||||||
if (!btn || !pop) return;
|
|
||||||
btn.addEventListener("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
var open = pop.hidden;
|
|
||||||
pop.hidden = !open;
|
|
||||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
||||||
});
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
if (!pop.hidden && !pop.contains(e.target) && !btn.contains(e.target)) {
|
|
||||||
pop.hidden = true; btn.setAttribute("aria-expanded", "false");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 토스트 ---------------- */
|
|
||||||
var toastTimer = null;
|
|
||||||
function toast(msg) {
|
|
||||||
if (!msg) return;
|
|
||||||
var old = $(".toast"); if (old) old.remove();
|
|
||||||
var t = document.createElement("div");
|
|
||||||
t.className = "toast";
|
|
||||||
t.innerHTML = '<span class="toast-ico">' + CHECK_SVG + "</span>";
|
|
||||||
t.appendChild(document.createTextNode(msg));
|
|
||||||
document.body.appendChild(t);
|
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
|
||||||
toastTimer = setTimeout(function () { if (t.parentNode) t.remove(); }, 2700);
|
|
||||||
}
|
|
||||||
var CHECK_SVG = '<svg width="14" height="14" viewBox="0 0 16 16" fill="#3fb950" aria-hidden="true"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>';
|
|
||||||
|
|
||||||
/* ---------------- 이스터에그 (이모지 날리기) ---------------- */
|
|
||||||
var flyId = 0;
|
|
||||||
function flyLayer() {
|
|
||||||
var l = $(".flyers");
|
|
||||||
if (!l) { l = document.createElement("div"); l.className = "flyers"; document.body.appendChild(l); }
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
function addFlyer(content, style, ttl) {
|
|
||||||
var span = document.createElement("span");
|
|
||||||
span.className = "flyer"; span.textContent = content;
|
|
||||||
Object.keys(style).forEach(function (k) { span.style[k] = style[k]; });
|
|
||||||
var l = flyLayer(); l.appendChild(span);
|
|
||||||
var id = ++flyId; span._id = id;
|
|
||||||
setTimeout(function () { if (span.parentNode) span.remove(); }, ttl);
|
|
||||||
}
|
|
||||||
function burstThumbs() {
|
|
||||||
if (!EGGS_ON) return;
|
|
||||||
var cx = window.innerWidth / 2;
|
|
||||||
for (var i = 0; i < 6; i++) {
|
|
||||||
var dx = (Math.random() - 0.5) * 180;
|
|
||||||
addFlyer("👍", {
|
|
||||||
left: (cx + dx) + "px", top: (window.innerHeight * 0.62) + "px",
|
|
||||||
fontSize: (22 + Math.random() * 14) + "px",
|
|
||||||
animation: "aidev-flyup " + (0.9 + Math.random() * 0.3) + "s ease-out " + (i * 60) + "ms forwards",
|
|
||||||
opacity: 0,
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function burstRocket() {
|
|
||||||
if (!EGGS_ON) return;
|
|
||||||
addFlyer("🚀", {
|
|
||||||
left: "-40px", top: (window.innerHeight * (0.18 + Math.random() * 0.2)) + "px",
|
|
||||||
fontSize: "34px", animation: "aidev-rocket 1.1s ease-in forwards",
|
|
||||||
}, 1300);
|
|
||||||
}
|
|
||||||
function burstSparkle(x, y) {
|
|
||||||
if (!EGGS_ON) return;
|
|
||||||
var chars = ["✨", "⭐", "💫"];
|
|
||||||
for (var i = 0; i < 7; i++) {
|
|
||||||
var dx = (Math.random() - 0.5) * 90, dy = (Math.random() - 0.5) * 90;
|
|
||||||
addFlyer(chars[i % 3], {
|
|
||||||
left: (x + dx) + "px", top: (y + dy) + "px",
|
|
||||||
fontSize: (12 + Math.random() * 12) + "px",
|
|
||||||
animation: "aidev-sparkle " + (0.5 + Math.random() * 0.3) + "s ease-out forwards",
|
|
||||||
}, 900);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로고 이스터에그: 4번 빠르게 클릭하면 흔들림 + 👍 (단일 클릭은 홈 이동) */
|
|
||||||
function initLogoEgg() {
|
|
||||||
var logo = $("#brandLink");
|
|
||||||
if (!logo) return;
|
|
||||||
var clicks = 0, timer = null, navTimer = null;
|
|
||||||
var mark = $(".brand-mark", logo);
|
|
||||||
logo.addEventListener("click", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
clicks++;
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(function () { clicks = 0; }, 700);
|
|
||||||
if (navTimer) clearTimeout(navTimer);
|
|
||||||
if (clicks >= 4) {
|
|
||||||
clicks = 0;
|
|
||||||
if (mark) {
|
|
||||||
mark.style.animation = "aidev-shake .5s ease";
|
|
||||||
var old = mark.style.filter;
|
|
||||||
mark.style.filter = "hue-rotate(180deg) saturate(1.5)";
|
|
||||||
setTimeout(function () { mark.style.animation = ""; mark.style.filter = old; }, 520);
|
|
||||||
}
|
|
||||||
burstThumbs();
|
|
||||||
toast('🌈 git commit -m "found an easter egg"');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 추가 클릭이 없으면 잠시 뒤 홈으로 이동
|
|
||||||
navTimer = setTimeout(function () { window.location.href = logo.getAttribute("href") || "/"; }, 320);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 반응(❤️/⭐) fetch 토글 ---------------- */
|
|
||||||
function initReactions() {
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
var btn = e.target.closest("[data-react]");
|
|
||||||
if (!btn) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
var id = btn.getAttribute("data-id");
|
|
||||||
var kind = btn.getAttribute("data-kind"); // heart | star
|
|
||||||
// 별 빠르게 4탭 → 반짝
|
|
||||||
if (kind === "star") {
|
|
||||||
starTaps = (starTaps || []).filter(function (t) { return Date.now() - t < 1400; });
|
|
||||||
starTaps.push(Date.now());
|
|
||||||
if (starTaps.length >= 4) burstSparkle(e.clientX, e.clientY);
|
|
||||||
}
|
|
||||||
fetch("/projects/" + id + "/" + kind, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "X-Requested-With": "fetch", "Accept": "application/json" },
|
|
||||||
})
|
|
||||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
|
||||||
.then(function (data) { syncReaction(id, kind, data.on, data.count); })
|
|
||||||
.catch(function () { toast("처리에 실패했습니다. 다시 시도해 주세요."); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var starTaps = [];
|
|
||||||
function syncReaction(id, kind, on, count) {
|
|
||||||
$$('[data-react][data-id="' + id + '"][data-kind="' + kind + '"]').forEach(function (b) {
|
|
||||||
b.classList.toggle("on", !!on);
|
|
||||||
var c = $(".count", b);
|
|
||||||
if (c) c.textContent = count;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 상세 모달 ---------------- */
|
|
||||||
function openDetail(id) {
|
|
||||||
var tpl = $('#detailStore template[data-detail-id="' + id + '"]');
|
|
||||||
var overlay = $("#detailModal");
|
|
||||||
if (!tpl || !overlay) { window.location.href = "/projects/" + id; return; }
|
|
||||||
var mount = $(".modal", overlay) || overlay;
|
|
||||||
mount.innerHTML = tpl.innerHTML;
|
|
||||||
overlay.hidden = false;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
overlay.setAttribute("data-open-id", id);
|
|
||||||
var ta = $(".comment-input", overlay);
|
|
||||||
if (ta) ta.focus();
|
|
||||||
}
|
|
||||||
function closeDetail() {
|
|
||||||
var overlay = $("#detailModal");
|
|
||||||
if (!overlay || overlay.hidden) return;
|
|
||||||
overlay.hidden = true;
|
|
||||||
overlay.removeAttribute("data-open-id");
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
function initDetailModal() {
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
var opener = e.target.closest("[data-open-id]");
|
|
||||||
if (opener && !e.target.closest("[data-react]") && !e.target.closest("a") && !e.target.closest("[data-stop]")) {
|
|
||||||
e.preventDefault();
|
|
||||||
openDetail(opener.getAttribute("data-open-id"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.target.closest("[data-close-detail]")) { e.preventDefault(); closeDetail(); return; }
|
|
||||||
var overlay = $("#detailModal");
|
|
||||||
if (overlay && !overlay.hidden && e.target === overlay) closeDetail();
|
|
||||||
});
|
|
||||||
document.addEventListener("keydown", function (e) { if (e.key === "Escape") { closeDetail(); closeShare(); } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 댓글: 답글 타깃 / Enter 등록 / 삭제 확인 ---------------- */
|
|
||||||
function initComments() {
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
var rb = e.target.closest("[data-reply-to]");
|
|
||||||
if (rb) {
|
|
||||||
e.preventDefault();
|
|
||||||
var scope = rb.closest(".modal") || document;
|
|
||||||
var parentInput = $('input[name="parent_id"]', scope);
|
|
||||||
var banner = $(".reply-banner", scope);
|
|
||||||
var ta = $(".comment-input", scope);
|
|
||||||
if (parentInput) parentInput.value = rb.getAttribute("data-reply-to");
|
|
||||||
if (banner) {
|
|
||||||
banner.hidden = false;
|
|
||||||
var who = $(".reply-banner-name", banner);
|
|
||||||
if (who) who.textContent = rb.getAttribute("data-reply-name") || "";
|
|
||||||
}
|
|
||||||
if (ta) { ta.placeholder = "답글을 입력하세요…"; ta.focus(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.target.closest("[data-cancel-reply]")) {
|
|
||||||
e.preventDefault();
|
|
||||||
var scope2 = e.target.closest(".modal") || document;
|
|
||||||
var pi = $('input[name="parent_id"]', scope2); if (pi) pi.value = "";
|
|
||||||
var bn = $(".reply-banner", scope2); if (bn) bn.hidden = true;
|
|
||||||
var ta2 = $(".comment-input", scope2); if (ta2) ta2.placeholder = "댓글을 입력하세요…";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var del = e.target.closest("[data-confirm]");
|
|
||||||
if (del) {
|
|
||||||
if (!window.confirm(del.getAttribute("data-confirm"))) e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Enter 등록 / Shift+Enter 줄바꿈
|
|
||||||
document.addEventListener("keydown", function (e) {
|
|
||||||
var ta = e.target;
|
|
||||||
if (ta && ta.classList && ta.classList.contains("comment-input") && e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
var form = ta.closest("form");
|
|
||||||
if (form && ta.value.trim()) form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 공유(등록) 모달 ---------------- */
|
|
||||||
function openShare() {
|
|
||||||
var m = $("#shareModal"); if (!m) return;
|
|
||||||
m.hidden = false; document.body.style.overflow = "hidden";
|
|
||||||
var t = $('input[name="title"]', m); if (t) t.focus();
|
|
||||||
}
|
|
||||||
function closeShare() {
|
|
||||||
var m = $("#shareModal"); if (!m || m.hidden) return;
|
|
||||||
m.hidden = true; document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
function initShare() {
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
if (e.target.closest("[data-open-share]")) { e.preventDefault(); openShare(); return; }
|
|
||||||
if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; }
|
|
||||||
var m = $("#shareModal");
|
|
||||||
if (m && !m.hidden && e.target === m) closeShare();
|
|
||||||
// 공개/비공개 토글
|
|
||||||
var vo = e.target.closest("[data-vis]");
|
|
||||||
if (vo && m) {
|
|
||||||
var val = vo.getAttribute("data-vis");
|
|
||||||
var input = $('input[name="is_public"]', m);
|
|
||||||
if (input) input.value = val === "public" ? "1" : "0";
|
|
||||||
$$("[data-vis]", m).forEach(function (b) {
|
|
||||||
var pub = b.getAttribute("data-vis") === "public";
|
|
||||||
b.classList.toggle("on-public", pub && val === "public");
|
|
||||||
b.classList.toggle("on-private", !pub && val === "private");
|
|
||||||
});
|
|
||||||
var hint = $(".share-vishint", m);
|
|
||||||
if (hint) hint.textContent = val === "public" ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 검색 / 정렬 / 필터 (클라이언트, 피드 대상) ---------------- */
|
|
||||||
function applyFeed() {
|
|
||||||
var grid = $("#feedGrid"); if (!grid) return;
|
|
||||||
var q = ($("#feedSearch") && $("#feedSearch").value || "").trim().toLowerCase();
|
|
||||||
var starOnly = $("#feedStarFilter") && $("#feedStarFilter").classList.contains("on");
|
|
||||||
var cards = $$(".fcard", grid);
|
|
||||||
var visible = 0;
|
|
||||||
cards.forEach(function (c) {
|
|
||||||
var hit = !q || (c.getAttribute("data-search") || "").indexOf(q) >= 0;
|
|
||||||
var st = !starOnly || c.getAttribute("data-starred") === "true";
|
|
||||||
var show = hit && st;
|
|
||||||
c.style.display = show ? "" : "none";
|
|
||||||
if (show) visible++;
|
|
||||||
});
|
|
||||||
var empty = $("#feedEmpty");
|
|
||||||
if (empty) {
|
|
||||||
empty.hidden = visible !== 0;
|
|
||||||
empty.textContent = starOnly && visible === 0
|
|
||||||
? "아직 즐겨찾기한 프로젝트가 없어요 · 카드의 ⭐ 버튼으로 추가하세요"
|
|
||||||
: "“" + ($("#feedSearch") && $("#feedSearch").value || "") + "” 와 일치하는 프로젝트가 없습니다.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function sortFeed(mode) {
|
|
||||||
var grid = $("#feedGrid"); if (!grid) return;
|
|
||||||
var cards = $$(".fcard", grid);
|
|
||||||
cards.sort(function (a, b) {
|
|
||||||
if (mode === "popular") {
|
|
||||||
return (+b.getAttribute("data-pop") || 0) - (+a.getAttribute("data-pop") || 0)
|
|
||||||
|| (b.getAttribute("data-created") || "").localeCompare(a.getAttribute("data-created") || "");
|
|
||||||
}
|
|
||||||
return (b.getAttribute("data-created") || "").localeCompare(a.getAttribute("data-created") || "");
|
|
||||||
});
|
|
||||||
cards.forEach(function (c) { grid.appendChild(c); });
|
|
||||||
}
|
|
||||||
function initFeedControls() {
|
|
||||||
var search = $("#feedSearch");
|
|
||||||
if (search) search.addEventListener("input", applyFeed);
|
|
||||||
var sl = $("#sortLatest"), sp = $("#sortPopular");
|
|
||||||
if (sl) sl.addEventListener("click", function () { sl.classList.add("on"); if (sp) sp.classList.remove("on"); sortFeed("latest"); });
|
|
||||||
if (sp) sp.addEventListener("click", function () { sp.classList.add("on"); if (sl) sl.classList.remove("on"); sortFeed("popular"); });
|
|
||||||
var sf = $("#feedStarFilter");
|
|
||||||
if (sf) sf.addEventListener("click", function () { sf.classList.toggle("on"); applyFeed(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 내 프로젝트 필터 (전체/공개/비공개) ---------------- */
|
|
||||||
function initMyFilter() {
|
|
||||||
var tabs = $$("[data-myfilter]");
|
|
||||||
if (!tabs.length) return;
|
|
||||||
tabs.forEach(function (tab) {
|
|
||||||
tab.addEventListener("click", function () {
|
|
||||||
var f = tab.getAttribute("data-myfilter"); // all | public | private
|
|
||||||
tabs.forEach(function (t) { t.classList.toggle("on", t === tab); });
|
|
||||||
$$("#myProjGrid .pcard").forEach(function (c) {
|
|
||||||
var vis = c.getAttribute("data-vis");
|
|
||||||
c.style.display = (f === "all" || vis === f) ? "" : "none";
|
|
||||||
});
|
|
||||||
var add = $("#addCard");
|
|
||||||
if (add) add.style.display = f === "all" ? "" : "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 시작 신호 처리 (서버가 body 속성으로 전달) ---------------- */
|
|
||||||
function initSignals() {
|
|
||||||
var b = document.body;
|
|
||||||
if (b.getAttribute("data-toast")) toast(b.getAttribute("data-toast"));
|
|
||||||
var egg = b.getAttribute("data-egg");
|
|
||||||
if (egg === "lgtm") { burstThumbs(); toast("LGTM 👍 — Looks Good To Me!"); }
|
|
||||||
else if (egg === "rocket") burstRocket();
|
|
||||||
var open = b.getAttribute("data-open-modal");
|
|
||||||
if (open) openDetail(open);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 콘솔 이스터에그 ---------------- */
|
|
||||||
function consoleArt() {
|
|
||||||
if (!EGGS_ON) return;
|
|
||||||
try {
|
|
||||||
var art = [" ___ ____ ____ _______ __", " / | / _/ / __ \\/ ____/ | / /", " / /| | / / / / / / __/ | | / / ", "/ ___ |_/ / / /_/ / /___ | |/ / ", "/_/ |_/___/ /_____/_____/ |___/ "].join("\n");
|
|
||||||
console.log("%c" + art, "color:#0969da;font-family:monospace;font-size:11px;line-height:1.1");
|
|
||||||
console.log("%c👀 코드 구경 왔어요? 환영합니다.", "color:#1f883d;font-size:13px;font-weight:bold");
|
|
||||||
console.log("%c사내 개발팀은 늘 동료를 찾고 있어요 — git blame 하지 말고 git praise 합시다. 🙇", "color:#656d76;font-size:12px");
|
|
||||||
console.log("%cps. 댓글에 LGTM 한 번 쳐보세요.", "color:#8b949e;font-size:11px");
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
initTheme();
|
|
||||||
initUserMenu();
|
|
||||||
initLogoEgg();
|
|
||||||
initReactions();
|
|
||||||
initDetailModal();
|
|
||||||
initComments();
|
|
||||||
initShare();
|
|
||||||
initFeedControls();
|
|
||||||
initMyFilter();
|
|
||||||
initSignals();
|
|
||||||
consoleArt();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
59
public/css/base.css
Normal file
59
public/css/base.css
Normal 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
220
public/css/components.css
Normal 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
118
public/css/layout.css
Normal 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
128
public/css/modal.css
Normal 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
60
public/css/responsive.css
Normal 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
39
public/css/tokens.css
Normal 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
14
public/favicon.svg
Normal 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="</>">
|
||||||
|
<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 |
31
public/js/announcements.js
Normal file
31
public/js/announcements.js
Normal 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
43
public/js/comments.js
Normal 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
65
public/js/feed.js
Normal 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
62
public/js/main.js
Normal 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
163
public/js/modals.js
Normal 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
150
public/js/notifications.js
Normal 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
192
public/js/profile.js
Normal 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
99
public/js/reactions.js
Normal 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
59
public/js/ui.js
Normal 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
68
public/js/util.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
399
public/style.css
399
public/style.css
@@ -1,399 +0,0 @@
|
|||||||
/* AI DEV 포털 — 단일 스타일시트. (디자인 레퍼런스 GitHub 감성 / 라이트·다크)
|
|
||||||
빌드 없음: CSS 변수 + 클래스만. 다크는 html[data-aidev-theme="dark"] 로 전환. */
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg:#ffffff; --panel:#f6f8fa; --card:#ffffff; --tile:#f3f5f8; --tile-bd:#e1e6eb;
|
|
||||||
--inset:#ffffff; --bg-soft:#fbfcfd;
|
|
||||||
--border:#d0d7de; --border-soft:#d8dee4; --divider:#eaecef;
|
|
||||||
--fg:#1f2328; --fg2:#656d76; --fg3:#8b949e;
|
|
||||||
--link:#0969da; --accent-bg:#ddf4ff; --accent-bd:#b6e3ff; --accent-fg:#0969da; --accent-strong:#0a3069;
|
|
||||||
--success:#1f883d; --success-hover:#1a7f37; --success-bg:#dafbe1; --success-bd:#b7ebc4; --success-bd2:#4ac26b; --success-fg:#1a7f37;
|
|
||||||
--attn:#d4a72c; --attn-fg:#9a6700; --attn-bg:#fff8e6; --attn-bd:#eac54f;
|
|
||||||
--admin:#8250df; --admin-fg:#8250df; --admin-bg:#f3effc; --admin-bd:#e7defb;
|
|
||||||
--avatar-bg:#eaeef2; --overlay:rgba(31,35,40,.42);
|
|
||||||
--shadow:rgba(31,35,40,.08); --shadow-hover:rgba(31,35,40,.12); --shadow-strong:rgba(31,35,40,.22);
|
|
||||||
--btn-bg:#f6f8fa; --btn-bd:#d0d7de; --btn-hover:#eef1f4;
|
|
||||||
--tab-underline:#fd8c73; --focus-ring:rgba(9,105,218,.12); --danger:#cf222e;
|
|
||||||
--heart:#bf3989; --heart-fg:#bf3989; --heart-bg:#ffeff5; --heart-bd:#ffc1da;
|
|
||||||
--hbg:#24292f; --hfg:#ffffff; --hfg2:#b9c0c9; --hbd:#444c56; --hsearch-bg:#383f47; --hsearch-bd:#565b62; --hsearch-fg:#cdd3da;
|
|
||||||
}
|
|
||||||
html[data-aidev-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--bg:#0d1117; --panel:#161b22; --card:#1c2128; --tile:#2d333b; --tile-bd:#373e47;
|
|
||||||
--inset:#0d1117; --bg-soft:#161b22;
|
|
||||||
--border:#30363d; --border-soft:#373e47; --divider:#262c34;
|
|
||||||
--fg:#e6edf3; --fg2:#9198a1; --fg3:#6e7681;
|
|
||||||
--link:#4493f8; --accent-bg:rgba(56,139,253,.15); --accent-bd:rgba(56,139,253,.4); --accent-fg:#4493f8; --accent-strong:#a5d6ff;
|
|
||||||
--success:#238636; --success-hover:#2ea043; --success-bg:rgba(46,160,67,.15); --success-bd:rgba(46,160,67,.4); --success-bd2:rgba(63,185,80,.6); --success-fg:#3fb950;
|
|
||||||
--attn:#e3b341; --attn-fg:#e3b341; --attn-bg:rgba(227,179,65,.12); --attn-bd:rgba(187,128,9,.6);
|
|
||||||
--admin:#bc8cff; --admin-fg:#bc8cff; --admin-bg:rgba(163,113,247,.15); --admin-bd:rgba(163,113,247,.4);
|
|
||||||
--avatar-bg:#2d333b; --overlay:rgba(1,4,9,.7);
|
|
||||||
--shadow:rgba(1,4,9,.4); --shadow-hover:rgba(1,4,9,.55); --shadow-strong:rgba(1,4,9,.6);
|
|
||||||
--btn-bg:#21262d; --btn-bd:#30363d; --btn-hover:#30363d;
|
|
||||||
--tab-underline:#f78166; --focus-ring:rgba(56,139,253,.3); --danger:#f85149;
|
|
||||||
--heart:#db61a2; --heart-fg:#db61a2; --heart-bg:rgba(219,97,162,.15); --heart-bd:rgba(219,97,162,.4);
|
|
||||||
--hbg:#010409; --hfg:#e6edf3; --hfg2:#9198a1; --hbd:#21262d; --hsearch-bg:#0d1117; --hsearch-bd:#30363d; --hsearch-fg:#9198a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body { background: var(--bg); color: var(--fg); }
|
|
||||||
body {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== 헤더 ===================== */
|
|
||||||
.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: 11px; color: var(--hfg2); font-weight: 500; border-left: 1px solid var(--hbd); padding-left: 10px; margin-left: 2px; white-space: nowrap; }
|
|
||||||
|
|
||||||
.hdr-search { flex: 1; max-width: 300px; position: relative; display: flex; align-items: center; }
|
|
||||||
.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); }
|
|
||||||
.theme-ico-sun { display: none; }
|
|
||||||
html[data-aidev-theme="dark"] .theme-ico-moon { display: none; }
|
|
||||||
html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
|
|
||||||
|
|
||||||
.admin-pill {
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600;
|
|
||||||
color: #fff; background: color-mix(in srgb, var(--admin) 80%, transparent);
|
|
||||||
border: 1px solid var(--hbd); border-radius: 999px; padding: 4px 11px; white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usermenu { position: relative; }
|
|
||||||
.usermenu-btn {
|
|
||||||
display: flex; align-items: center; gap: 8px; background: transparent; border: 1px solid transparent;
|
|
||||||
border-radius: 8px; padding: 4px 6px 4px 8px; cursor: pointer; font-family: inherit;
|
|
||||||
}
|
|
||||||
.usermenu-btn:hover { background: rgba(255,255,255,.08); }
|
|
||||||
.usermenu-btn .who { font-size: 13px; font-weight: 600; color: var(--hfg); font-variant-numeric: tabular-nums; }
|
|
||||||
.usermenu-btn .caret { display: inline-flex; color: var(--hfg2); }
|
|
||||||
.usermenu-pop {
|
|
||||||
position: absolute; top: 46px; right: 0; width: 212px; background: var(--card); border: 1px solid var(--border);
|
|
||||||
border-radius: 10px; box-shadow: 0 8px 24px var(--shadow-strong); padding: 6px; z-index: 320; animation: aidev-pop .14s ease-out;
|
|
||||||
}
|
|
||||||
.usermenu-pop[hidden] { display: none; }
|
|
||||||
.usermenu-head { padding: 8px 10px 10px; border-bottom: 1px solid var(--divider); margin-bottom: 6px; }
|
|
||||||
.usermenu-label { font-size: 12px; color: var(--fg2); }
|
|
||||||
.usermenu-sabun { font-size: 13.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
|
||||||
.usermenu-role { font-size: 11.5px; color: var(--fg2); margin-top: 2px; }
|
|
||||||
.usermenu-item { width: 100%; text-align: left; background: transparent; border: 0; border-radius: 6px; padding: 8px 10px; cursor: pointer; font-family: inherit; font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 8px; }
|
|
||||||
.usermenu-item:hover { background: var(--panel); }
|
|
||||||
.usermenu-ico { display: inline-flex; color: var(--fg2); }
|
|
||||||
|
|
||||||
/* ===================== 레이아웃 / 섹션 ===================== */
|
|
||||||
.main { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; }
|
|
||||||
.section { margin-bottom: 40px; }
|
|
||||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
|
||||||
.section-titlewrap { display: flex; align-items: baseline; gap: 10px; }
|
|
||||||
.section-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; }
|
|
||||||
.section-sub { font-size: 13px; color: var(--fg2); }
|
|
||||||
|
|
||||||
/* ===================== ① 개발 도구 ===================== */
|
|
||||||
.tools-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
|
||||||
.tool-card {
|
|
||||||
position: relative; text-align: left; background: var(--card); border: 1px solid var(--border-soft);
|
|
||||||
border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit; display: block;
|
|
||||||
transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
|
|
||||||
}
|
|
||||||
.tool-card:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow-hover); transform: translateY(-3px); }
|
|
||||||
.tool-card.admin { border-color: var(--admin-bd); }
|
|
||||||
.tool-icon {
|
|
||||||
width: 44px; height: 44px; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 24px; background: var(--tile); border: 1px solid var(--tile-bd);
|
|
||||||
}
|
|
||||||
.tool-card.admin .tool-icon { background: var(--admin-bg); border-color: var(--admin-bd); }
|
|
||||||
.tool-name { display: block; margin-top: 14px; font-size: 14.5px; font-weight: 700; color: var(--fg); }
|
|
||||||
.tool-desc { display: block; margin-top: 3px; font-size: 12.5px; color: var(--fg2); line-height: 1.45; }
|
|
||||||
.sso-badge {
|
|
||||||
position: absolute; top: 13px; right: 13px; font-size: 10px; font-weight: 700; letter-spacing: .06em;
|
|
||||||
color: var(--accent-fg); background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 4px; padding: 2px 6px;
|
|
||||||
}
|
|
||||||
.tool-admin-badge {
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; margin-top: 11px; font-size: 11px; font-weight: 600;
|
|
||||||
color: var(--admin-fg); background: var(--admin-bg); border: 1px solid var(--admin-bd); border-radius: 999px; padding: 2px 8px; white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== ② 내 프로젝트 ===================== */
|
|
||||||
.myproj { position: relative; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px 22px 24px; }
|
|
||||||
.myproj-accent { position: absolute; left: 0; top: 18px; bottom: 18px; width: 4px; border-radius: 0 4px 4px 0; background: var(--link); }
|
|
||||||
.myproj-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding-left: 8px; flex-wrap: wrap; }
|
|
||||||
.myproj-tools { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.filter-tabs { display: inline-flex; align-items: center; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 2px; gap: 2px; }
|
|
||||||
.filter-tab {
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 5px 11px;
|
|
||||||
cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--fg2); white-space: nowrap;
|
|
||||||
transition: background .14s ease, color .14s ease;
|
|
||||||
}
|
|
||||||
.filter-tab.on { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
|
|
||||||
.new-proj-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; background: var(--success);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 8px; padding: 7px 13px;
|
|
||||||
cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
|
|
||||||
}
|
|
||||||
.new-proj-btn:hover { background: var(--success-hover); }
|
|
||||||
.myproj-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-left: 8px; }
|
|
||||||
|
|
||||||
/* 프로젝트 카드 (내 프로젝트 / 피드 공용 베이스) */
|
|
||||||
.pcard {
|
|
||||||
background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 15px 16px 14px;
|
|
||||||
cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column;
|
|
||||||
}
|
|
||||||
.pcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
|
|
||||||
.pcard.private { background: var(--bg); border: 1.5px dashed var(--border); }
|
|
||||||
.pcard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
||||||
.pcard-titlewrap { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
|
||||||
.pcard-repoico { display: inline-flex; color: var(--fg2); flex: 0 0 auto; }
|
|
||||||
.pcard-title { font-size: 14px; font-weight: 700; color: var(--link); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.pcard-desc { margin-top: 8px; font-size: 12.5px; color: var(--fg2); line-height: 1.5; flex: 1; }
|
|
||||||
.pcard-tags { margin-top: 11px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.pcard-foot { margin-top: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
||||||
.pcard-stats { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
|
|
||||||
.pcard-stats .stat { display: inline-flex; align-items: center; gap: 4px; }
|
|
||||||
.lang-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
|
||||||
.ico-heart { display: inline-flex; color: var(--heart); }
|
|
||||||
.ico-star { display: inline-flex; color: var(--attn); }
|
|
||||||
.ico-comment { display: inline-flex; color: var(--fg2); }
|
|
||||||
.vis-badge {
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; font-size: 11px; font-weight: 600;
|
|
||||||
border-radius: 999px; padding: 1px 8px 1px 7px; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.vis-badge.public { color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd); }
|
|
||||||
.vis-badge.private { color: var(--fg2); background: var(--panel); border: 1px solid var(--border); }
|
|
||||||
.toggle-share-btn { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg3); white-space: nowrap; }
|
|
||||||
.toggle-share-btn:hover { color: var(--fg); }
|
|
||||||
.private-note { font-size: 11.5px; color: var(--fg3); }
|
|
||||||
.share-mini-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; background: var(--success);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 7px; padding: 4px 10px;
|
|
||||||
cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
|
|
||||||
}
|
|
||||||
.share-mini-btn:hover { background: var(--success-hover); }
|
|
||||||
.add-card {
|
|
||||||
background: transparent; border: 1.5px dashed var(--border); border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit;
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--fg2); min-height: 158px; width: 100%;
|
|
||||||
transition: border-color .16s ease, color .16s ease, background .16s ease;
|
|
||||||
}
|
|
||||||
.add-card:hover { border-color: var(--success); color: var(--success); background: var(--card); }
|
|
||||||
.add-card-plus { width: 38px; height: 38px; border-radius: 50%; background: var(--success-bg); border: 1px solid var(--success-bd); display: inline-flex; align-items: center; justify-content: center; color: var(--success); }
|
|
||||||
.add-card-title { font-size: 13.5px; font-weight: 700; white-space: nowrap; }
|
|
||||||
.add-card-sub { font-size: 11.5px; text-align: center; line-height: 1.4; }
|
|
||||||
.empty { grid-column: 1 / -1; text-align: center; padding: 30px; color: var(--fg2); font-size: 13px; border: 1px dashed var(--border); border-radius: 12px; }
|
|
||||||
|
|
||||||
/* ===================== ③ 공유 피드 ===================== */
|
|
||||||
.feed-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--border-soft); }
|
|
||||||
.feed-head .section-titlewrap { padding-bottom: 14px; }
|
|
||||||
.feed-head-right { display: flex; align-items: flex-end; gap: 14px; }
|
|
||||||
.feed-star-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--btn-bd); border-radius: 7px;
|
|
||||||
padding: 6px 12px; margin-bottom: 8px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap;
|
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
|
||||||
}
|
|
||||||
.feed-star-btn:hover { border-color: var(--attn-bd); color: var(--attn-fg); }
|
|
||||||
.feed-star-btn.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
|
||||||
.feed-star-btn .ico { display: inline-flex; }
|
|
||||||
.sort-tabs { display: flex; gap: 2px; }
|
|
||||||
.sort-tab { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px 14px; color: var(--fg2); transition: color .14s ease; }
|
|
||||||
.sort-tab.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--tab-underline); }
|
|
||||||
.feed-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
|
||||||
|
|
||||||
.fcard { background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 18px; cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column; }
|
|
||||||
.fcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
|
|
||||||
.fcard-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
||||||
.fcard-titlewrap { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
||||||
.fcard-title { font-size: 15px; font-weight: 700; color: var(--link); line-height: 1.3; }
|
|
||||||
.react-btns { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
|
||||||
.react-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; background: var(--btn-bg); border: 1px solid var(--btn-bd);
|
|
||||||
border-radius: 7px; padding: 4px 9px; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg);
|
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
|
||||||
}
|
|
||||||
.react-btn .count { font-variant-numeric: tabular-nums; }
|
|
||||||
.react-btn.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
|
|
||||||
.react-btn.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
|
||||||
/* 채움(on) / 외곽선(off) 아이콘 토글 — JS 가 .on 클래스만 바꾸면 됨 */
|
|
||||||
.react-btn .ico, .modal-action .ico { display: inline-flex; }
|
|
||||||
.react-btn .ico-on, .modal-action .ico-on { display: none; }
|
|
||||||
.react-btn.on .ico-on, .modal-action.on .ico-on { display: inline-flex; }
|
|
||||||
.react-btn.on .ico-off, .modal-action.on .ico-off { display: none; }
|
|
||||||
.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; }
|
|
||||||
.fcard-tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.fcard-foot { margin-top: 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.fcard-author { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
||||||
.fcard-author .sabun { font-size: 12.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
|
||||||
.fcard-author .date { font-size: 11.5px; color: var(--fg3); }
|
|
||||||
.fcard-meta { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
|
|
||||||
.fcard-meta .stat { display: inline-flex; align-items: center; gap: 5px; }
|
|
||||||
.bara-link { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); white-space: nowrap; }
|
|
||||||
|
|
||||||
.tag { font-size: 11px; font-weight: 500; color: var(--accent-fg); background: var(--accent-bg); border-radius: 999px; padding: 1px 9px; }
|
|
||||||
|
|
||||||
/* ===================== 상세 모달 ===================== */
|
|
||||||
.modal-overlay { position: fixed; inset: 0; z-index: 500; background: var(--overlay); display: flex; align-items: flex-start; justify-content: center; padding: 48px 20px; overflow-y: auto; animation: aidev-fade .16s ease-out; }
|
|
||||||
.modal-overlay[hidden] { display: none; }
|
|
||||||
.modal { width: 100%; max-width: 640px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
|
|
||||||
.modal-head { padding: 22px 24px 18px; border-bottom: 1px solid var(--divider); }
|
|
||||||
.modal-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
|
||||||
.modal-titlewrap { min-width: 0; }
|
|
||||||
.modal-title-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.modal-repoico { display: inline-flex; color: var(--fg2); }
|
|
||||||
.modal-title { margin: 0; font-size: 20px; font-weight: 800; color: var(--link); line-height: 1.3; letter-spacing: -.01em; }
|
|
||||||
.modal-meta { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.modal-meta .sabun { font-size: 13px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
|
||||||
.modal-meta .date { font-size: 12px; color: var(--fg3); }
|
|
||||||
.modal-meta .lang { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--fg2); margin-left: 4px; }
|
|
||||||
.modal-close { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; border: 1px solid transparent; background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--fg2); }
|
|
||||||
.modal-close:hover { background: var(--panel); border-color: var(--border-soft); }
|
|
||||||
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
|
||||||
.modal-desc { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; white-space: pre-wrap; }
|
|
||||||
.modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.modal-action {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px;
|
|
||||||
padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg);
|
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
|
||||||
}
|
|
||||||
.modal-action .count { font-variant-numeric: tabular-nums; padding-left: 8px; margin-left: 2px; border-left: 1px solid var(--border); }
|
|
||||||
.modal-action.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
|
|
||||||
.modal-action.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
|
||||||
.modal-action.primary { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
|
||||||
.modal-action.primary:hover { background: var(--success-hover); }
|
|
||||||
.modal-action.ghost:hover { background: var(--btn-hover); }
|
|
||||||
|
|
||||||
/* 댓글 스레드 */
|
|
||||||
.modal-body { padding: 18px 24px 4px; }
|
|
||||||
.comments-title { font-size: 13px; font-weight: 700; color: var(--fg); margin-bottom: 14px; display: flex; align-items: center; gap: 7px; }
|
|
||||||
.comments-count { font-weight: 600; color: var(--fg2); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 999px; padding: 0 8px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
||||||
.comment-list { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.comment { display: flex; gap: 11px; padding: 11px 0; }
|
|
||||||
.comment-main { min-width: 0; flex: 1; }
|
|
||||||
.comment-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.comment-author { font-size: 13px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; }
|
|
||||||
.comment-time { font-size: 11.5px; color: var(--fg3); }
|
|
||||||
.lgtm { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 800; letter-spacing: .04em; color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd2); border-radius: 5px; padding: 1px 7px; animation: aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2); }
|
|
||||||
.comment-text { margin-top: 3px; font-size: 13.5px; color: var(--fg); line-height: 1.6; white-space: pre-wrap; }
|
|
||||||
.comment-actions { margin-top: 5px; display: flex; align-items: center; gap: 12px; }
|
|
||||||
.comment-link { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg2); display: inline-flex; align-items: center; gap: 5px; }
|
|
||||||
.comment-link:hover { color: var(--link); }
|
|
||||||
.comment-del:hover { color: var(--danger); }
|
|
||||||
.replies { margin-left: 21px; padding-left: 18px; border-left: 2px solid var(--divider); display: flex; flex-direction: column; }
|
|
||||||
.reply { display: flex; gap: 10px; padding: 10px 0; }
|
|
||||||
.reply .comment-author { font-size: 12.5px; }
|
|
||||||
.reply .comment-text { font-size: 13px; }
|
|
||||||
|
|
||||||
.modal-foot { padding: 14px 24px 22px; border-top: 1px solid var(--divider); margin-top: 6px; background: var(--bg-soft); }
|
|
||||||
.reply-banner { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 7px; padding: 6px 10px; }
|
|
||||||
.reply-banner span { font-size: 12px; color: var(--accent-strong); }
|
|
||||||
.reply-banner button { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); }
|
|
||||||
.comment-form { display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
.comment-input { flex: 1; resize: none; font-family: inherit; font-size: 13.5px; line-height: 1.5; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; outline: none; min-height: 40px; }
|
|
||||||
.comment-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
|
|
||||||
.submit-btn { flex: 0 0 auto; background: var(--success); border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 9px; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 700; color: #fff; align-self: stretch; white-space: nowrap; transition: background .14s ease; }
|
|
||||||
.submit-btn:hover { background: var(--success-hover); }
|
|
||||||
.comment-hint { margin-top: 7px; font-size: 11px; color: var(--fg3); padding-left: 38px; }
|
|
||||||
|
|
||||||
/* ===================== 공유(등록) 모달 ===================== */
|
|
||||||
.share-modal { width: 100%; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
|
|
||||||
.share-head { padding: 20px 24px; border-bottom: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.share-head h3 { margin: 0; font-size: 17px; font-weight: 800; color: var(--fg); }
|
|
||||||
.share-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 15px; }
|
|
||||||
.share-label { display: block; font-size: 12.5px; font-weight: 700; color: var(--fg); margin-bottom: 6px; }
|
|
||||||
.share-label .req { color: var(--danger); }
|
|
||||||
.share-label .hint { font-weight: 500; color: var(--fg3); }
|
|
||||||
.share-input { width: 100%; font-family: inherit; font-size: 14px; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none; }
|
|
||||||
.share-input.mono { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 13px; }
|
|
||||||
.share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
|
|
||||||
.vis-toggle { display: inline-flex; background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; }
|
|
||||||
.vis-opt { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; }
|
|
||||||
.vis-opt.on-public { background: var(--success-bg); color: var(--success-fg); box-shadow: inset 0 0 0 1px var(--success-bd); }
|
|
||||||
.vis-opt.on-private { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
|
|
||||||
.share-vishint { margin-top: 7px; font-size: 11.5px; color: var(--fg3); }
|
|
||||||
.modal-foot-actions { padding: 16px 24px; border-top: 1px solid var(--divider); background: var(--bg-soft); display: flex; align-items: center; justify-content: flex-end; gap: 9px; }
|
|
||||||
|
|
||||||
/* ===================== 토스트 / 날아다니는 이모지 ===================== */
|
|
||||||
.toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); z-index: 600; background: #1f2328; color: #fff; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 11px 16px; font-size: 13px; font-weight: 500; box-shadow: 0 8px 24px rgba(1,4,9,.4); display: flex; align-items: center; gap: 9px; max-width: 90vw; animation: aidev-toast 2.6s ease forwards; }
|
|
||||||
.toast .toast-ico { display: inline-flex; color: #3fb950; }
|
|
||||||
.flyers { position: fixed; inset: 0; z-index: 650; pointer-events: none; overflow: hidden; }
|
|
||||||
.flyer { position: fixed; pointer-events: none; user-select: none; will-change: transform, opacity; }
|
|
||||||
|
|
||||||
/* ===================== 푸터 ===================== */
|
|
||||||
.footer { border-top: 1px solid var(--border-soft); }
|
|
||||||
.footer-inner { max-width: 1180px; margin: 0 auto; padding: 20px 24px 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
|
||||||
.footer-brand { display: flex; align-items: center; gap: 8px; color: var(--fg3); font-size: 12px; }
|
|
||||||
.footer-tech { font-size: 11.5px; color: var(--fg3); font-family: 'SF Mono', ui-monospace, Menlo, monospace; }
|
|
||||||
|
|
||||||
/* ===================== 버튼/태그 공용 (단독 페이지·로그인) ===================== */
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 6px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--fg); transition: background .14s ease; }
|
|
||||||
.btn:hover { background: var(--btn-hover); }
|
|
||||||
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
|
|
||||||
.btn-primary { background: var(--link); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
|
||||||
.btn-primary:hover { filter: brightness(1.06); }
|
|
||||||
.btn-success { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
|
||||||
.btn-success:hover { background: var(--success-hover); }
|
|
||||||
.muted { color: var(--fg2); }
|
|
||||||
.back-link { font-size: 13px; color: var(--fg2); }
|
|
||||||
.back-link:hover { color: var(--link); }
|
|
||||||
|
|
||||||
/* ===================== 로그인 폴백 페이지 ===================== */
|
|
||||||
.login-wrap { min-height: calc(100vh - 60px); display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
||||||
.login-card { width: 100%; max-width: 360px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 8px 24px var(--shadow); padding: 32px 28px; text-align: center; }
|
|
||||||
.login-logo { margin-bottom: 12px; }
|
|
||||||
.login-title { font-size: 20px; font-weight: 800; letter-spacing: .06em; margin-bottom: 18px; }
|
|
||||||
.alert { background: var(--attn-bg); border: 1px solid var(--attn-bd); color: var(--attn-fg); border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 16px; }
|
|
||||||
|
|
||||||
/* ===================== 단독 상세 페이지(project.ejs) ===================== */
|
|
||||||
.detail-wrap { max-width: 760px; margin: 0 auto; padding: 28px 24px 64px; }
|
|
||||||
|
|
||||||
/* ===================== 애니메이션 키프레임 ===================== */
|
|
||||||
@keyframes aidev-flyup { 0%{transform:translateY(0) scale(.5);opacity:0} 12%{opacity:1} 100%{transform:translateY(-180px) scale(1.15);opacity:0} }
|
|
||||||
@keyframes aidev-sparkle { 0%{transform:scale(0) rotate(0);opacity:1} 100%{transform:scale(1.5) rotate(120deg);opacity:0} }
|
|
||||||
@keyframes aidev-rocket { 0%{transform:translateX(-40px) rotate(-12deg);opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{transform:translateX(calc(100vw + 60px)) rotate(-12deg);opacity:0} }
|
|
||||||
@keyframes aidev-shake { 10%,90%{transform:translateX(-1px) rotate(-1deg)} 20%,80%{transform:translateX(2px) rotate(1.5deg)} 30%,50%,70%{transform:translateX(-4px) rotate(-2deg)} 40%,60%{transform:translateX(4px) rotate(2deg)} }
|
|
||||||
@keyframes aidev-stamp { 0%{transform:scale(2.4) rotate(-22deg);opacity:0} 55%{transform:scale(.86) rotate(-11deg);opacity:1} 100%{transform:scale(1) rotate(-11deg);opacity:1} }
|
|
||||||
@keyframes aidev-pop { 0%{transform:scale(.96) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
|
|
||||||
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
|
|
||||||
@keyframes aidev-toast { 0%{transform:translateX(-50%) translateY(12px);opacity:0} 12%{transform:translateX(-50%) translateY(0);opacity:1} 88%{transform:translateX(-50%) translateY(0);opacity:1} 100%{transform:translateX(-50%) translateY(8px);opacity:0} }
|
|
||||||
|
|
||||||
/* ===================== 반응형 ===================== */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.tools-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.myproj-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.feed-grid { grid-template-columns: 1fr; }
|
|
||||||
.brand-sub { display: none; }
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.tools-grid, .myproj-grid { grid-template-columns: 1fr; }
|
|
||||||
.hdr-search { display: none; }
|
|
||||||
}
|
|
||||||
38
schema.sql
38
schema.sql
@@ -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 (
|
||||||
@@ -56,3 +64,31 @@ CREATE TABLE IF NOT EXISTS stars (
|
|||||||
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);
|
||||||
|
|||||||
44
src/auth.js
44
src/auth.js
@@ -42,23 +42,36 @@ export async function handleCallback(req) {
|
|||||||
nonce,
|
nonce,
|
||||||
});
|
});
|
||||||
const claims = tokenSet.claims();
|
const claims = tokenSet.claims();
|
||||||
// Keycloak realm 역할: realm_access.roles. (클라이언트 역할은 resource_access[client].roles)
|
// 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
|
||||||
const realmRoles = (claims.realm_access && claims.realm_access.roles) || [];
|
// full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
|
||||||
const clientRoles =
|
const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, ""));
|
||||||
(claims.resource_access &&
|
// 표시 이름: 한국식 성+이름 순서(예: 홍길동). Keycloak `name` 클레임은 보통 이름+성("길동 홍")이라
|
||||||
claims.resource_access[config.oidc.clientId] &&
|
// family_name(성)+given_name(이름) 을 공백 없이 조합해 교정한다. 둘 다 없으면 name/사번으로 폴백.
|
||||||
claims.resource_access[config.oidc.clientId].roles) ||
|
const fam = (claims.family_name || "").trim();
|
||||||
[];
|
const giv = (claims.given_name || "").trim();
|
||||||
const roles = [...new Set([...realmRoles, ...clientRoles])];
|
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,
|
||||||
roles,
|
groups,
|
||||||
isAdmin: roles.includes(config.adminRole),
|
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();
|
||||||
@@ -69,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("관리자만 접근할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,21 +13,30 @@ export const config = {
|
|||||||
// redirect 는 baseUrl + /auth/callback 로 구성
|
// redirect 는 baseUrl + /auth/callback 로 구성
|
||||||
},
|
},
|
||||||
|
|
||||||
// 관리자 판별용 Keycloak 역할 이름. 이 role 을 가진 사용자에게만 인프라 도구 노출.
|
// 관리자 판별용 Keycloak 그룹 이름. 이 그룹에 속한 사용자에게만 인프라 도구 노출.
|
||||||
adminRole: process.env.ADMIN_ROLE || "admin",
|
// (groups 클레임은 portal 클라이언트의 group-membership 매퍼가 ID 토큰에 넣어줌)
|
||||||
|
adminGroup: process.env.ADMIN_GROUP || "DevOps",
|
||||||
|
|
||||||
|
// 프로젝트 등록 시 Gitea 언어 통계 API 로 메인 언어 자동 판별용 토큰(선택).
|
||||||
|
// 공개 레포는 없어도 되고, 비공개 레포 조회에만 필요.
|
||||||
|
giteaToken: process.env.GITEA_TOKEN || "",
|
||||||
|
|
||||||
// 도구(사이트) URL — 폐쇄망/도메인 변경 대비 환경변수로 덮어쓰기 가능.
|
// 도구(사이트) URL — 폐쇄망/도메인 변경 대비 환경변수로 덮어쓰기 가능.
|
||||||
tools: {
|
tools: {
|
||||||
coder: process.env.CODER_URL || "https://coder.bokdev.in/api/v2/users/oidc/callback",
|
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",
|
gitea: process.env.GITEA_URL || "https://gitea.bokdev.in/user/oauth2/keycloak",
|
||||||
kubero: process.env.KUBERO_URL || "https://kubero.bokdev.in",
|
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",
|
harbor: process.env.HARBOR_URL || "https://harbor.bokdev.in",
|
||||||
openeverest: process.env.OPENEVEREST_URL || "https://openeverest.bokdev.in",
|
openeverest: process.env.OPENEVEREST_URL || "https://openeverest.bokdev.in",
|
||||||
minio: process.env.MINIO_URL || "https://minioc.bokdev.in",
|
minio: process.env.MINIO_URL || "https://minioc.bokdev.in",
|
||||||
keycloak: process.env.KEYCLOAK_URL || "https://keycloak.bokdev.in/admin",
|
keycloak: process.env.KEYCLOAK_URL || "https://keycloak.bokdev.in/admin",
|
||||||
litellm: process.env.LITELLM_URL || "https://litellm.bokdev.in",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
175
src/db.js
175
src/db.js
@@ -6,17 +6,48 @@ 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 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(
|
||||||
|
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio, u.is_admin,
|
||||||
|
(u.avatar IS NOT NULL) AS has_avatar,
|
||||||
|
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
||||||
|
FROM users u WHERE u.sabun=$1`,
|
||||||
|
[sabun]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
// 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
|
// 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
|
||||||
// $1 = viewer 사번.
|
// $1 = viewer 사번.
|
||||||
const PROJECT_SELECT = `
|
const PROJECT_SELECT = `
|
||||||
@@ -40,6 +71,18 @@ export async function listProjects(viewer) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 특정 사용자(owner)의 프로젝트 — 프로필 페이지용.
|
||||||
|
// 본인($1===owner)이면 비공개 포함 전체, 타인이면 공개 프로젝트만.
|
||||||
|
export async function listProjectsByOwner(owner, viewer) {
|
||||||
|
const { rows } = await q(
|
||||||
|
`${PROJECT_SELECT}
|
||||||
|
WHERE p.owner_sabun=$2 AND (p.is_public OR p.owner_sabun=$1)
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
[viewer, owner]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
|
// 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
|
||||||
export async function getProject(id, viewer) {
|
export async function getProject(id, viewer) {
|
||||||
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
|
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
|
||||||
@@ -78,6 +121,17 @@ export async function addProject({ owner, title, description, repoUrl, appUrl, t
|
|||||||
return rows[0].id;
|
return rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 본인 프로젝트 수정. 적용 행 수 반환(0이면 권한 없음/없음). 소유자 조건으로 타인 수정 차단.
|
||||||
|
export async function updateProject({ id, sabun, title, description, repoUrl, appUrl, tags, isPublic, lang }) {
|
||||||
|
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이면 권한 없음/없음).
|
// 본인 프로젝트 삭제. 삭제 행 수 반환(0이면 권한 없음/없음).
|
||||||
export async function deleteProject({ id, sabun }) {
|
export async function deleteProject({ id, sabun }) {
|
||||||
const { rowCount } = await q(
|
const { rowCount } = await q(
|
||||||
@@ -146,3 +200,112 @@ export async function toggleStar({ projectId, sabun }) {
|
|||||||
const { rows } = await q(`SELECT count(*)::int AS c FROM stars WHERE project_id=$1`, [projectId]);
|
const { rows } = await q(`SELECT count(*)::int AS c FROM stars WHERE project_id=$1`, [projectId]);
|
||||||
return { on, count: rows[0].c };
|
return { on, count: rows[0].c };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== 알림(🔔) =====================
|
||||||
|
|
||||||
|
// 프로젝트에 반응(❤️/⭐)·댓글이 달리면 프로젝트 주인에게 알림.
|
||||||
|
// 본인 행위(주인===행위자)엔 알림하지 않는다. 같은 (받는사람,행위자,프로젝트,종류)의
|
||||||
|
// "안 읽은" 알림이 이미 있으면 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
92
src/gitea.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/helpers.js
134
src/helpers.js
@@ -1,10 +1,13 @@
|
|||||||
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
|
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
|
||||||
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
|
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
|
||||||
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor() 로 사용.
|
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor()/renderMarkdown() 로 사용.
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
|
||||||
// GitHub Octicon 16x16 패스 모음.
|
// GitHub Octicon 16x16 패스 모음.
|
||||||
const OCTICONS = {
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
@@ -24,6 +27,12 @@ const OCTICONS = {
|
|||||||
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"/>',
|
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"/>',
|
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"/>',
|
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(부모 색 상속).
|
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).
|
||||||
@@ -37,6 +46,86 @@ export function icon(name, opts = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 인증 뱃지 — 골드 스캘럽 씰 + 흰 체크(다색이라 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 = {
|
const LANG_COLORS = {
|
||||||
Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a",
|
Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a",
|
||||||
@@ -54,7 +143,7 @@ export function langFromTags(tags) {
|
|||||||
if (t.includes("go") || t.includes("golang")) return "Go";
|
if (t.includes("go") || t.includes("golang")) return "Go";
|
||||||
if (t.includes("rust")) return "Rust";
|
if (t.includes("rust")) return "Rust";
|
||||||
if (t.includes("node") || t.includes("js")) return "JavaScript";
|
if (t.includes("node") || t.includes("js")) return "JavaScript";
|
||||||
return "TypeScript";
|
return ""; // 매칭되는 언어 태그가 없으면 언어 미표시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 → 결정적 해시(아바타/색 시드).
|
// 문자열 → 결정적 해시(아바타/색 시드).
|
||||||
@@ -65,12 +154,10 @@ function hashSeed(seed) {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각.
|
// 사번 기반 identicon(5x5 대칭 격자)의 rects+color 계산 코어.
|
||||||
export function identicon(seed, size = 28) {
|
function identiconCore(seed) {
|
||||||
const h = hashSeed(seed);
|
const h = hashSeed(seed);
|
||||||
const hue = h % 360;
|
const color = `hsl(${h % 360},${48 + (h % 22)}%,52%)`;
|
||||||
const sat = 48 + (h % 22);
|
|
||||||
const color = `hsl(${hue},${sat}%,52%)`;
|
|
||||||
const grid = 5;
|
const grid = 5;
|
||||||
const on = new Array(25).fill(false);
|
const on = new Array(25).fill(false);
|
||||||
for (let col = 0; col < 3; col++) {
|
for (let col = 0; col < 3; col++) {
|
||||||
@@ -80,8 +167,7 @@ export function identicon(seed, size = 28) {
|
|||||||
on[row * 5 + (4 - col)] = !!bit;
|
on[row * 5 + (4 - col)] = !!bit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const inner = Math.round(size * 0.74);
|
const cell = 100 / grid; // viewBox 0..100 기준
|
||||||
const cell = inner / grid;
|
|
||||||
let rects = "";
|
let rects = "";
|
||||||
for (let r = 0; r < grid; r++) {
|
for (let r = 0; r < grid; r++) {
|
||||||
for (let c = 0; c < grid; c++) {
|
for (let c = 0; c < grid; c++) {
|
||||||
@@ -90,9 +176,37 @@ export function identicon(seed, size = 28) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 radius = Math.max(5, Math.round(size * 0.2));
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 ${inner} ${inner}" fill="${color}" style="display:block">${rects}</svg>`;
|
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 (
|
return (
|
||||||
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
|
`<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) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" }[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)}" />`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
201
src/server.js
201
src/server.js
@@ -5,8 +5,9 @@ import { fileURLToPath } from "url";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { toolsForUser } 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, langColor, langFromTags } from "./helpers.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));
|
||||||
@@ -30,15 +31,23 @@ 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.icon = icon;
|
||||||
res.locals.identicon = identicon;
|
res.locals.identicon = identicon;
|
||||||
|
res.locals.avatar = avatar;
|
||||||
|
res.locals.adminBadge = adminBadge;
|
||||||
res.locals.langColor = langColor;
|
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 를 붙인다.
|
// fetch(AJAX) 요청인지: app.js 가 X-Requested-With 를 붙인다.
|
||||||
const isAjax = (req) =>
|
const isAjax = (req) =>
|
||||||
req.get("X-Requested-With") === "fetch" || (req.get("Accept") || "").includes("application/json");
|
req.get("X-Requested-With") === "fetch" || (req.get("Accept") || "").includes("application/json");
|
||||||
@@ -58,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; // { sabun, name, email, roles, isAdmin }
|
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);
|
||||||
@@ -67,7 +79,70 @@ 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 대시보드 ---
|
// --- 대시보드 ---
|
||||||
@@ -101,20 +176,53 @@ app.post("/projects", requireAuth, async (req, res) => {
|
|||||||
.map((t) => t.trim().replace(/^#/, ""))
|
.map((t) => t.trim().replace(/^#/, ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 5);
|
.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 || "").trim(),
|
description: (description || "").trim(),
|
||||||
repoUrl: (repo_url || "").trim(),
|
repoUrl,
|
||||||
appUrl: (app_url || "").trim(),
|
appUrl: (app_url || "").trim(),
|
||||||
tags: tagArr.join(", "),
|
tags: tagArr.join(", "),
|
||||||
isPublic: req.body.is_public !== "0",
|
isPublic: req.body.is_public !== "0",
|
||||||
lang: langFromTags(tagArr),
|
lang,
|
||||||
});
|
});
|
||||||
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
|
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
|
||||||
res.redirect("/?toast=" + encodeURIComponent(msg));
|
res.redirect("/?toast=" + encodeURIComponent(msg));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 프로젝트 수정 (본인) --- 등록과 동일한 필드 파싱. 소유자 조건은 db.updateProject 에서 강제.
|
||||||
|
app.post("/projects/:id/edit", requireAuth, async (req, res) => {
|
||||||
|
const { title, description, repo_url, app_url, tags } = req.body;
|
||||||
|
const back = safeBack(req.body.return_to, "/");
|
||||||
|
const sep = back.includes("?") ? "&" : "?";
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return res.redirect(back + sep + "toast=" + encodeURIComponent("제목을 입력해주세요"));
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
|
// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
|
||||||
app.get("/projects/:id", requireAuth, async (req, res) => {
|
app.get("/projects/:id", requireAuth, async (req, res) => {
|
||||||
const me = req.session.user.sabun;
|
const me = req.session.user.sabun;
|
||||||
@@ -123,14 +231,24 @@ app.get("/projects/:id", requireAuth, async (req, res) => {
|
|||||||
// 비공개는 본인만 열람
|
// 비공개는 본인만 열람
|
||||||
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
|
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
|
||||||
const comments = await db.listComments(project.id);
|
const comments = await db.listComments(project.id);
|
||||||
res.render("project", { project, comments, starred: project.starred });
|
// 뒤로가기 대상: ?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) {
|
async function reactionHandler(kind, req, res) {
|
||||||
|
const me = req.session.user.sabun;
|
||||||
const result = await (kind === "heart"
|
const result = await (kind === "heart"
|
||||||
? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun })
|
? db.toggleHeart({ projectId: req.params.id, sabun: me })
|
||||||
: db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }));
|
: 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);
|
if (isAjax(req)) return res.json(result);
|
||||||
res.redirect(safeBack(req.get("Referer"), "/"));
|
res.redirect(safeBack(req.get("Referer"), "/"));
|
||||||
}
|
}
|
||||||
@@ -142,7 +260,17 @@ app.post("/projects/:id/visibility", requireAuth, async (req, res) => {
|
|||||||
const isPublic = req.body.is_public === "1";
|
const isPublic = req.body.is_public === "1";
|
||||||
await db.setVisibility({ id: req.params.id, sabun: req.session.user.sabun, isPublic });
|
await db.setVisibility({ id: req.params.id, sabun: req.session.user.sabun, isPublic });
|
||||||
const msg = isPublic ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
|
const msg = isPublic ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
|
||||||
res.redirect("/?toast=" + encodeURIComponent(msg));
|
// 모달에서 전환하면 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 프로젝트 삭제 (본인) ---
|
// --- 프로젝트 삭제 (본인) ---
|
||||||
@@ -157,12 +285,14 @@ app.post("/projects/:id/comments", requireAuth, async (req, res) => {
|
|||||||
const body = (req.body.body || "").trim();
|
const body = (req.body.body || "").trim();
|
||||||
if (!body) return res.redirect(back);
|
if (!body) return res.redirect(back);
|
||||||
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
|
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
|
||||||
await db.addComment({
|
const created = await db.addComment({
|
||||||
projectId: req.params.id,
|
projectId: req.params.id,
|
||||||
author: req.session.user.sabun,
|
author: req.session.user.sabun,
|
||||||
body,
|
body,
|
||||||
parentId: parentId || null,
|
parentId: parentId || null,
|
||||||
});
|
});
|
||||||
|
// 댓글이 실제로 등록됐으면 프로젝트 주인에게 알림(본인 댓글/중복은 db 에서 걸러짐).
|
||||||
|
if (created) await db.notifyProjectOwner({ projectId: req.params.id, actor: req.session.user.sabun, kind: "comment" });
|
||||||
// 이스터에그 신호
|
// 이스터에그 신호
|
||||||
let egg = "";
|
let egg = "";
|
||||||
if (/\blgtm\b/i.test(body)) egg = "lgtm";
|
if (/\blgtm\b/i.test(body)) egg = "lgtm";
|
||||||
@@ -177,6 +307,53 @@ app.post("/comments/:id/delete", requireAuth, async (req, res) => {
|
|||||||
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
|
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 ? "공지가 삭제되었습니다" : "공지를 찾을 수 없습니다"));
|
||||||
|
});
|
||||||
|
|
||||||
// --- 부팅 ---
|
// --- 부팅 ---
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
15
src/sites.js
15
src/sites.js
@@ -5,19 +5,24 @@
|
|||||||
// 포털 로그인 후 이 링크를 누르면 바로 들어간다.
|
// 포털 로그인 후 이 링크를 누르면 바로 들어간다.
|
||||||
// - 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)
|
||||||
|
// - Coolify: /auth/keycloak/redirect (Laravel Socialite, keycloak authorize 로 302)
|
||||||
// SSO 미지원(Kubero/Harbor 등)은 기본 URL 유지.
|
// SSO 미지원(Kubero/Harbor 등)은 기본 URL 유지.
|
||||||
//
|
//
|
||||||
// adminOnly: true 인 항목은 관리자(role=admin)에게만 렌더(숨김, disabled 아님).
|
// adminOnly: true 인 항목은 관리자(role=admin)에게만 렌더(숨김, disabled 아님).
|
||||||
// icon: 이모지(폐쇄망에서도 외부 의존 없이 표시). desc: 카드 한 줄 설명.
|
// icon: 이모지(폐쇄망에서도 외부 의존 없이 표시). desc: 카드 한 줄 설명. tip: 호버 시 뜨는 상세 설명(title 속성).
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
export const sites = [
|
export const sites = [
|
||||||
// --- 개발 핵심 (모두에게) ---
|
// --- 개발 핵심 (모두에게) ---
|
||||||
{ key: "coder", name: "Coder", desc: "클라우드 IDE", url: config.tools.coder, icon: "💻", sso: true },
|
{ 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 저장소", url: config.tools.gitea, icon: "🍵", sso: true },
|
{ key: "gitea", name: "Gitea", desc: "Git 저장소", tip: "소스 코드 저장 및 버전 관리.\n다른 사람과 Git으로 협업할 수 있습니다.", url: config.tools.gitea, icon: "🍵", sso: true },
|
||||||
{ key: "kubero", name: "Kubero", desc: "배포 (PaaS)", url: config.tools.kubero, icon: "🚀" },
|
// Kubero: Keycloak SSO 로 로그인 가능(단, 랜딩에서 "OAuth 로 로그인"을 한 번 더 눌러야 함). Kubernetes 기반.
|
||||||
{ key: "harbor", name: "Harbor", desc: "컨테이너 레지스트리", url: config.tools.harbor, icon: "📦" },
|
{ key: "kubero", name: "Kubero", desc: "배포 (PaaS)", tip: "Kubernetes 기반 배포 도구입니다.\nplayground 파이프라인에 앱을 추가해 서버에 올립니다.", url: config.tools.kubero, icon: "🚀", sso: true },
|
||||||
|
// Coolify: Kubero 와 같은 배포 PaaS. Docker 기반. Keycloak SSO 지원 →
|
||||||
|
// /auth/keycloak/redirect (로그인 시작, code 없으면 keycloak authorize 로 302). callback 은 /auth/keycloak/callback.
|
||||||
|
{ key: "coolify", name: "Coolify", desc: "배포 (PaaS)", tip: "Docker 기반 배포 도구입니다.\naidev 프로젝트에 앱을 추가해 서버에 올립니다.", url: config.tools.coolify, icon: "⛵", sso: true },
|
||||||
// --- 인프라/운영 (관리자 전용) ---
|
// --- 인프라/운영 (관리자 전용) ---
|
||||||
|
{ 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: "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: "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: "keycloak", name: "Keycloak", desc: "계정 / SSO 관리", url: config.tools.keycloak, icon: "🔑", adminOnly: true },
|
||||||
|
|||||||
12
views/_footer.ejs
Normal file
12
views/_footer.ejs
Normal 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>
|
||||||
@@ -4,7 +4,16 @@
|
|||||||
<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>
|
<script>
|
||||||
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
|
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
|
||||||
(function () {
|
(function () {
|
||||||
@@ -19,7 +28,7 @@
|
|||||||
<header class="hdr">
|
<header class="hdr">
|
||||||
<div class="hdr-inner">
|
<div class="hdr-inner">
|
||||||
<a class="brand" href="/" id="brandLink">
|
<a class="brand" href="/" id="brandLink">
|
||||||
<span class="brand-mark"><%- icon("code", { size: 22 }) %></span>
|
<span class="brand-mark"><%- icon("codeslash", { size: 22 }) %></span>
|
||||||
<span class="brand-name"><%= brand %></span>
|
<span class="brand-name"><%= brand %></span>
|
||||||
<span class="brand-sub">사내 개발 포털</span>
|
<span class="brand-sub">사내 개발 포털</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -27,7 +36,7 @@
|
|||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
<div class="hdr-search">
|
<div class="hdr-search">
|
||||||
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
|
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
|
||||||
<input type="search" id="feedSearch" placeholder="프로젝트 검색…" autocomplete="off" />
|
<input type="search" id="feedSearch" placeholder="<%= typeof searchPlaceholder !== 'undefined' && searchPlaceholder ? searchPlaceholder : '공유 프로젝트 검색…' %>" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hdr-right">
|
<div class="hdr-right">
|
||||||
@@ -36,6 +45,33 @@
|
|||||||
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
||||||
</button>
|
</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) { %>
|
<% if (user.isAdmin) { %>
|
||||||
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -43,7 +79,7 @@
|
|||||||
<div class="usermenu">
|
<div class="usermenu">
|
||||||
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="who"><%= user.sabun %></span>
|
<span class="who"><%= user.sabun %></span>
|
||||||
<%- identicon(user.sabun, 26) %>
|
<%- avatar(user.sabun, 26) %>
|
||||||
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
|
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="usermenu-pop" id="userMenuPop" hidden>
|
<div class="usermenu-pop" id="userMenuPop" hidden>
|
||||||
@@ -52,6 +88,8 @@
|
|||||||
<div class="usermenu-sabun"><%= user.sabun %></div>
|
<div class="usermenu-sabun"><%= user.sabun %></div>
|
||||||
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
||||||
</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>
|
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,23 @@
|
|||||||
<div class="modal-title-line">
|
<div class="modal-title-line">
|
||||||
<span class="modal-repoico"><%- icon("repo", { size: 18 }) %></span>
|
<span class="modal-repoico"><%- icon("repo", { size: 18 }) %></span>
|
||||||
<h3 class="modal-title"><%= p.title %></h3>
|
<h3 class="modal-title"><%= p.title %></h3>
|
||||||
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
|
<% 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>
|
||||||
<div class="modal-meta">
|
<div class="modal-meta">
|
||||||
<%- identicon(p.owner_sabun, 24) %>
|
<a class="meta-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
|
||||||
<span class="sabun"><%= p.owner_sabun %></span>
|
<%- avatar(p.owner_sabun, 24) %>
|
||||||
|
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
|
||||||
|
</a>
|
||||||
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
|
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
|
||||||
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,13 +44,17 @@
|
|||||||
<% if (modal) { %>
|
<% if (modal) { %>
|
||||||
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
|
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a class="back-link" href="/">← 대시보드</a>
|
<a class="back-link" href="<%= typeof back !== 'undefined' && back ? back.href : '/' %>">← <%= typeof back !== 'undefined' && back ? back.label : '대시보드' %></a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% if (pdTags.length) { %>
|
<% if (pdTags.length) { %>
|
||||||
<div class="modal-tags"><% pdTags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
<div class="modal-tags"><% pdTags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<p class="modal-desc"><%= p.description || "설명 없음" %></p>
|
<% 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">
|
<div class="modal-actions">
|
||||||
<button type="button" class="modal-action heart<%= p.hearted ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="heart">
|
<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 class="ico ico-on"><%- icon("heartFill", { size: 15 }) %></span><span class="ico ico-off"><%- icon("heart", { size: 15 }) %></span>
|
||||||
@@ -55,10 +71,22 @@
|
|||||||
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
|
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (p.owner_sabun === me) { %>
|
<% if (p.owner_sabun === me) { %>
|
||||||
<form method="post" action="/projects/<%= p.id %>/delete" style="margin-left:auto">
|
<div class="modal-actions-owner">
|
||||||
<input type="hidden" name="return_to" value="/" />
|
<button type="button" class="comment-link" data-open-edit style="font-size:12.5px"
|
||||||
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px">프로젝트 삭제</button>
|
data-id="<%= p.id %>"
|
||||||
</form>
|
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>
|
</div>
|
||||||
@@ -69,16 +97,16 @@
|
|||||||
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
|
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
|
||||||
<div class="comment-item">
|
<div class="comment-item">
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<%- identicon(c.author_sabun, 28) %>
|
<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-main">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
<span class="comment-author"><%= c.author_sabun %></span>
|
<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>
|
<span class="comment-time"><%= reltime(c.created_at) %></span>
|
||||||
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text"><%= c.body %></div>
|
<div class="comment-text"><%= c.body %></div>
|
||||||
<div class="comment-actions">
|
<div class="comment-actions">
|
||||||
<button type="button" class="comment-link" data-reply-to="<%= c.id %>" data-reply-name="<%= c.author_sabun %>"><%- icon("reply", { size: 13 }) %>답글</button>
|
<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) { %>
|
<% if (c.author_sabun === me) { %>
|
||||||
<form method="post" action="/comments/<%= c.id %>/delete" data-stop style="display:inline">
|
<form method="post" action="/comments/<%= c.id %>/delete" data-stop style="display:inline">
|
||||||
<input type="hidden" name="return_to" value="<%= returnTo %>" />
|
<input type="hidden" name="return_to" value="<%= returnTo %>" />
|
||||||
@@ -92,10 +120,10 @@
|
|||||||
<div class="replies">
|
<div class="replies">
|
||||||
<% reps.forEach(function (r) { %>
|
<% reps.forEach(function (r) { %>
|
||||||
<div class="reply">
|
<div class="reply">
|
||||||
<%- identicon(r.author_sabun, 24) %>
|
<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-main">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
<span class="comment-author"><%= r.author_sabun %></span>
|
<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>
|
<span class="comment-time"><%= reltime(r.created_at) %></span>
|
||||||
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +153,7 @@
|
|||||||
<button type="button" data-cancel-reply>취소</button>
|
<button type="button" data-cancel-reply>취소</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
<%- identicon(me, 28) %>
|
<%- avatar(me, 28) %>
|
||||||
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
|
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
|
||||||
<button type="submit" class="submit-btn">등록</button>
|
<button type="submit" class="submit-btn">등록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
66
views/_project_form_modal.ejs
Normal file
66
views/_project_form_modal.ejs
Normal 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="무엇을 하는 프로젝트인가요? ## 예시 - Markdown 문법을 그대로 쓸 수 있어요 - 위 저장소 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
75
views/announcements.ejs
Normal 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>
|
||||||
@@ -26,8 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tools-grid">
|
<div class="tools-grid">
|
||||||
<% tools.forEach(function (t) { %>
|
<% tools.forEach(function (t) { %>
|
||||||
<a class="tool-card<%= t.adminOnly ? " admin" : "" %>" href="<%= t.url %>" target="_blank" rel="noopener">
|
<a class="tool-card<%= t.adminOnly ? " admin" : "" %>" href="<%= t.url %>" target="_blank" rel="noopener"<% if (t.tip) { %> title="<%= t.tip %>"<% } %>>
|
||||||
<% if (t.sso) { %><span class="sso-badge">SSO</span><% } %>
|
|
||||||
<span class="tool-icon"><%= t.icon %></span>
|
<span class="tool-icon"><%= t.icon %></span>
|
||||||
<span class="tool-name"><%= t.name %></span>
|
<span class="tool-name"><%= t.name %></span>
|
||||||
<span class="tool-desc"><%= t.desc %></span>
|
<span class="tool-desc"><%= t.desc %></span>
|
||||||
@@ -69,7 +68,7 @@
|
|||||||
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
|
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pcard-desc"><%= p.description || "설명 없음" %></div>
|
<div class="pcard-desc"><%= mdPlain(p.description) || "설명 없음" %></div>
|
||||||
<% if (tags.length) { %>
|
<% if (tags.length) { %>
|
||||||
<div class="pcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
<div class="pcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -77,8 +76,8 @@
|
|||||||
<% if (p.is_public) { %>
|
<% if (p.is_public) { %>
|
||||||
<div class="pcard-stats">
|
<div class="pcard-stats">
|
||||||
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
<% 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-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"><%= p.star_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>
|
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/projects/<%= p.id %>/visibility" data-stop style="display:inline">
|
<form method="post" action="/projects/<%= p.id %>/visibility" data-stop style="display:inline">
|
||||||
@@ -153,16 +152,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fcard-desc"><%= p.description || "설명 없음" %></div>
|
<div class="fcard-desc"><%= mdPlain(p.description) || "설명 없음" %></div>
|
||||||
<% if (tags.length) { %>
|
<% if (tags.length) { %>
|
||||||
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="fcard-foot">
|
<div class="fcard-foot">
|
||||||
<div class="fcard-author">
|
<a class="fcard-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
|
||||||
<%- identicon(p.owner_sabun, 22) %>
|
<%- avatar(p.owner_sabun, 22) %>
|
||||||
<span class="sabun"><%= p.owner_sabun %></span>
|
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
|
||||||
<span class="date">· <%= fdate(p.created_at) %></span>
|
<span class="date">· <%= fdate(p.created_at) %></span>
|
||||||
</div>
|
</a>
|
||||||
<div class="fcard-meta">
|
<div class="fcard-meta">
|
||||||
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
<% 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>
|
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
|
||||||
@@ -177,12 +176,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<%- include("_footer") %>
|
||||||
<div class="footer-inner">
|
|
||||||
<div class="footer-brand"><span><%- icon("code", { size: 14 }) %></span><%= brand %> · 사내 개발 포털</div>
|
|
||||||
<div class="footer-tech">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
|
<!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
|
||||||
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
|
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
|
||||||
@@ -194,53 +188,9 @@
|
|||||||
<% }); %>
|
<% }); %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 새 프로젝트 등록 모달 -->
|
<!-- 새 프로젝트 등록/수정 공용 모달 -->
|
||||||
<div class="modal-overlay" id="shareModal" hidden>
|
<%- include("_project_form_modal") %>
|
||||||
<div class="share-modal">
|
|
||||||
<div class="share-head">
|
|
||||||
<h3>새 프로젝트 등록</h3>
|
|
||||||
<button type="button" class="modal-close" data-close-share title="닫기"><%- icon("x", { size: 16 }) %></button>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/projects">
|
|
||||||
<div class="share-body">
|
|
||||||
<label>
|
|
||||||
<span class="share-label">제목 <span class="req">*</span></span>
|
|
||||||
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span class="share-label">한 줄 설명</span>
|
|
||||||
<input class="share-input" name="description" placeholder="무엇을 하는 프로젝트인가요?" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span class="share-label">태그 <span class="hint">쉼표로 구분 · 최대 5개</span></span>
|
|
||||||
<input class="share-input" name="tags" placeholder="infra, react, poc" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
|
|
||||||
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span class="share-label">배포 URL <span class="hint">선택 · 없으면 바로가기 숨김</span></span>
|
|
||||||
<input class="share-input mono" name="app_url" placeholder="https://myapp.apps.bokdev.in" />
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span class="share-label">공개 범위</span>
|
|
||||||
<div class="vis-toggle">
|
|
||||||
<button type="button" class="vis-opt on-public" data-vis="public"><%- icon("globe", { size: 12 }) %>공개</button>
|
|
||||||
<button type="button" class="vis-opt" data-vis="private"><%- icon("lock", { size: 12 }) %>비공개</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="is_public" value="1" />
|
|
||||||
<div class="share-vishint">피드에 공개되어 동료가 볼 수 있어요</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-foot-actions">
|
|
||||||
<button type="button" class="btn" data-close-share>취소</button>
|
|
||||||
<button type="submit" class="btn btn-success">등록</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/public/app.js"></script>
|
<script type="module" src="/public/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
127
views/profile.ejs
Normal file
127
views/profile.ejs
Normal 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>
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="detail-wrap">
|
<div class="detail-wrap">
|
||||||
<div class="modal" style="margin:0 auto;animation:none">
|
<div class="modal" style="margin:0 auto;animation:none">
|
||||||
<%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false }) %>
|
<%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false, back: back }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="/public/app.js"></script>
|
|
||||||
|
<!-- 프로젝트 수정 모달 (상세의 '수정' 버튼이 채워서 연다) -->
|
||||||
|
<%- include("_project_form_modal") %>
|
||||||
|
|
||||||
|
<script type="module" src="/public/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user