feat(ui): 디자인 시스템 전면 개편 — 헤더·CSS·아이콘 헬퍼·클라이언트 JS
- style.css: CSS 변수(라이트/다크) 단일 스타일시트로 재작성 - _header.ejs: 스티키 다크 헤더, 실제 role 배지, FOUC 방지 테마 초기화 - helpers.js: icon(Octicon)·identicon(SVG 아바타)·langColor·langFromTags - app.js: 테마·메뉴·모달·검색/정렬/필터·반응 fetch·댓글 답글·이스터에그 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
385
public/app.js
Normal file
385
public/app.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/* 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();
|
||||
});
|
||||
})();
|
||||
519
public/style.css
519
public/style.css
@@ -1,136 +1,399 @@
|
||||
/* GitHub 감성 (Primer 팔레트 근사). 라이트 테마. */
|
||||
:root {
|
||||
--canvas: #ffffff;
|
||||
--canvas-subtle: #f6f8fa;
|
||||
--border: #d0d7de;
|
||||
--border-muted: #d8dee4;
|
||||
--fg: #1f2328;
|
||||
--fg-muted: #656d76;
|
||||
--accent: #0969da;
|
||||
--accent-emphasis: #0969da;
|
||||
--success: #1a7f37;
|
||||
--btn-primary: #1f883d;
|
||||
--btn-primary-hover: #1a7f37;
|
||||
--header-bg: #24292f;
|
||||
--star: #9a6700;
|
||||
}
|
||||
/* AI DEV 포털 — 단일 스타일시트. (디자인 레퍼런스 GitHub 감성 / 라이트·다크)
|
||||
빌드 없음: CSS 변수 + 클래스만. 다크는 html[data-aidev-theme="dark"] 로 전환. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg:#ffffff; --panel:#f6f8fa; --card:#ffffff; --tile:#f3f5f8; --tile-bd:#e1e6eb;
|
||||
--inset:#ffffff; --bg-soft:#fbfcfd;
|
||||
--border:#d0d7de; --border-soft:#d8dee4; --divider:#eaecef;
|
||||
--fg:#1f2328; --fg2:#656d76; --fg3:#8b949e;
|
||||
--link:#0969da; --accent-bg:#ddf4ff; --accent-bd:#b6e3ff; --accent-fg:#0969da; --accent-strong:#0a3069;
|
||||
--success:#1f883d; --success-hover:#1a7f37; --success-bg:#dafbe1; --success-bd:#b7ebc4; --success-bd2:#4ac26b; --success-fg:#1a7f37;
|
||||
--attn:#d4a72c; --attn-fg:#9a6700; --attn-bg:#fff8e6; --attn-bd:#eac54f;
|
||||
--admin:#8250df; --admin-fg:#8250df; --admin-bg:#f3effc; --admin-bd:#e7defb;
|
||||
--avatar-bg:#eaeef2; --overlay:rgba(31,35,40,.42);
|
||||
--shadow:rgba(31,35,40,.08); --shadow-hover:rgba(31,35,40,.12); --shadow-strong:rgba(31,35,40,.22);
|
||||
--btn-bg:#f6f8fa; --btn-bd:#d0d7de; --btn-hover:#eef1f4;
|
||||
--tab-underline:#fd8c73; --focus-ring:rgba(9,105,218,.12); --danger:#cf222e;
|
||||
--heart:#bf3989; --heart-fg:#bf3989; --heart-bg:#ffeff5; --heart-bd:#ffc1da;
|
||||
--hbg:#24292f; --hfg:#ffffff; --hfg2:#b9c0c9; --hbd:#444c56; --hsearch-bg:#383f47; --hsearch-bd:#565b62; --hsearch-fg:#cdd3da;
|
||||
}
|
||||
html[data-aidev-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg:#0d1117; --panel:#161b22; --card:#1c2128; --tile:#2d333b; --tile-bd:#373e47;
|
||||
--inset:#0d1117; --bg-soft:#161b22;
|
||||
--border:#30363d; --border-soft:#373e47; --divider:#262c34;
|
||||
--fg:#e6edf3; --fg2:#9198a1; --fg3:#6e7681;
|
||||
--link:#4493f8; --accent-bg:rgba(56,139,253,.15); --accent-bd:rgba(56,139,253,.4); --accent-fg:#4493f8; --accent-strong:#a5d6ff;
|
||||
--success:#238636; --success-hover:#2ea043; --success-bg:rgba(46,160,67,.15); --success-bd:rgba(46,160,67,.4); --success-bd2:rgba(63,185,80,.6); --success-fg:#3fb950;
|
||||
--attn:#e3b341; --attn-fg:#e3b341; --attn-bg:rgba(227,179,65,.12); --attn-bd:rgba(187,128,9,.6);
|
||||
--admin:#bc8cff; --admin-fg:#bc8cff; --admin-bg:rgba(163,113,247,.15); --admin-bd:rgba(163,113,247,.4);
|
||||
--avatar-bg:#2d333b; --overlay:rgba(1,4,9,.7);
|
||||
--shadow:rgba(1,4,9,.4); --shadow-hover:rgba(1,4,9,.55); --shadow-strong:rgba(1,4,9,.6);
|
||||
--btn-bg:#21262d; --btn-bd:#30363d; --btn-hover:#30363d;
|
||||
--tab-underline:#f78166; --focus-ring:rgba(56,139,253,.3); --danger:#f85149;
|
||||
--heart:#db61a2; --heart-fg:#db61a2; --heart-bg:rgba(219,97,162,.15); --heart-bd:rgba(219,97,162,.4);
|
||||
--hbg:#010409; --hfg:#e6edf3; --hfg2:#9198a1; --hbd:#21262d; --hsearch-bg:#0d1117; --hsearch-bd:#30363d; --hsearch-fg:#9198a1;
|
||||
}
|
||||
|
||||
html, body { background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", Helvetica, Arial, sans-serif;
|
||||
color: var(--fg);
|
||||
background: var(--canvas-subtle);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color .25s ease, color .2s ease;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
::selection { background: color-mix(in srgb, var(--link) 22%, transparent); }
|
||||
::placeholder { color: var(--fg3); }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
svg { display: block; }
|
||||
.tabnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: var(--header-bg);
|
||||
color: #fff;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.header .brand { font-weight: 600; font-size: 16px; color: #fff; display: flex; align-items: center; gap: 8px; }
|
||||
.header .brand:hover { text-decoration: none; }
|
||||
.header .spacer { flex: 1; }
|
||||
.header .who { color: #d1d9e0; font-size: 13px; }
|
||||
.header a.navlink { color: #fff; font-size: 14px; }
|
||||
|
||||
/* 컨테이너 */
|
||||
.container { max-width: 1012px; margin: 24px auto; padding: 0 16px; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; }
|
||||
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 카드 */
|
||||
.box {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.box-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
background: var(--canvas-subtle);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.box-body { padding: 16px; }
|
||||
.box-row {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.box-row:last-child { border-bottom: 0; }
|
||||
|
||||
/* 사이트 그리드 */
|
||||
.site-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
@media (max-width: 540px) { .site-grid { grid-template-columns: 1fr; } }
|
||||
.site-card {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 12px; background: var(--canvas);
|
||||
}
|
||||
.site-card:hover { border-color: var(--accent); text-decoration: none; }
|
||||
.site-card .ico { font-size: 22px; line-height: 1; }
|
||||
.site-card .name { font-weight: 600; color: var(--fg); }
|
||||
.site-card .desc { color: var(--fg-muted); font-size: 12px; }
|
||||
.sso-badge { display: inline-block; font-size: 10px; font-weight: 600; color: #1a7f37; background: #dafbe1; border: 1px solid #aceebb; border-radius: 999px; padding: 1px 7px; vertical-align: middle; margin-left: 4px; }
|
||||
.group-title { font-size: 13px; font-weight: 600; color: var(--fg-muted); margin: 20px 0 8px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
/* 버튼 */
|
||||
.btn {
|
||||
display: inline-block; padding: 5px 16px; font-size: 14px; font-weight: 500;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--canvas-subtle);
|
||||
color: var(--fg); cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: #f3f4f6; text-decoration: none; }
|
||||
.btn-primary { background: var(--btn-primary); border-color: rgba(31,35,40,.15); color: #fff; }
|
||||
.btn-primary:hover { background: var(--btn-primary-hover); color: #fff; }
|
||||
.btn-sm { padding: 3px 12px; font-size: 12px; }
|
||||
|
||||
/* 폼 */
|
||||
input[type=text], textarea, input[type=url] {
|
||||
width: 100%; padding: 6px 12px; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-size: 14px; font-family: inherit;
|
||||
}
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
label { display: block; font-weight: 600; margin: 10px 0 4px; font-size: 13px; }
|
||||
|
||||
/* 프로젝트 리스트 */
|
||||
.proj-item { padding: 16px; border-bottom: 1px solid var(--border-muted); }
|
||||
.proj-item:last-child { border-bottom: 0; }
|
||||
.proj-title { font-size: 16px; font-weight: 600; }
|
||||
.proj-meta { color: var(--fg-muted); font-size: 12px; margin-top: 4px; }
|
||||
.tag {
|
||||
display: inline-block; background: #ddf4ff; color: var(--accent);
|
||||
border-radius: 2em; padding: 0 8px; font-size: 12px; margin-right: 4px;
|
||||
}
|
||||
.counter { color: var(--fg-muted); font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* 아바타 */
|
||||
/* 공통 아바타 래퍼 (identicon 헬퍼가 사용) */
|
||||
.avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff; display: inline-flex;
|
||||
align-items: center; justify-content: center; font-size: 12px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||
overflow: hidden; background: var(--avatar-bg);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
}
|
||||
|
||||
/* 코멘트 */
|
||||
.comment { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; }
|
||||
.comment-head { background: var(--canvas-subtle); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.comment-body { padding: 12px; white-space: pre-wrap; }
|
||||
/* ===================== 헤더 ===================== */
|
||||
.hdr { position: sticky; top: 0; z-index: 300; background: var(--hbg); border-bottom: 1px solid var(--hbd); }
|
||||
.hdr-inner { max-width: 1180px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 16px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; flex: 0 0 auto; }
|
||||
.brand-mark { display: inline-flex; color: var(--hfg); transition: transform .2s ease, filter .2s ease; }
|
||||
.brand-name { font-weight: 800; font-size: 15px; letter-spacing: .13em; color: var(--hfg); }
|
||||
.brand-sub { font-size: 11px; color: var(--hfg2); font-weight: 500; border-left: 1px solid var(--hbd); padding-left: 10px; margin-left: 2px; white-space: nowrap; }
|
||||
|
||||
/* 로그인 */
|
||||
.login-wrap { max-width: 340px; margin: 80px auto; text-align: center; }
|
||||
.login-card { padding: 32px 24px; }
|
||||
.login-logo { font-size: 40px; }
|
||||
.login-title { font-size: 24px; font-weight: 300; margin: 12px 0 24px; }
|
||||
.alert { background: #ffebe9; border: 1px solid #ff818266; color: #cf222e; padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
|
||||
.muted { color: var(--fg-muted); font-size: 12px; }
|
||||
.hdr-search { flex: 1; max-width: 300px; position: relative; display: flex; align-items: center; }
|
||||
.hdr-search-ico { position: absolute; left: 10px; display: inline-flex; color: var(--hfg2); pointer-events: none; }
|
||||
.hdr-search input {
|
||||
width: 100%; font-family: inherit; font-size: 13px; color: var(--hfg);
|
||||
background: var(--hsearch-bg); border: 1px solid var(--hsearch-bd); border-radius: 7px;
|
||||
padding: 6px 10px 6px 32px; outline: none;
|
||||
}
|
||||
.hdr-search input:focus { border-color: var(--link); background: var(--bg); color: var(--fg); }
|
||||
.hdr-search input::-webkit-search-cancel-button { filter: invert(.5); }
|
||||
|
||||
.hdr-right { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; margin-left: auto; }
|
||||
.icon-btn {
|
||||
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--hbd); background: transparent;
|
||||
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--hfg2);
|
||||
transition: background .14s ease, color .14s ease;
|
||||
}
|
||||
.icon-btn:hover { background: rgba(255,255,255,.08); color: var(--hfg); }
|
||||
.theme-ico-sun { display: none; }
|
||||
html[data-aidev-theme="dark"] .theme-ico-moon { display: none; }
|
||||
html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
|
||||
|
||||
.admin-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600;
|
||||
color: #fff; background: color-mix(in srgb, var(--admin) 80%, transparent);
|
||||
border: 1px solid var(--hbd); border-radius: 999px; padding: 4px 11px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.usermenu { position: relative; }
|
||||
.usermenu-btn {
|
||||
display: flex; align-items: center; gap: 8px; background: transparent; border: 1px solid transparent;
|
||||
border-radius: 8px; padding: 4px 6px 4px 8px; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.usermenu-btn:hover { background: rgba(255,255,255,.08); }
|
||||
.usermenu-btn .who { font-size: 13px; font-weight: 600; color: var(--hfg); font-variant-numeric: tabular-nums; }
|
||||
.usermenu-btn .caret { display: inline-flex; color: var(--hfg2); }
|
||||
.usermenu-pop {
|
||||
position: absolute; top: 46px; right: 0; width: 212px; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; box-shadow: 0 8px 24px var(--shadow-strong); padding: 6px; z-index: 320; animation: aidev-pop .14s ease-out;
|
||||
}
|
||||
.usermenu-pop[hidden] { display: none; }
|
||||
.usermenu-head { padding: 8px 10px 10px; border-bottom: 1px solid var(--divider); margin-bottom: 6px; }
|
||||
.usermenu-label { font-size: 12px; color: var(--fg2); }
|
||||
.usermenu-sabun { font-size: 13.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.usermenu-role { font-size: 11.5px; color: var(--fg2); margin-top: 2px; }
|
||||
.usermenu-item { width: 100%; text-align: left; background: transparent; border: 0; border-radius: 6px; padding: 8px 10px; cursor: pointer; font-family: inherit; font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 8px; }
|
||||
.usermenu-item:hover { background: var(--panel); }
|
||||
.usermenu-ico { display: inline-flex; color: var(--fg2); }
|
||||
|
||||
/* ===================== 레이아웃 / 섹션 ===================== */
|
||||
.main { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; }
|
||||
.section { margin-bottom: 40px; }
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.section-titlewrap { display: flex; align-items: baseline; gap: 10px; }
|
||||
.section-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; }
|
||||
.section-sub { font-size: 13px; color: var(--fg2); }
|
||||
|
||||
/* ===================== ① 개발 도구 ===================== */
|
||||
.tools-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
.tool-card {
|
||||
position: relative; text-align: left; background: var(--card); border: 1px solid var(--border-soft);
|
||||
border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit; display: block;
|
||||
transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
|
||||
}
|
||||
.tool-card:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow-hover); transform: translateY(-3px); }
|
||||
.tool-card.admin { border-color: var(--admin-bd); }
|
||||
.tool-icon {
|
||||
width: 44px; height: 44px; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 24px; background: var(--tile); border: 1px solid var(--tile-bd);
|
||||
}
|
||||
.tool-card.admin .tool-icon { background: var(--admin-bg); border-color: var(--admin-bd); }
|
||||
.tool-name { display: block; margin-top: 14px; font-size: 14.5px; font-weight: 700; color: var(--fg); }
|
||||
.tool-desc { display: block; margin-top: 3px; font-size: 12.5px; color: var(--fg2); line-height: 1.45; }
|
||||
.sso-badge {
|
||||
position: absolute; top: 13px; right: 13px; font-size: 10px; font-weight: 700; letter-spacing: .06em;
|
||||
color: var(--accent-fg); background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 4px; padding: 2px 6px;
|
||||
}
|
||||
.tool-admin-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; margin-top: 11px; font-size: 11px; font-weight: 600;
|
||||
color: var(--admin-fg); background: var(--admin-bg); border: 1px solid var(--admin-bd); border-radius: 999px; padding: 2px 8px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===================== ② 내 프로젝트 ===================== */
|
||||
.myproj { position: relative; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px 22px 24px; }
|
||||
.myproj-accent { position: absolute; left: 0; top: 18px; bottom: 18px; width: 4px; border-radius: 0 4px 4px 0; background: var(--link); }
|
||||
.myproj-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding-left: 8px; flex-wrap: wrap; }
|
||||
.myproj-tools { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.filter-tabs { display: inline-flex; align-items: center; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 2px; gap: 2px; }
|
||||
.filter-tab {
|
||||
display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 5px 11px;
|
||||
cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--fg2); white-space: nowrap;
|
||||
transition: background .14s ease, color .14s ease;
|
||||
}
|
||||
.filter-tab.on { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.new-proj-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; background: var(--success);
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 8px; padding: 7px 13px;
|
||||
cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
|
||||
}
|
||||
.new-proj-btn:hover { background: var(--success-hover); }
|
||||
.myproj-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-left: 8px; }
|
||||
|
||||
/* 프로젝트 카드 (내 프로젝트 / 피드 공용 베이스) */
|
||||
.pcard {
|
||||
background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 15px 16px 14px;
|
||||
cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column;
|
||||
}
|
||||
.pcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
|
||||
.pcard.private { background: var(--bg); border: 1.5px dashed var(--border); }
|
||||
.pcard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.pcard-titlewrap { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.pcard-repoico { display: inline-flex; color: var(--fg2); flex: 0 0 auto; }
|
||||
.pcard-title { font-size: 14px; font-weight: 700; color: var(--link); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pcard-desc { margin-top: 8px; font-size: 12.5px; color: var(--fg2); line-height: 1.5; flex: 1; }
|
||||
.pcard-tags { margin-top: 11px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pcard-foot { margin-top: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.pcard-stats { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
|
||||
.pcard-stats .stat { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.lang-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||
.ico-heart { display: inline-flex; color: var(--heart); }
|
||||
.ico-star { display: inline-flex; color: var(--attn); }
|
||||
.ico-comment { display: inline-flex; color: var(--fg2); }
|
||||
.vis-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; font-size: 11px; font-weight: 600;
|
||||
border-radius: 999px; padding: 1px 8px 1px 7px; white-space: nowrap;
|
||||
}
|
||||
.vis-badge.public { color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd); }
|
||||
.vis-badge.private { color: var(--fg2); background: var(--panel); border: 1px solid var(--border); }
|
||||
.toggle-share-btn { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg3); white-space: nowrap; }
|
||||
.toggle-share-btn:hover { color: var(--fg); }
|
||||
.private-note { font-size: 11.5px; color: var(--fg3); }
|
||||
.share-mini-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px; background: var(--success);
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 7px; padding: 4px 10px;
|
||||
cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 700; color: #fff; white-space: nowrap; transition: background .14s ease;
|
||||
}
|
||||
.share-mini-btn:hover { background: var(--success-hover); }
|
||||
.add-card {
|
||||
background: transparent; border: 1.5px dashed var(--border); border-radius: 12px; padding: 16px; cursor: pointer; font-family: inherit;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--fg2); min-height: 158px; width: 100%;
|
||||
transition: border-color .16s ease, color .16s ease, background .16s ease;
|
||||
}
|
||||
.add-card:hover { border-color: var(--success); color: var(--success); background: var(--card); }
|
||||
.add-card-plus { width: 38px; height: 38px; border-radius: 50%; background: var(--success-bg); border: 1px solid var(--success-bd); display: inline-flex; align-items: center; justify-content: center; color: var(--success); }
|
||||
.add-card-title { font-size: 13.5px; font-weight: 700; white-space: nowrap; }
|
||||
.add-card-sub { font-size: 11.5px; text-align: center; line-height: 1.4; }
|
||||
.empty { grid-column: 1 / -1; text-align: center; padding: 30px; color: var(--fg2); font-size: 13px; border: 1px dashed var(--border); border-radius: 12px; }
|
||||
|
||||
/* ===================== ③ 공유 피드 ===================== */
|
||||
.feed-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--border-soft); }
|
||||
.feed-head .section-titlewrap { padding-bottom: 14px; }
|
||||
.feed-head-right { display: flex; align-items: flex-end; gap: 14px; }
|
||||
.feed-star-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--btn-bd); border-radius: 7px;
|
||||
padding: 6px 12px; margin-bottom: 8px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap;
|
||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
||||
}
|
||||
.feed-star-btn:hover { border-color: var(--attn-bd); color: var(--attn-fg); }
|
||||
.feed-star-btn.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
||||
.feed-star-btn .ico { display: inline-flex; }
|
||||
.sort-tabs { display: flex; gap: 2px; }
|
||||
.sort-tab { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px 14px; color: var(--fg2); transition: color .14s ease; }
|
||||
.sort-tab.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--tab-underline); }
|
||||
.feed-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
|
||||
.fcard { background: var(--card); border: 1px solid var(--border-soft); border-radius: 12px; padding: 18px; cursor: pointer; transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease; display: flex; flex-direction: column; }
|
||||
.fcard:hover { border-color: var(--link); box-shadow: 0 6px 18px var(--shadow); transform: translateY(-2px); }
|
||||
.fcard-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.fcard-titlewrap { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.fcard-title { font-size: 15px; font-weight: 700; color: var(--link); line-height: 1.3; }
|
||||
.react-btns { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
||||
.react-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; background: var(--btn-bg); border: 1px solid var(--btn-bd);
|
||||
border-radius: 7px; padding: 4px 9px; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg);
|
||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
||||
}
|
||||
.react-btn .count { font-variant-numeric: tabular-nums; }
|
||||
.react-btn.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
|
||||
.react-btn.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
||||
/* 채움(on) / 외곽선(off) 아이콘 토글 — JS 가 .on 클래스만 바꾸면 됨 */
|
||||
.react-btn .ico, .modal-action .ico { display: inline-flex; }
|
||||
.react-btn .ico-on, .modal-action .ico-on { display: none; }
|
||||
.react-btn.on .ico-on, .modal-action.on .ico-on { display: inline-flex; }
|
||||
.react-btn.on .ico-off, .modal-action.on .ico-off { display: none; }
|
||||
.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; }
|
||||
.fcard-tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.fcard-foot { margin-top: 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.fcard-author { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.fcard-author .sabun { font-size: 12.5px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.fcard-author .date { font-size: 11.5px; color: var(--fg3); }
|
||||
.fcard-meta { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
|
||||
.fcard-meta .stat { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.bara-link { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); white-space: nowrap; }
|
||||
|
||||
.tag { font-size: 11px; font-weight: 500; color: var(--accent-fg); background: var(--accent-bg); border-radius: 999px; padding: 1px 9px; }
|
||||
|
||||
/* ===================== 상세 모달 ===================== */
|
||||
.modal-overlay { position: fixed; inset: 0; z-index: 500; background: var(--overlay); display: flex; align-items: flex-start; justify-content: center; padding: 48px 20px; overflow-y: auto; animation: aidev-fade .16s ease-out; }
|
||||
.modal-overlay[hidden] { display: none; }
|
||||
.modal { width: 100%; max-width: 640px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
|
||||
.modal-head { padding: 22px 24px 18px; border-bottom: 1px solid var(--divider); }
|
||||
.modal-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
||||
.modal-titlewrap { min-width: 0; }
|
||||
.modal-title-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.modal-repoico { display: inline-flex; color: var(--fg2); }
|
||||
.modal-title { margin: 0; font-size: 20px; font-weight: 800; color: var(--link); line-height: 1.3; letter-spacing: -.01em; }
|
||||
.modal-meta { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.modal-meta .sabun { font-size: 13px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.modal-meta .date { font-size: 12px; color: var(--fg3); }
|
||||
.modal-meta .lang { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--fg2); margin-left: 4px; }
|
||||
.modal-close { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; border: 1px solid transparent; background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--fg2); }
|
||||
.modal-close:hover { background: var(--panel); border-color: var(--border-soft); }
|
||||
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
.modal-desc { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; white-space: pre-wrap; }
|
||||
.modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.modal-action {
|
||||
display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px;
|
||||
padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg);
|
||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
||||
}
|
||||
.modal-action .count { font-variant-numeric: tabular-nums; padding-left: 8px; margin-left: 2px; border-left: 1px solid var(--border); }
|
||||
.modal-action.heart.on { background: var(--heart-bg); border-color: var(--heart-bd); color: var(--heart-fg); }
|
||||
.modal-action.star.on { background: var(--attn-bg); border-color: var(--attn-bd); color: var(--attn-fg); }
|
||||
.modal-action.primary { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
||||
.modal-action.primary:hover { background: var(--success-hover); }
|
||||
.modal-action.ghost:hover { background: var(--btn-hover); }
|
||||
|
||||
/* 댓글 스레드 */
|
||||
.modal-body { padding: 18px 24px 4px; }
|
||||
.comments-title { font-size: 13px; font-weight: 700; color: var(--fg); margin-bottom: 14px; display: flex; align-items: center; gap: 7px; }
|
||||
.comments-count { font-weight: 600; color: var(--fg2); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 999px; padding: 0 8px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.comment-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.comment { display: flex; gap: 11px; padding: 11px 0; }
|
||||
.comment-main { min-width: 0; flex: 1; }
|
||||
.comment-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.comment-author { font-size: 13px; font-weight: 700; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.comment-time { font-size: 11.5px; color: var(--fg3); }
|
||||
.lgtm { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 800; letter-spacing: .04em; color: var(--success-fg); background: var(--success-bg); border: 1px solid var(--success-bd2); border-radius: 5px; padding: 1px 7px; animation: aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2); }
|
||||
.comment-text { margin-top: 3px; font-size: 13.5px; color: var(--fg); line-height: 1.6; white-space: pre-wrap; }
|
||||
.comment-actions { margin-top: 5px; display: flex; align-items: center; gap: 12px; }
|
||||
.comment-link { background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--fg2); display: inline-flex; align-items: center; gap: 5px; }
|
||||
.comment-link:hover { color: var(--link); }
|
||||
.comment-del:hover { color: var(--danger); }
|
||||
.replies { margin-left: 21px; padding-left: 18px; border-left: 2px solid var(--divider); display: flex; flex-direction: column; }
|
||||
.reply { display: flex; gap: 10px; padding: 10px 0; }
|
||||
.reply .comment-author { font-size: 12.5px; }
|
||||
.reply .comment-text { font-size: 13px; }
|
||||
|
||||
.modal-foot { padding: 14px 24px 22px; border-top: 1px solid var(--divider); margin-top: 6px; background: var(--bg-soft); }
|
||||
.reply-banner { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; background: var(--accent-bg); border: 1px solid var(--accent-bd); border-radius: 7px; padding: 6px 10px; }
|
||||
.reply-banner span { font-size: 12px; color: var(--accent-strong); }
|
||||
.reply-banner button { background: transparent; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); }
|
||||
.comment-form { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.comment-input { flex: 1; resize: none; font-family: inherit; font-size: 13.5px; line-height: 1.5; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; outline: none; min-height: 40px; }
|
||||
.comment-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
|
||||
.submit-btn { flex: 0 0 auto; background: var(--success); border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); border-radius: 9px; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 700; color: #fff; align-self: stretch; white-space: nowrap; transition: background .14s ease; }
|
||||
.submit-btn:hover { background: var(--success-hover); }
|
||||
.comment-hint { margin-top: 7px; font-size: 11px; color: var(--fg3); padding-left: 38px; }
|
||||
|
||||
/* ===================== 공유(등록) 모달 ===================== */
|
||||
.share-modal { width: 100%; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 16px 48px var(--shadow-strong); overflow: hidden; animation: aidev-pop .18s ease-out; }
|
||||
.share-head { padding: 20px 24px; border-bottom: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; }
|
||||
.share-head h3 { margin: 0; font-size: 17px; font-weight: 800; color: var(--fg); }
|
||||
.share-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 15px; }
|
||||
.share-label { display: block; font-size: 12.5px; font-weight: 700; color: var(--fg); margin-bottom: 6px; }
|
||||
.share-label .req { color: var(--danger); }
|
||||
.share-label .hint { font-weight: 500; color: var(--fg3); }
|
||||
.share-input { width: 100%; font-family: inherit; font-size: 14px; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none; }
|
||||
.share-input.mono { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 13px; }
|
||||
.share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); }
|
||||
.vis-toggle { display: inline-flex; background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; }
|
||||
.vis-opt { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; }
|
||||
.vis-opt.on-public { background: var(--success-bg); color: var(--success-fg); box-shadow: inset 0 0 0 1px var(--success-bd); }
|
||||
.vis-opt.on-private { background: var(--panel); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.share-vishint { margin-top: 7px; font-size: 11.5px; color: var(--fg3); }
|
||||
.modal-foot-actions { padding: 16px 24px; border-top: 1px solid var(--divider); background: var(--bg-soft); display: flex; align-items: center; justify-content: flex-end; gap: 9px; }
|
||||
|
||||
/* ===================== 토스트 / 날아다니는 이모지 ===================== */
|
||||
.toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); z-index: 600; background: #1f2328; color: #fff; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 11px 16px; font-size: 13px; font-weight: 500; box-shadow: 0 8px 24px rgba(1,4,9,.4); display: flex; align-items: center; gap: 9px; max-width: 90vw; animation: aidev-toast 2.6s ease forwards; }
|
||||
.toast .toast-ico { display: inline-flex; color: #3fb950; }
|
||||
.flyers { position: fixed; inset: 0; z-index: 650; pointer-events: none; overflow: hidden; }
|
||||
.flyer { position: fixed; pointer-events: none; user-select: none; will-change: transform, opacity; }
|
||||
|
||||
/* ===================== 푸터 ===================== */
|
||||
.footer { border-top: 1px solid var(--border-soft); }
|
||||
.footer-inner { max-width: 1180px; margin: 0 auto; padding: 20px 24px 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.footer-brand { display: flex; align-items: center; gap: 8px; color: var(--fg3); font-size: 12px; }
|
||||
.footer-tech { font-size: 11.5px; color: var(--fg3); font-family: 'SF Mono', ui-monospace, Menlo, monospace; }
|
||||
|
||||
/* ===================== 버튼/태그 공용 (단독 페이지·로그인) ===================== */
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--fg); transition: background .14s ease; }
|
||||
.btn:hover { background: var(--btn-hover); }
|
||||
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
|
||||
.btn-primary { background: var(--link); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-success { background: var(--success); border-color: color-mix(in srgb, var(--fg) 12%, transparent); color: #fff; }
|
||||
.btn-success:hover { background: var(--success-hover); }
|
||||
.muted { color: var(--fg2); }
|
||||
.back-link { font-size: 13px; color: var(--fg2); }
|
||||
.back-link:hover { color: var(--link); }
|
||||
|
||||
/* ===================== 로그인 폴백 페이지 ===================== */
|
||||
.login-wrap { min-height: calc(100vh - 60px); display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.login-card { width: 100%; max-width: 360px; background: var(--card); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 8px 24px var(--shadow); padding: 32px 28px; text-align: center; }
|
||||
.login-logo { margin-bottom: 12px; }
|
||||
.login-title { font-size: 20px; font-weight: 800; letter-spacing: .06em; margin-bottom: 18px; }
|
||||
.alert { background: var(--attn-bg); border: 1px solid var(--attn-bd); color: var(--attn-fg); border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 16px; }
|
||||
|
||||
/* ===================== 단독 상세 페이지(project.ejs) ===================== */
|
||||
.detail-wrap { max-width: 760px; margin: 0 auto; padding: 28px 24px 64px; }
|
||||
|
||||
/* ===================== 애니메이션 키프레임 ===================== */
|
||||
@keyframes aidev-flyup { 0%{transform:translateY(0) scale(.5);opacity:0} 12%{opacity:1} 100%{transform:translateY(-180px) scale(1.15);opacity:0} }
|
||||
@keyframes aidev-sparkle { 0%{transform:scale(0) rotate(0);opacity:1} 100%{transform:scale(1.5) rotate(120deg);opacity:0} }
|
||||
@keyframes aidev-rocket { 0%{transform:translateX(-40px) rotate(-12deg);opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{transform:translateX(calc(100vw + 60px)) rotate(-12deg);opacity:0} }
|
||||
@keyframes aidev-shake { 10%,90%{transform:translateX(-1px) rotate(-1deg)} 20%,80%{transform:translateX(2px) rotate(1.5deg)} 30%,50%,70%{transform:translateX(-4px) rotate(-2deg)} 40%,60%{transform:translateX(4px) rotate(2deg)} }
|
||||
@keyframes aidev-stamp { 0%{transform:scale(2.4) rotate(-22deg);opacity:0} 55%{transform:scale(.86) rotate(-11deg);opacity:1} 100%{transform:scale(1) rotate(-11deg);opacity:1} }
|
||||
@keyframes aidev-pop { 0%{transform:scale(.96) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
|
||||
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
|
||||
@keyframes aidev-toast { 0%{transform:translateX(-50%) translateY(12px);opacity:0} 12%{transform:translateX(-50%) translateY(0);opacity:1} 88%{transform:translateX(-50%) translateY(0);opacity:1} 100%{transform:translateX(-50%) translateY(8px);opacity:0} }
|
||||
|
||||
/* ===================== 반응형 ===================== */
|
||||
@media (max-width: 900px) {
|
||||
.tools-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.myproj-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.feed-grid { grid-template-columns: 1fr; }
|
||||
.brand-sub { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.tools-grid, .myproj-grid { grid-template-columns: 1fr; }
|
||||
.hdr-search { display: none; }
|
||||
}
|
||||
|
||||
98
src/helpers.js
Normal file
98
src/helpers.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
|
||||
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
|
||||
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor() 로 사용.
|
||||
|
||||
// GitHub Octicon 16x16 패스 모음.
|
||||
const OCTICONS = {
|
||||
code: '<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>',
|
||||
search: '<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>',
|
||||
sun: '<path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.061a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.751.751 0 0 1-1.061-1.061l1.06-1.06a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.751.751 0 0 1-1.061 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-1.06 1.06l-1.061-1.06a.75.75 0 0 1 0-1.06Z"/>',
|
||||
moon: '<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/>',
|
||||
caret: '<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>',
|
||||
signout: '<path d="M2 2.75C2 1.784 2.784 1 3.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/>',
|
||||
lock: '<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/>',
|
||||
globe: '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z"/>',
|
||||
repo: '<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/>',
|
||||
starFill: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>',
|
||||
star: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/>',
|
||||
heart: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002ZM4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.58 20.58 0 0 0 8 13.393a20.58 20.58 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5Z"/>',
|
||||
heartFill: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"/>',
|
||||
comment: '<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>',
|
||||
plus: '<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>',
|
||||
x: '<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>',
|
||||
reply: '<path d="M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"/>',
|
||||
check: '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>',
|
||||
ext: '<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>',
|
||||
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"/>',
|
||||
};
|
||||
|
||||
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).
|
||||
export function icon(name, opts = {}) {
|
||||
const size = opts.size || 16;
|
||||
const fill = opts.fill || "currentColor";
|
||||
const path = OCTICONS[name] || OCTICONS.repo;
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" ` +
|
||||
`viewBox="0 0 16 16" fill="${fill}" style="display:block" aria-hidden="true">${path}</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
// 언어 → 색(카드의 언어 점).
|
||||
const LANG_COLORS = {
|
||||
Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a",
|
||||
Go: "#00ADD8", Rust: "#dea584", Shell: "#89e051", Java: "#b07219",
|
||||
};
|
||||
export function langColor(lang) {
|
||||
return LANG_COLORS[lang] || "#8b949e";
|
||||
}
|
||||
|
||||
// 태그에서 대표 언어 추론(프로젝트 등록 시 lang 저장용).
|
||||
export function langFromTags(tags) {
|
||||
const t = (tags || []).map((x) => String(x).toLowerCase());
|
||||
if (t.includes("fastapi") || t.includes("python") || t.includes("django")) return "Python";
|
||||
if (t.includes("react") || t.includes("ts") || t.includes("typescript") || t.includes("next")) return "TypeScript";
|
||||
if (t.includes("go") || t.includes("golang")) return "Go";
|
||||
if (t.includes("rust")) return "Rust";
|
||||
if (t.includes("node") || t.includes("js")) return "JavaScript";
|
||||
return "TypeScript";
|
||||
}
|
||||
|
||||
// 문자열 → 결정적 해시(아바타/색 시드).
|
||||
function hashSeed(seed) {
|
||||
let h = 0;
|
||||
const s = String(seed);
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각.
|
||||
export function identicon(seed, size = 28) {
|
||||
const h = hashSeed(seed);
|
||||
const hue = h % 360;
|
||||
const sat = 48 + (h % 22);
|
||||
const color = `hsl(${hue},${sat}%,52%)`;
|
||||
const grid = 5;
|
||||
const on = new Array(25).fill(false);
|
||||
for (let col = 0; col < 3; col++) {
|
||||
for (let row = 0; row < 5; row++) {
|
||||
const bit = (h >> (col * 5 + row)) & 1;
|
||||
on[row * 5 + col] = !!bit;
|
||||
on[row * 5 + (4 - col)] = !!bit;
|
||||
}
|
||||
}
|
||||
const inner = Math.round(size * 0.74);
|
||||
const cell = inner / grid;
|
||||
let rects = "";
|
||||
for (let r = 0; r < grid; r++) {
|
||||
for (let c = 0; c < grid; c++) {
|
||||
if (on[r * grid + c]) {
|
||||
rects += `<rect x="${(c * cell).toFixed(2)}" y="${(r * cell).toFixed(2)}" width="${cell.toFixed(2)}" height="${cell.toFixed(2)}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const radius = Math.max(5, Math.round(size * 0.2));
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 ${inner} ${inner}" fill="${color}" style="display:block">${rects}</svg>`;
|
||||
return (
|
||||
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="ko" data-aidev-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= brand %></title>
|
||||
<link rel="stylesheet" href="/public/style.css" />
|
||||
<script>
|
||||
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("aidev-theme");
|
||||
if (t === "dark" || t === "light") document.documentElement.setAttribute("data-aidev-theme", t);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<a class="brand" href="/"><span>🧠</span><%= brand %></a>
|
||||
<div class="spacer"></div>
|
||||
<header class="hdr">
|
||||
<div class="hdr-inner">
|
||||
<a class="brand" href="/" id="brandLink">
|
||||
<span class="brand-mark"><%- icon("code", { size: 22 }) %></span>
|
||||
<span class="brand-name"><%= brand %></span>
|
||||
<span class="brand-sub">사내 개발 포털</span>
|
||||
</a>
|
||||
|
||||
<% if (user) { %>
|
||||
<span class="who"><%= user.name %> (<%= user.sabun %>)</span>
|
||||
<a class="navlink" href="/logout">로그아웃</a>
|
||||
<div class="hdr-search">
|
||||
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
|
||||
<input type="search" id="feedSearch" placeholder="프로젝트 검색…" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="hdr-right">
|
||||
<button class="icon-btn" id="themeToggle" type="button" title="테마 전환" aria-label="테마 전환">
|
||||
<span class="theme-ico theme-ico-moon"><%- icon("moon", { size: 16 }) %></span>
|
||||
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
||||
</button>
|
||||
|
||||
<% if (user.isAdmin) { %>
|
||||
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
||||
<% } %>
|
||||
|
||||
<div class="usermenu">
|
||||
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="who"><%= user.sabun %></span>
|
||||
<%- identicon(user.sabun, 26) %>
|
||||
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
|
||||
</button>
|
||||
<div class="usermenu-pop" id="userMenuPop" hidden>
|
||||
<div class="usermenu-head">
|
||||
<div class="usermenu-label">로그인 계정</div>
|
||||
<div class="usermenu-sabun"><%= user.sabun %></div>
|
||||
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
||||
</div>
|
||||
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user