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