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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
104
views/profile.ejs
Normal 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>
|
||||
Reference in New Issue
Block a user