feat: 프로필 사진 업로드·프로필 페이지 + 모달/이름 개선

- 프로필 페이지 /u/:sabun 신설 (본인=비공개 포함, 타인=공개만).
  헤더 "내 프로필", 카드·모달·댓글 작성자 클릭 시 프로필로 이동.
- 프로필 사진 업로드: users.avatar(bytea) 저장, GET /avatar/:sabun
  (업로드본 없으면 identicon SVG 폴백), POST /profile/avatar(express.raw).
  브라우저 canvas 크롭(256px webp) — 서버 이미지 라이브러리/멀터 불필요.
  identicon 6개 렌더 지점을 avatar() 헬퍼로 통일.
- 상세 모달 폭 640→760px, 소유자 액션(비공개 전환·삭제)을 우측 그룹으로
  묶어 한 줄 정렬.
- 표시 이름을 한국식 성+이름(family_name+given_name)으로 교정.
- .project-env.example ADMIN_ROLE→ADMIN_GROUP 정리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:41:51 +09:00
parent b4c9b4a395
commit 6c2bb888b4
14 changed files with 446 additions and 35 deletions

View File

@@ -48,7 +48,7 @@
<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) %>
<%- avatar(user.sabun, 26) %>
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
</button>
<div class="usermenu-pop" id="userMenuPop" hidden>
@@ -57,6 +57,7 @@
<div class="usermenu-sabun"><%= user.sabun %></div>
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
</div>
<a class="usermenu-item" href="/u/<%= user.sabun %>"><span class="usermenu-ico"><%- icon("repo", { size: 15 }) %></span>내 프로필</a>
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
</div>
</div>

View File

