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