From 84ce8567e787911493531eeed35c184dbb6fb0bc Mon Sep 17 00:00:00 2001 From: 2620227 Date: Fri, 10 Jul 2026 13:43:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EC=97=90=20'=ED=95=9C=EC=A4=84=20=EC=86=8C=EA=B0=9C'=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=B3=B8=EC=9D=B8=EB=A7=8C=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=ED=8E=B8=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- public/css/components.css | 15 +++++++++++-- public/js/profile.js | 46 +++++++++++++++++++++++++++++++++++++++ schema.sql | 2 ++ src/db.js | 7 +++++- src/server.js | 10 +++++++++ views/profile.ejs | 22 +++++++++++++++++-- 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/public/css/components.css b/public/css/components.css index b44b298..33ffedb 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -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; } diff --git a/public/js/profile.js b/public/js/profile.js index 2f7ccde..a7f57f2 100644 --- a/public/js/profile.js +++ b/public/js/profile.js @@ -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 없음 diff --git a/schema.sql b/schema.sql index d797fe1..e535171 100644 --- a/schema.sql +++ b/schema.sql @@ -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 ( diff --git a/src/db.js b/src/db.js index 9c6260a..71e7f84 100644 --- a/src/db.js +++ b/src/db.js @@ -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`, diff --git a/src/server.js b/src/server.js index 81854cc..7a13cc5 100644 --- a/src/server.js +++ b/src/server.js @@ -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; diff --git a/views/profile.ejs b/views/profile.ejs index 61ac86b..619ddb7 100644 --- a/views/profile.ejs +++ b/views/profile.ejs @@ -17,12 +17,30 @@ <% } %>
-

<%= profile.name %>

-
<%= profile.sabun %>
+
+

<%= profile.name %>

+ <%= profile.sabun %> +
<%= profile.project_count %>개 프로젝트 · 가입 <%= fdate(profile.created_at) %>
+ + <% if (profile.bio || isSelf) { %> +
+

" id="bioText"><%= profile.bio || "한줄 소개를 남겨보세요" %>

+ <% if (isSelf) { %> + + <% } %> +
+ <% if (isSelf) { %> + + <% } %> + <% } %>