@@ -23,8 +23,10 @@
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
</div>
<div class="modal-meta">
<%- identicon(p.owner_sabun, 24) %>
<span class="sabun" title="<%= p.owner_sabun %>"><%= p.owner_name || p.owner_sabun %></span>
<a class="meta-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
<%- avatar(p.owner_sabun, 24) %>
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
</a>
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
</div>
@@ -59,15 +61,17 @@
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
<% } %>
<% if (p.owner_sabun === me) { %>
<form method="post" action="/projects/<%= p.id %>/visibility" style="margin-left:auto">
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="modal-action"><%- icon(p.is_public ? "lock" : "globe", { size: 14 }) %><%= p.is_public ? "비공개로 전환" : "공개로 전환" %></button>
</form>
<form method="post" action="/projects/<%= p.id %>/delete">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</button>
</form>
<div class="modal-actions-owner">
<form method="post" action="/projects/<%= p.id %>/visibility">
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="modal-action"><%- icon(p.is_public ? "lock" : "globe", { size: 14 }) %><%= p.is_public ? "비공개로 전환" : "공개로 전환" %></button>
</form>
<form method="post" action="/projects/<%= p.id %>/delete">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</button>
</form>
</div>
<% } %>
</div>
</div>
@@ -78,10 +82,10 @@
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
<div class="comment-item">
<div class="comment">
<%- identicon(c.author_sabun, 28) %>
<a class="cavatar" href="/u/<%= c.author_sabun %>" title="<%= c.author_name || c.author_sabun %> 프로필"><%- avatar(c.author_sabun, 28) %></a>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author" title="<%= c.author_sabun %>"><%= c.author_name || c.author_sabun %></span>
<a class="comment-author" href="/u/<%= c.author_sabun %>"><%= c.author_name || c.author_sabun %></a>
<span class="comment-time"><%= reltime(c.created_at) %></span>
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
@@ -101,10 +105,10 @@
<div class="replies">
<% reps.forEach(function (r) { %>
<div class="reply">
<%- identicon(r.author_sabun, 24) %>
<a class="cavatar" href="/u/<%= r.author_sabun %>" title="<%= r.author_name || r.author_sabun %> 프로필"><%- avatar(r.author_sabun, 24) %></a>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author" title="<%= r.author_sabun %>"><%= r.author_name || r.author_sabun %></span>
<a class="comment-author" href="/u/<%= r.author_sabun %>"><%= r.author_name || r.author_sabun %></a>
<span class="comment-time"><%= reltime(r.created_at) %></span>
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
@@ -134,7 +138,7 @@
<button type="button" data-cancel-reply>취소</button>
</div>
<div class="comment-form">
<%- identicon(me, 28) %>
<%- avatar(me, 28) %>
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
<button type="submit" class="submit-btn">등록</button>
</div>

View File

@@ -157,11 +157,11 @@
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<div class="fcard-foot">
<div class="fcard-author">
<%- identicon(p.owner_sabun, 22) %>
<span class="sabun" title="<%= p.owner_sabun %>"><%= p.owner_name || p.owner_sabun %></span>
<a class="fcard-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
<%- avatar(p.owner_sabun, 22) %>
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
<span class="date">· <%= fdate(p.created_at) %></span>
</div>
</a>
<div class="fcard-meta">
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>

104
views/profile.ejs Normal file
View File

@@ -0,0 +1,104 @@
<%- include("_header") %>
<%
// 뷰 로컬 헬퍼
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"); }
%>
<main class="main">
<!-- 프로필 히어로 -->
<section class="section">
<div class="profile-hero">
<div class="profile-avatar-wrap">
<img class="profile-avatar" id="profileAvatar" src="/avatar/<%= profile.sabun %>" alt="<%= profile.name %>" width="96" height="96" />
<% if (isSelf) { %>
<button type="button" class="profile-avatar-edit" id="avatarEditBtn" title="프로필 사진 변경" aria-label="프로필 사진 변경"><%- icon("plus", { size: 15, fill: "#fff" }) %></button>
<% } %>
</div>
<div class="profile-info">
<h1 class="profile-name"><%= profile.name %></h1>
<div class="profile-sabun"><%= profile.sabun %></div>
<div class="profile-metaline">
<span><%= profile.project_count %>개 프로젝트</span>
<span>· 가입 <%= fdate(profile.created_at) %></span>
</div>
</div>
</div>
</section>
<!-- 프로젝트 목록 -->
<section class="section">
<div class="section-head">
<div class="section-titlewrap">
<h2 class="section-title"><%= isSelf ? "내 프로젝트" : (profile.name + " 님의 프로젝트") %></h2>
<span class="section-sub"><%= isSelf ? "공개·비공개 모두 표시" : "공개된 프로젝트만 표시" %></span>
</div>
</div>
<% if (!projects.length) { %>
<div class="empty"><%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %></div>
<% } %>
<div class="feed-grid">
<% projects.forEach(function (p) { var tags = tagList(p.tags); %>
<a class="fcard" href="/projects/<%= p.id %>">
<div class="fcard-top">
<div class="fcard-titlewrap">
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
<span class="fcard-title"><%= p.title %></span>
</div>
<span class="vis-badge <%= p.is_public ? "public" : "private" %>">
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
</span>
</div>
<div class="fcard-desc"><%= mdPlain(p.description) || "설명 없음" %></div>
<% if (tags.length) { %>
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<div class="fcard-foot">
<div class="fcard-meta">
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
<span class="stat"><span class="ico-heart"><%- icon("heartFill", { size: 13 }) %></span><span class="tabnum"><%= p.heart_count %></span></span>
<span class="stat"><span class="ico-star"><%- icon("starFill", { size: 13 }) %></span><span class="tabnum"><%= p.star_count %></span></span>
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
</div>
<span class="date"><%= fdate(p.created_at) %></span>
</div>
</a>
<% }); %>
</div>
</section>
</main>
<% if (isSelf) { %>
<!-- 프로필 사진 크롭 모달 -->
<div class="modal-overlay" id="cropModal" hidden>
<div class="crop-modal">
<div class="crop-head">
<h3>프로필 사진</h3>
<button type="button" class="modal-close" id="cropClose" title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<div class="crop-body">
<div class="crop-stage" id="cropStage">
<img id="cropImg" alt="" draggable="false" />
<div class="crop-veil"></div>
</div>
<label class="crop-zoom">
<span><%- icon("search", { size: 13 }) %></span>
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
</label>
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.</p>
</div>
<div class="modal-foot-actions">
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
<button type="button" class="btn" id="cropPick">다른 사진 선택</button>
<button type="button" class="btn btn-success" id="cropSave">저장</button>
</div>
</div>
</div>
<% } %>
<script type="module" src="/public/js/main.js"></script>
<% if (isSelf) { %><script type="module" src="/public/js/profile.js"></script><% } %>
</body>
</html>