feat: 헤더 매뉴얼 버튼 · 프로필 검색 · 상세 뒤로가기 뎁스 정리
- 헤더 프로필 왼쪽에 '매뉴얼' 버튼 추가(MANUAL_URL 환경변수, 기본 gitea MANUAL) - 검색을 페이지 내 data-search 카드 전체 대상으로 일반화 → 프로필에서 그 사람 프로젝트도 검색(자리표시자도 페이지별로: 공유/내/○○ 님의 프로젝트) - 상세 페이지 뒤로가기: ?from= 내부경로 기반으로 프로필→상세는 프로필로 복귀 - '사내 개발 포털' +4pt, 도구 카드 이름·설명 +2pt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,8 @@
|
|||||||
font-size: 24px; background: var(--tile); border: 1px solid var(--tile-bd);
|
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-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-name { display: block; margin-top: 14px; font-size: 16.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; }
|
.tool-desc { display: block; margin-top: 3px; font-size: 14.5px; color: var(--fg2); line-height: 1.45; }
|
||||||
.tool-admin-badge {
|
.tool-admin-badge {
|
||||||
display: inline-flex; align-items: center; gap: 5px; margin-top: 11px; font-size: 11px; font-weight: 600;
|
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;
|
color: var(--admin-fg); background: var(--admin-bg); border: 1px solid var(--admin-bd); border-radius: 999px; padding: 2px 8px; white-space: nowrap;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
.brand { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; flex: 0 0 auto; }
|
.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-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-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; }
|
.brand-sub { font-size: 15px; color: var(--hfg2); font-weight: 500; border-left: 1px solid var(--hbd); padding-left: 10px; margin-left: 2px; white-space: nowrap; }
|
||||||
|
|
||||||
.hdr-search { flex: 1; max-width: 300px; position: relative; display: flex; align-items: center; }
|
.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-ico { position: absolute; left: 10px; display: inline-flex; color: var(--hfg2); pointer-events: none; }
|
||||||
@@ -25,6 +25,14 @@
|
|||||||
transition: background .14s ease, color .14s ease;
|
transition: background .14s ease, color .14s ease;
|
||||||
}
|
}
|
||||||
.icon-btn:hover { background: rgba(255,255,255,.08); color: var(--hfg); }
|
.icon-btn:hover { background: rgba(255,255,255,.08); color: var(--hfg); }
|
||||||
|
/* 헤더 텍스트 링크 버튼(매뉴얼 등) — 아이콘 + 글자 */
|
||||||
|
.hdr-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px;
|
||||||
|
border-radius: 8px; border: 1px solid var(--hbd); background: transparent;
|
||||||
|
color: var(--hfg2); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap;
|
||||||
|
transition: background .14s ease, color .14s ease;
|
||||||
|
}
|
||||||
|
.hdr-link:hover { background: rgba(255,255,255,.08); color: var(--hfg); }
|
||||||
.theme-ico-sun { display: none; }
|
.theme-ico-sun { display: none; }
|
||||||
html[data-aidev-theme="dark"] .theme-ico-moon { display: none; }
|
html[data-aidev-theme="dark"] .theme-ico-moon { display: none; }
|
||||||
html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
|
html[data-aidev-theme="dark"] .theme-ico-sun { display: inline-flex; }
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { $, $$ } from "./util.js";
|
import { $, $$ } from "./util.js";
|
||||||
|
|
||||||
function applyFeed() {
|
function applyFeed() {
|
||||||
const grid = $("#feedGrid"); if (!grid) return;
|
// 검색 대상 = 현재 페이지의 검색 가능한 카드 전체(대시보드=공유 프로젝트, 프로필=그 사람 프로젝트).
|
||||||
|
const cards = $$(".fcard[data-search]");
|
||||||
|
if (!cards.length) return;
|
||||||
const q = (($("#feedSearch") && $("#feedSearch").value) || "").trim().toLowerCase();
|
const q = (($("#feedSearch") && $("#feedSearch").value) || "").trim().toLowerCase();
|
||||||
const starOnly = $("#feedStarFilter") && $("#feedStarFilter").classList.contains("on");
|
const starOnly = $("#feedStarFilter") && $("#feedStarFilter").classList.contains("on");
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
$$(".fcard", grid).forEach((c) => {
|
cards.forEach((c) => {
|
||||||
const hit = !q || (c.getAttribute("data-search") || "").indexOf(q) >= 0;
|
const hit = !q || (c.getAttribute("data-search") || "").indexOf(q) >= 0;
|
||||||
const st = !starOnly || c.getAttribute("data-starred") === "true";
|
const st = !starOnly || c.getAttribute("data-starred") === "true";
|
||||||
const show = hit && st;
|
const show = hit && st;
|
||||||
|
|||||||
@@ -35,4 +35,7 @@ export const config = {
|
|||||||
|
|
||||||
// 포털 표시 이름 (변경 가능)
|
// 포털 표시 이름 (변경 가능)
|
||||||
brand: process.env.PORTAL_BRAND || "AI DEV",
|
brand: process.env.PORTAL_BRAND || "AI DEV",
|
||||||
|
|
||||||
|
// 포털 사용 매뉴얼 링크(헤더 '매뉴얼' 버튼). 도메인 변경 대비 환경변수로 덮어쓰기 가능.
|
||||||
|
manualUrl: process.env.MANUAL_URL || "https://gitea.bokdev.in/playground/MANUAL",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const OCTICONS = {
|
|||||||
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"/>',
|
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"/>',
|
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"/>',
|
trash: '<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/>',
|
||||||
|
book: '<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).
|
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ app.use(
|
|||||||
// 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
|
// 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.locals.brand = config.brand;
|
res.locals.brand = config.brand;
|
||||||
|
res.locals.manualUrl = config.manualUrl;
|
||||||
res.locals.user = req.session.user || null;
|
res.locals.user = req.session.user || null;
|
||||||
res.locals.icon = icon;
|
res.locals.icon = icon;
|
||||||
res.locals.identicon = identicon;
|
res.locals.identicon = identicon;
|
||||||
@@ -124,7 +125,10 @@ app.get("/u/:sabun", requireAuth, async (req, res) => {
|
|||||||
const profile = await db.getUserProfile(req.params.sabun);
|
const profile = await db.getUserProfile(req.params.sabun);
|
||||||
if (!profile) return res.status(404).send("사용자를 찾을 수 없습니다.");
|
if (!profile) return res.status(404).send("사용자를 찾을 수 없습니다.");
|
||||||
const projects = await db.listProjectsByOwner(profile.sabun, me);
|
const projects = await db.listProjectsByOwner(profile.sabun, me);
|
||||||
res.render("profile", { profile, projects, isSelf: profile.sabun === me });
|
const searchPlaceholder = profile.sabun === me
|
||||||
|
? "내 프로젝트 검색…"
|
||||||
|
: `${profile.name} 님의 프로젝트 검색…`;
|
||||||
|
res.render("profile", { profile, projects, isSelf: profile.sabun === me, searchPlaceholder });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 대시보드 ---
|
// --- 대시보드 ---
|
||||||
@@ -183,7 +187,14 @@ app.get("/projects/:id", requireAuth, async (req, res) => {
|
|||||||
// 비공개는 본인만 열람
|
// 비공개는 본인만 열람
|
||||||
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
|
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
|
||||||
const comments = await db.listComments(project.id);
|
const comments = await db.listComments(project.id);
|
||||||
res.render("project", { project, comments, starred: project.starred });
|
// 뒤로가기 대상: ?from= 으로 넘어온 내부 경로(프로필 등)가 있으면 그곳, 없으면 대시보드.
|
||||||
|
// (뎁스 순서: 프로필 → 프로젝트 상세 로 들어왔으면 프로필로 돌아간다)
|
||||||
|
const from = req.query.from;
|
||||||
|
const safeFrom = typeof from === "string" && /^\/[^/]/.test(from) ? from : null; // 내부 절대경로만 허용
|
||||||
|
const back = safeFrom && safeFrom.startsWith("/u/")
|
||||||
|
? { href: safeFrom, label: "프로필" }
|
||||||
|
: { href: "/", label: "대시보드" };
|
||||||
|
res.render("project", { project, comments, starred: project.starred, back });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
|
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
<div class="hdr-search">
|
<div class="hdr-search">
|
||||||
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
|
<span class="hdr-search-ico"><%- icon("search", { size: 15 }) %></span>
|
||||||
<input type="search" id="feedSearch" placeholder="프로젝트 검색…" autocomplete="off" />
|
<input type="search" id="feedSearch" placeholder="<%= typeof searchPlaceholder !== 'undefined' && searchPlaceholder ? searchPlaceholder : '공유 프로젝트 검색…' %>" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hdr-right">
|
<div class="hdr-right">
|
||||||
@@ -41,6 +41,10 @@
|
|||||||
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a class="hdr-link" href="<%= manualUrl %>" target="_blank" rel="noopener" title="포털 사용 매뉴얼">
|
||||||
|
<%- icon("book", { size: 15 }) %><span>매뉴얼</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<% if (user.isAdmin) { %>
|
<% if (user.isAdmin) { %>
|
||||||
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<% if (modal) { %>
|
<% if (modal) { %>
|
||||||
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
|
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<a class="back-link" href="/">← 대시보드</a>
|
<a class="back-link" href="<%= typeof back !== 'undefined' && back ? back.href : '/' %>">← <%= typeof back !== 'undefined' && back ? back.label : '대시보드' %></a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% if (pdTags.length) { %>
|
<% if (pdTags.length) { %>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// 뷰 로컬 헬퍼
|
// 뷰 로컬 헬퍼
|
||||||
function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
|
function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
|
||||||
function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
|
function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
|
||||||
|
function searchText(p) { return (p.title + " " + (p.description || "") + " " + (p.tags || "")).toLowerCase(); }
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
@@ -38,10 +39,11 @@
|
|||||||
<% if (!projects.length) { %>
|
<% if (!projects.length) { %>
|
||||||
<div class="empty"><%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %></div>
|
<div class="empty"><%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
<div class="empty" id="feedEmpty" hidden></div>
|
||||||
|
|
||||||
<div class="feed-grid">
|
<div class="feed-grid">
|
||||||
<% projects.forEach(function (p) { var tags = tagList(p.tags); %>
|
<% projects.forEach(function (p) { var tags = tagList(p.tags); %>
|
||||||
<a class="fcard" href="/projects/<%= p.id %>">
|
<a class="fcard" href="/projects/<%= p.id %>?from=/u/<%= encodeURIComponent(profile.sabun) %>" data-search="<%= searchText(p) %>">
|
||||||
<div class="fcard-top">
|
<div class="fcard-top">
|
||||||
<div class="fcard-titlewrap">
|
<div class="fcard-titlewrap">
|
||||||
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
|
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="detail-wrap">
|
<div class="detail-wrap">
|
||||||
<div class="modal" style="margin:0 auto;animation:none">
|
<div class="modal" style="margin:0 auto;animation:none">
|
||||||
<%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false }) %>
|
<%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false, back: back }) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user