diff --git a/public/css/components.css b/public/css/components.css index 0dffbf9..792b037 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -191,3 +191,28 @@ .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; } +.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; } diff --git a/public/css/layout.css b/public/css/layout.css index 7f9a2a3..98e1794 100644 --- a/public/css/layout.css +++ b/public/css/layout.css @@ -64,6 +64,40 @@ html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; } .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; } diff --git a/public/js/announcements.js b/public/js/announcements.js new file mode 100644 index 0000000..cf91529 --- /dev/null +++ b/public/js/announcements.js @@ -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; + } + }); +} diff --git a/public/js/main.js b/public/js/main.js index 49398c0..aa6c8ff 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -6,6 +6,8 @@ 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"; // ์„œ๋ฒ„๊ฐ€ ๋กœ ์ „๋‹ฌํ•œ 1ํšŒ์„ฑ ์‹ ํ˜ธ ์ฒ˜๋ฆฌ function initSignals() { @@ -50,6 +52,8 @@ function boot() { initComments(); initFeed(); initMyFilter(); + initNotifications(); + initAnnouncements(); initSignals(); consoleArt(); } diff --git a/public/js/notifications.js b/public/js/notifications.js new file mode 100644 index 0000000..4d33815 --- /dev/null +++ b/public/js/notifications.js @@ -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); +} diff --git a/schema.sql b/schema.sql index bfcf46a..94f32b6 100644 --- a/schema.sql +++ b/schema.sql @@ -64,3 +64,31 @@ CREATE TABLE IF NOT EXISTS stars ( created_at timestamptz NOT NULL DEFAULT now(), 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); diff --git a/src/auth.js b/src/auth.js index befbc06..11239ed 100644 --- a/src/auth.js +++ b/src/auth.js @@ -82,3 +82,12 @@ export function requireAuth(req, res, next) { // OIDC ๋ฏธ์ค€๋น„(discovery ์‹คํŒจ)๋ฉด ์•ˆ๋‚ด ํŽ˜์ด์ง€๋กœ ํด๋ฐฑ 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("๊ด€๋ฆฌ์ž๋งŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")); +} diff --git a/src/db.js b/src/db.js index 9b76041..89a1321 100644 --- a/src/db.js +++ b/src/db.js @@ -200,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]); 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; +} diff --git a/src/helpers.js b/src/helpers.js index 1003ea8..d9ae775 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -29,6 +29,10 @@ const OCTICONS = { trash: '', book: '', pencil: '', + // ์ข…(์•Œ๋ฆผ) โ€” Octicon bell-16 + bell: '', + // ํ™•์„ฑ๊ธฐ(๊ณต์ง€) โ€” Octicon megaphone-16 + megaphone: '', }; // ์ธ๋ผ์ธ SVG ์•„์ด์ฝ˜ ๋ฌธ์ž์—ด. fill ๊ธฐ๋ณธ์€ currentColor(๋ถ€๋ชจ ์ƒ‰ ์ƒ์†). diff --git a/src/server.js b/src/server.js index 39d627b..c0bd15b 100644 --- a/src/server.js +++ b/src/server.js @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import path from "path"; import { config } from "./config.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, adminBadge, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js"; import { fetchMainLang, fetchReadme } from "./gitea.js"; import * as db from "./db.js"; @@ -243,9 +243,12 @@ app.get("/projects/:id", requireAuth, async (req, res) => { // --- ๋ฐ˜์‘: โค๏ธ ์ข‹์•„์š” / โญ ์ฆ๊ฒจ์ฐพ๊ธฐ --- async function reactionHandler(kind, req, res) { + const me = req.session.user.sabun; const result = await (kind === "heart" - ? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun }) - : db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun })); + ? db.toggleHeart({ projectId: req.params.id, sabun: me }) + : db.toggleStar({ projectId: req.params.id, sabun: me })); + // ์ƒˆ๋กœ ์ผ  ๊ฒฝ์šฐ์—๋งŒ ํ”„๋กœ์ ํŠธ ์ฃผ์ธ์—๊ฒŒ ์•Œ๋ฆผ(๋ณธ์ธ ํ”„๋กœ์ ํŠธ/์ค‘๋ณต์€ db ์—์„œ ๊ฑธ๋Ÿฌ์ง). + if (result.on) await db.notifyProjectOwner({ projectId: req.params.id, actor: me, kind }); if (isAjax(req)) return res.json(result); res.redirect(safeBack(req.get("Referer"), "/")); } @@ -282,12 +285,14 @@ app.post("/projects/:id/comments", requireAuth, async (req, res) => { const body = (req.body.body || "").trim(); if (!body) return res.redirect(back); const parentId = req.body.parent_id ? Number(req.body.parent_id) : null; - await db.addComment({ + const created = await db.addComment({ projectId: req.params.id, author: req.session.user.sabun, body, parentId: parentId || null, }); + // ๋Œ“๊ธ€์ด ์‹ค์ œ๋กœ ๋“ฑ๋ก๋์œผ๋ฉด ํ”„๋กœ์ ํŠธ ์ฃผ์ธ์—๊ฒŒ ์•Œ๋ฆผ(๋ณธ์ธ ๋Œ“๊ธ€/์ค‘๋ณต์€ db ์—์„œ ๊ฑธ๋Ÿฌ์ง). + if (created) await db.notifyProjectOwner({ projectId: req.params.id, actor: req.session.user.sabun, kind: "comment" }); // ์ด์Šคํ„ฐ์—๊ทธ ์‹ ํ˜ธ let egg = ""; if (/\blgtm\b/i.test(body)) egg = "lgtm"; @@ -302,6 +307,53 @@ app.post("/comments/:id/delete", requireAuth, async (req, res) => { 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 () => { try { diff --git a/views/_header.ejs b/views/_header.ejs index d7597d2..8bca4b0 100644 --- a/views/_header.ejs +++ b/views/_header.ejs @@ -45,6 +45,10 @@ <%- icon("sun", { size: 16 }) %> + + <%- icon("megaphone", { size: 15 }) %>๊ณต์ง€ + + <%- icon("book", { size: 15 }) %>๋งค๋‰ด์–ผ @@ -53,6 +57,24 @@ <%- icon("lock", { size: 11 }) %>๊ด€๋ฆฌ์ž <% } %> + +
+ + +
+
<%- icon("repo", { size: 15 }) %>๋‚ด ํ”„๋กœํ•„ + <%- icon("megaphone", { size: 15 }) %>๊ณต์ง€์‚ฌํ•ญ <%- icon("signout", { size: 15 }) %>๋กœ๊ทธ์•„์›ƒ diff --git a/views/announcements.ejs b/views/announcements.ejs new file mode 100644 index 0000000..ea59751 --- /dev/null +++ b/views/announcements.ejs @@ -0,0 +1,76 @@ +<%- 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; } } +%> + +
+
+
+
+

๊ณต์ง€์‚ฌํ•ญ

+ ๊ด€๋ฆฌ์ž ๊ณต์ง€ ๋ชจ์•„๋ณด๊ธฐ +
+
+ + <% if (user.isAdmin) { %> + +
+ + +
+ +
+
+ <% } %> + + <% if (!announcements.length) { %> +
์•„์ง ๋“ฑ๋ก๋œ ๊ณต์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
+ <% } %> + +
+ <% announcements.forEach(function (a) { %> +
+
+
+

<%= a.title %>

+ <% if (user.isAdmin) { %> +
+ +
+ +
+
+ <% } %> +
+
+ <%= a.author_name %> ยท <%= fdate(a.created_at) %><% if (edited(a)) { %> ยท ์ˆ˜์ •๋จ<% } %> +
+ <% if (a.body && a.body.trim()) { %> +
<%- renderMarkdown(a.body) %>
+ <% } %> +
+ + <% if (user.isAdmin) { %> + + + <% } %> +
+ <% }); %> +
+
+
+ +<%- include("_footer") %> + + + +