feat(profile): 프로필에 '한줄 소개' 추가 — 본인만 인라인 편집

- users.bio 컬럼(멱등) + db.setBio / getUserProfile 조회 포함
- POST /profile/bio (본인만, fetch JSON / 폼 POST 폴백)
- 프로필 히어로: 이름·사번 한 줄로 합치고 한줄 소개를 3줄째로 배치
- 본인은 연필 버튼으로 인라인 편집(저장/취소·Esc), 최대 80자
- word-break: keep-all 로 한글 단어 중간 잘림 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:43:49 +09:00
parent 0ed41849ca
commit 84ce8567e7
6 changed files with 97 additions and 5 deletions

View File

@@ -157,9 +157,20 @@
.profile-avatar-edit { position: absolute; right: -4px; bottom: -4px; width: 30px; height: 30px; border-radius: 50%; border: 2px solid var(--card); background: var(--link); color: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px var(--shadow); }
.profile-avatar-edit:hover { filter: brightness(1.08); }
.profile-info { min-width: 0; }
.profile-idline { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.profile-name { margin: 0; font-size: 24px; font-weight: 800; color: var(--fg); letter-spacing: -.01em; }
.profile-sabun { margin-top: 3px; font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
.profile-metaline { margin-top: 8px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }
.profile-sabun { font-size: 13px; color: var(--fg2); font-variant-numeric: tabular-nums; }
.profile-metaline { margin-top: 6px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }
/* 한줄 소개 */
.profile-bio-row { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
.profile-bio { margin: 0; font-size: 13.5px; color: var(--fg2); line-height: 1.5; word-break: keep-all; overflow-wrap: break-word; }
.profile-bio.is-empty { color: var(--fg3); font-style: italic; }
.profile-bio-edit { flex: 0 0 auto; width: 24px; height: 24px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--fg3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.profile-bio-edit:hover { color: var(--fg); border-color: var(--fg3); }
.profile-bio-form { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.profile-bio-form input { flex: 1 1 240px; min-width: 0; padding: 6px 10px; font-size: 13.5px; color: var(--fg); background: var(--card); border: 1px solid var(--border); border-radius: 8px; }
.profile-bio-form input:focus { outline: none; border-color: var(--link); }
/* ===================== 프로필 사진 크롭 모달 ===================== */
.crop-modal { width: 100%; max-width: 420px; margin: auto; 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; }

View File

@@ -4,7 +4,53 @@ import { $, toast } from "./util.js";
const OUT = 256; // 저장 이미지 한 변(px)
// 한줄 소개 편집 — 텍스트/편집버튼을 폼으로 토글, 저장은 fetch 로 즉시 반영.
function initBio() {
const row = $("#bioRow");
const form = $("#bioForm");
const text = $("#bioText");
const editBtn = $("#bioEditBtn");
const input = $("#bioInput");
const cancel = $("#bioCancel");
if (!row || !form || !text || !editBtn || !input) return; // 본인 프로필에서만 동작
function show(editing) {
row.hidden = editing;
form.hidden = !editing;
if (editing) { input.focus(); input.select(); }
}
editBtn.addEventListener("click", () => show(true));
if (cancel) cancel.addEventListener("click", () => { input.value = text.classList.contains("is-empty") ? "" : text.textContent; show(false); });
input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); cancel.click(); } });
form.addEventListener("submit", async (e) => {
e.preventDefault();
const bio = input.value.trim().slice(0, 80);
const saveBtn = $("#bioSave");
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = "저장 중…"; }
try {
const res = await fetch("/profile/bio", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" },
body: JSON.stringify({ bio }),
});
if (!res.ok) throw new Error("save failed");
text.textContent = bio || "한줄 소개를 남겨보세요";
text.classList.toggle("is-empty", !bio);
show(false);
toast("한줄 소개가 저장됐어요");
} catch (err) {
toast("저장에 실패했어요");
} finally {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = "저장"; }
}
});
}
export function initProfile() {
initBio();
const editBtn = $("#avatarEditBtn");
const modal = $("#cropModal");
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음

View File

@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS users (
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar bytea;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_mime text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
-- 한줄 소개(프로필에 표시). 본인만 편집.
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text DEFAULT '';
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
CREATE TABLE IF NOT EXISTS projects (

View File

@@ -25,6 +25,11 @@ export async function setAvatar({ sabun, buf, mime }) {
);
}
// 한줄 소개 저장(본인). 80자 이내로 잘라 보관.
export async function setBio({ sabun, bio }) {
await q(`UPDATE users SET bio=$2 WHERE sabun=$1`, [sabun, String(bio || "").slice(0, 80)]);
}
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
export async function getAvatar(sabun) {
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
@@ -34,7 +39,7 @@ export async function getAvatar(sabun) {
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
export async function getUserProfile(sabun) {
const { rows } = await q(
`SELECT u.sabun, u.name, u.email, u.created_at,
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio,
(u.avatar IS NOT NULL) AS has_avatar,
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
FROM users u WHERE u.sabun=$1`,

View File

@@ -122,6 +122,16 @@ app.post(
}
);
// --- 한줄 소개 저장(본인) ---
// JS 있으면 fetch(JSON), 없으면 폼 POST → 프로필로 리다이렉트(점진적 향상).
app.post("/profile/bio", requireAuth, async (req, res) => {
const sabun = req.session.user.sabun;
const bio = String(req.body.bio || "").trim().slice(0, 80);
await db.setBio({ sabun, bio });
if (isAjax(req)) return res.json({ ok: true, bio });
res.redirect("/u/" + encodeURIComponent(sabun));
});
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
app.get("/u/:sabun", requireAuth, async (req, res) => {
const me = req.session.user.sabun;

View File

@@ -17,12 +17,30 @@
<% } %>
</div>
<div class="profile-info">
<h1 class="profile-name"><%= profile.name %></h1>
<div class="profile-sabun"><%= profile.sabun %></div>
<div class="profile-idline">
<h1 class="profile-name"><%= profile.name %></h1>
<span class="profile-sabun"><%= profile.sabun %></span>
</div>
<div class="profile-metaline">
<span><%= profile.project_count %>개 프로젝트</span>
<span>· 가입 <%= fdate(profile.created_at) %></span>
</div>
<% if (profile.bio || isSelf) { %>
<div class="profile-bio-row" id="bioRow">
<p class="profile-bio<%= profile.bio ? "" : " is-empty" %>" id="bioText"><%= profile.bio || "한줄 소개를 남겨보세요" %></p>
<% if (isSelf) { %>
<button type="button" class="profile-bio-edit" id="bioEditBtn" title="한줄 소개 편집" aria-label="한줄 소개 편집"><%- icon("pencil", { size: 13 }) %></button>
<% } %>
</div>
<% if (isSelf) { %>
<form class="profile-bio-form" id="bioForm" method="post" action="/profile/bio" hidden>
<input type="text" name="bio" id="bioInput" maxlength="80" value="<%= profile.bio || "" %>" placeholder="한줄 소개 (최대 80자)" autocomplete="off" />
<button type="submit" class="btn btn-success" id="bioSave">저장</button>
<button type="button" class="btn" id="bioCancel">취소</button>
</form>
<% } %>
<% } %>
</div>
</div>
</section>