Compare commits
16 Commits
1f5179cd04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 355798a506 | |||
| a199114453 | |||
| 2ca23a00b9 | |||
| 5c76786ada | |||
| 2357dbd152 | |||
| d95f968800 | |||
| 75685e41da | |||
| 56430b7701 | |||
| ef3d495878 | |||
| 84ce8567e7 | |||
| 0ed41849ca | |||
| b779ed882a | |||
| ad693304cf | |||
| 26cc086414 | |||
| 44cc2af813 | |||
| 44f7cb4b8b |
@@ -6,7 +6,7 @@
|
|||||||
🔗 접속: **https://portal.bokdev.in**
|
🔗 접속: **https://portal.bokdev.in**
|
||||||
📖 자세한 사용 매뉴얼: **https://gitea.bokdev.in/playground/MANUAL** (포털 헤더의 `매뉴얼` 버튼에서도 열 수 있어요)
|
📖 자세한 사용 매뉴얼: **https://gitea.bokdev.in/playground/MANUAL** (포털 헤더의 `매뉴얼` 버튼에서도 열 수 있어요)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 처음 오셨나요?
|
## 처음 오셨나요?
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
버그·건의는 이슈로 남겨 주세요 — 언제든 환영합니다.
|
버그·건의는 이슈로 남겨 주세요 — 언제든 환영합니다.
|
||||||
👉 https://gitea.bokdev.in/playground/ai-dev-portal/issues
|
👉 https://gitea.bokdev.in/playground/ai-dev-portal/issues
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 개발자용 (참고)
|
## 개발자용 (참고)
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,4 @@ svg { display: block; }
|
|||||||
@keyframes aidev-fade { from{opacity:0} to{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} }
|
@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 로 이동(로드 순서상 마지막이어야 실제 적용됨). */
|
||||||
@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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -157,9 +157,27 @@
|
|||||||
.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 { 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-avatar-edit:hover { filter: brightness(1.08); }
|
||||||
.profile-info { min-width: 0; }
|
.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-name { margin: 0; font-size: 24px; font-weight: 800; color: var(--fg); letter-spacing: -.01em; }
|
||||||
.profile-sabun { margin-top: 3px; font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
|
.profile-sabun { font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
|
||||||
.profile-metaline { margin-top: 8px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }
|
.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-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; }
|
||||||
@@ -173,3 +191,30 @@
|
|||||||
.crop-zoom { margin-top: 14px; display: flex; align-items: center; gap: 10px; color: var(--fg2); }
|
.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-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; }
|
.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; }
|
||||||
|
|||||||
@@ -64,6 +64,40 @@ html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
|
|||||||
.usermenu-item:hover { background: var(--panel); }
|
.usermenu-item:hover { background: var(--panel); }
|
||||||
.usermenu-ico { display: inline-flex; color: var(--fg2); }
|
.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; }
|
.main { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; }
|
||||||
.section { margin-bottom: 40px; }
|
.section { margin-bottom: 40px; }
|
||||||
|
|||||||
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; }
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { initReactions } from "./reactions.js";
|
|||||||
import { initModals, openDetail } from "./modals.js";
|
import { initModals, openDetail } from "./modals.js";
|
||||||
import { initComments } from "./comments.js";
|
import { initComments } from "./comments.js";
|
||||||
import { initFeed, initMyFilter } from "./feed.js";
|
import { initFeed, initMyFilter } from "./feed.js";
|
||||||
|
import { initNotifications } from "./notifications.js";
|
||||||
|
import { initAnnouncements } from "./announcements.js";
|
||||||
|
|
||||||
// 서버가 <body data-*> 로 전달한 1회성 신호 처리
|
// 서버가 <body data-*> 로 전달한 1회성 신호 처리
|
||||||
function initSignals() {
|
function initSignals() {
|
||||||
@@ -50,6 +52,8 @@ function boot() {
|
|||||||
initComments();
|
initComments();
|
||||||
initFeed();
|
initFeed();
|
||||||
initMyFilter();
|
initMyFilter();
|
||||||
|
initNotifications();
|
||||||
|
initAnnouncements();
|
||||||
initSignals();
|
initSignals();
|
||||||
consoleArt();
|
consoleArt();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,12 @@ export function openDetail(id) {
|
|||||||
// 주의: 열기 감지 셀렉터가 [data-open-id] 라서 같은 이름을 쓰면 모달 내부 클릭(닫기/삭제)이
|
// 주의: 열기 감지 셀렉터가 [data-open-id] 라서 같은 이름을 쓰면 모달 내부 클릭(닫기/삭제)이
|
||||||
// 다시 "열기"로 잡힌다. 마커는 반드시 다른 속성명(data-open-pid)으로 둔다.
|
// 다시 "열기"로 잡힌다. 마커는 반드시 다른 속성명(data-open-pid)으로 둔다.
|
||||||
overlay.setAttribute("data-open-pid", id);
|
overlay.setAttribute("data-open-pid", id);
|
||||||
|
// 스크롤 컨테이너(overflow-y:auto 인 .modal-overlay)를 맨 위로 되돌린다.
|
||||||
|
overlay.scrollTop = 0;
|
||||||
|
// 주의: 그냥 focus() 하면 브라우저가 하단 댓글창을 보이게 하려고 모달을 맨 아래로
|
||||||
|
// 스크롤한다 → README 가 길면 상세가 하단에 고정돼 열린다. preventScroll 로 막는다.
|
||||||
const ta = $(".comment-input", overlay);
|
const ta = $(".comment-input", overlay);
|
||||||
if (ta) ta.focus();
|
if (ta) ta.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
const overlay = $("#detailModal");
|
const overlay = $("#detailModal");
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -4,7 +4,53 @@ import { $, toast } from "./util.js";
|
|||||||
|
|
||||||
const OUT = 256; // 저장 이미지 한 변(px)
|
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() {
|
export function initProfile() {
|
||||||
|
initBio();
|
||||||
|
|
||||||
const editBtn = $("#avatarEditBtn");
|
const editBtn = $("#avatarEditBtn");
|
||||||
const modal = $("#cropModal");
|
const modal = $("#cropModal");
|
||||||
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음
|
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음
|
||||||
|
|||||||
32
schema.sql
32
schema.sql
@@ -13,6 +13,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar bytea;
|
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_mime text;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
|
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 (
|
||||||
@@ -60,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);
|
||||||
|
|||||||
@@ -82,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("관리자만 접근할 수 있습니다."));
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const config = {
|
|||||||
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",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 포털 표시 이름 (변경 가능)
|
// 포털 표시 이름 (변경 가능)
|
||||||
|
|||||||
128
src/db.js
128
src/db.js
@@ -6,14 +6,14 @@ 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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ export async function setAvatar({ 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 폴백.
|
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
|
||||||
export async function getAvatar(sabun) {
|
export async function getAvatar(sabun) {
|
||||||
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
|
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
|
||||||
@@ -34,7 +39,7 @@ export async function getAvatar(sabun) {
|
|||||||
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
||||||
export async function getUserProfile(sabun) {
|
export async function getUserProfile(sabun) {
|
||||||
const { rows } = await q(
|
const { rows } = await q(
|
||||||
`SELECT u.sabun, u.name, u.email, u.created_at,
|
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio, u.is_admin,
|
||||||
(u.avatar IS NOT NULL) AS has_avatar,
|
(u.avatar IS NOT NULL) AS has_avatar,
|
||||||
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
||||||
FROM users u WHERE u.sabun=$1`,
|
FROM users u WHERE u.sabun=$1`,
|
||||||
@@ -195,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const OCTICONS = {
|
|||||||
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"/>',
|
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"/>',
|
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(부모 색 상속).
|
||||||
@@ -42,6 +46,49 @@ 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 형식) 마크다운 렌더링.
|
// 프로젝트 설명(README 형식) 마크다운 렌더링.
|
||||||
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
|
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
|
||||||
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
|
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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, logoutUrl } from "./auth.js";
|
import { initOidc, loginRedirect, handleCallback, requireAuth, requireAdmin, oidcReady, logoutUrl } from "./auth.js";
|
||||||
import { icon, identicon, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
|
import { icon, identicon, identiconSvg, avatar, adminBadge, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
|
||||||
import { fetchMainLang, fetchReadme } from "./gitea.js";
|
import { fetchMainLang, fetchReadme } from "./gitea.js";
|
||||||
import * as db from "./db.js";
|
import * as db from "./db.js";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ app.use((req, res, next) => {
|
|||||||
res.locals.icon = icon;
|
res.locals.icon = icon;
|
||||||
res.locals.identicon = identicon;
|
res.locals.identicon = identicon;
|
||||||
res.locals.avatar = avatar;
|
res.locals.avatar = avatar;
|
||||||
|
res.locals.adminBadge = adminBadge;
|
||||||
res.locals.langColor = langColor;
|
res.locals.langColor = langColor;
|
||||||
res.locals.renderMarkdown = renderMarkdown;
|
res.locals.renderMarkdown = renderMarkdown;
|
||||||
res.locals.mdPlain = mdPlain;
|
res.locals.mdPlain = mdPlain;
|
||||||
@@ -122,6 +123,16 @@ app.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- 한줄 소개 저장(본인) ---
|
||||||
|
// 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) => {
|
app.get("/u/:sabun", requireAuth, async (req, res) => {
|
||||||
const me = req.session.user.sabun;
|
const me = req.session.user.sabun;
|
||||||
@@ -232,9 +243,12 @@ app.get("/projects/:id", requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
|
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
|
||||||
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"), "/"));
|
||||||
}
|
}
|
||||||
@@ -271,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";
|
||||||
@@ -291,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 {
|
||||||
|
|||||||
14
src/sites.js
14
src/sites.js
@@ -9,18 +9,18 @@
|
|||||||
// 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 },
|
||||||
// Kubero: Keycloak SSO 로 로그인 가능(단, 랜딩에서 "OAuth 로 로그인"을 한 번 더 눌러야 함).
|
// Kubero: Keycloak SSO 로 로그인 가능(단, 랜딩에서 "OAuth 로 로그인"을 한 번 더 눌러야 함). Kubernetes 기반.
|
||||||
{ key: "kubero", name: "Kubero", desc: "배포 (PaaS)", url: config.tools.kubero, icon: "🚀", sso: true },
|
{ key: "kubero", name: "Kubero", desc: "배포 (PaaS)", tip: "Kubernetes 기반 배포 도구입니다.\nplayground 파이프라인에 앱을 추가해 서버에 올립니다.", url: config.tools.kubero, icon: "🚀", sso: true },
|
||||||
// Coolify: Kubero 와 같은 배포 PaaS. Keycloak SSO 지원 →
|
// Coolify: Kubero 와 같은 배포 PaaS. Docker 기반. Keycloak SSO 지원 →
|
||||||
// /auth/keycloak/redirect (로그인 시작, code 없으면 keycloak authorize 로 302). callback 은 /auth/keycloak/callback.
|
// /auth/keycloak/redirect (로그인 시작, code 없으면 keycloak authorize 로 302). callback 은 /auth/keycloak/callback.
|
||||||
{ key: "coolify", name: "Coolify", desc: "배포 (PaaS)", url: config.tools.coolify, icon: "⛵", sso: true },
|
{ 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: "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 },
|
||||||
|
|||||||
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>
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<link rel="stylesheet" href="/public/css/layout.css" />
|
<link rel="stylesheet" href="/public/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/public/css/components.css" />
|
<link rel="stylesheet" href="/public/css/components.css" />
|
||||||
<link rel="stylesheet" href="/public/css/modal.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 () {
|
||||||
@@ -43,10 +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="포털 사용 매뉴얼">
|
<a class="hdr-link" href="<%= manualUrl %>" target="_blank" rel="noopener" title="포털 사용 매뉴얼">
|
||||||
<%- icon("book", { size: 15 }) %><span>매뉴얼</span>
|
<%- icon("book", { size: 15 }) %><span>매뉴얼</span>
|
||||||
</a>
|
</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>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -64,6 +89,7 @@
|
|||||||
<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="/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>
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
|
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span class="share-label">저장소 URL <span class="hint">선택 · 먼저 넣으면 README·언어를 자동으로 가져와요</span></span>
|
<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" />
|
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
||||||
<small class="share-err" id="repoErr" hidden>git URL 을 입력하면 README 를 가져옵니다</small>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span class="share-label share-label-row">
|
<span class="share-label share-label-row">
|
||||||
|
|||||||
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,7 +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 %>"<% } %>>
|
||||||
<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>
|
||||||
@@ -176,15 +176,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<%- include("_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">이슈</a>에 제보바랍니다!</span>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -17,12 +17,31 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<h1 class="profile-name"><%= profile.name %></h1>
|
<div class="profile-idline">
|
||||||
<div class="profile-sabun"><%= profile.sabun %></div>
|
<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">
|
<div class="profile-metaline">
|
||||||
<span><%= profile.project_count %>개 프로젝트</span>
|
<span><%= profile.project_count %>개 프로젝트</span>
|
||||||
<span>· 가입 <%= fdate(profile.created_at) %></span>
|
<span>· 가입 <%= fdate(profile.created_at) %></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -72,6 +91,8 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<%- include("_footer") %>
|
||||||
|
|
||||||
<% if (isSelf) { %>
|
<% if (isSelf) { %>
|
||||||
<!-- 프로필 사진 크롭 모달 -->
|
<!-- 프로필 사진 크롭 모달 -->
|
||||||
<div class="modal-overlay" id="cropModal" hidden>
|
<div class="modal-overlay" id="cropModal" hidden>
|
||||||
@@ -89,7 +110,7 @@
|
|||||||
<span><%- icon("search", { size: 13 }) %></span>
|
<span><%- icon("search", { size: 13 }) %></span>
|
||||||
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
|
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
|
||||||
</label>
|
</label>
|
||||||
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.</p>
|
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.<br>변경한 사진이 모든 화면에 반영되기까지 최대 몇 분 걸릴 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot-actions">
|
<div class="modal-foot-actions">
|
||||||
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
|
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
|
||||||
|
|||||||
Reference in New Issue
Block a user