From 6c2bb888b4cfdc7b7d9f3624cfe72a2009541516 Mon Sep 17 00:00:00 2001 From: 2620227 Date: Thu, 2 Jul 2026 15:41:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C=C2=B7=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC/=EC=9D=B4=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 페이지 /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 --- .gitignore | 3 + .project-env.example | 5 +- public/css/components.css | 33 +++++++++ public/css/modal.css | 4 +- public/js/profile.js | 146 ++++++++++++++++++++++++++++++++++++++ schema.sql | 6 +- src/auth.js | 7 +- src/db.js | 38 ++++++++++ src/helpers.js | 41 ++++++++--- src/server.js | 47 +++++++++++- views/_header.ejs | 3 +- views/_project_detail.ejs | 36 +++++----- views/dashboard.ejs | 8 +-- views/profile.ejs | 104 +++++++++++++++++++++++++++ 14 files changed, 446 insertions(+), 35 deletions(-) create mode 100644 public/js/profile.js create mode 100644 views/profile.ejs diff --git a/.gitignore b/.gitignore index de17fcc..fa7d07b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ .DS_Store HANDOFF.md _preview.mjs + +# bkit 플러그인 런타임/감사 상태 (앱과 무관) +.bkit/ diff --git a/.project-env.example b/.project-env.example index 1871dfa..e4e6de7 100644 --- a/.project-env.example +++ b/.project-env.example @@ -22,8 +22,9 @@ OIDC_CLIENT_ID=portal OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET # --- 권한 --- -# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출. -ADMIN_ROLE=admin +# 이 Keycloak 그룹(group)에 속한 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출. +# (portal 클라이언트의 group-membership 매퍼가 ID 토큰에 groups 클레임을 넣어줌) +ADMIN_GROUP=DevOps # --- Gitea 언어 자동 판별 (선택) --- # 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다. diff --git a/public/css/components.css b/public/css/components.css index 70fa465..5ad8a5e 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -135,3 +135,36 @@ .fcard-meta { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); } .fcard-meta .stat { display: inline-flex; align-items: center; gap: 5px; } .bara-link { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 0; padding: 0; cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--link); white-space: nowrap; } +.fcard .fcard-foot > .date { font-size: 11.5px; color: var(--fg3); margin-left: auto; } + +/* ===================== 프로필 링크(아바타+이름) ===================== */ +/* 카드/모달/댓글의 작성자 아바타·이름을 프로필로 잇는 링크. 클릭 시 카드 모달 대신 이동. */ +.fcard-author:hover .sabun { text-decoration: underline; } +.meta-author { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } +.meta-author:hover .sabun { text-decoration: underline; } +.cavatar { display: inline-flex; flex: 0 0 auto; } +.comment-author:hover { text-decoration: underline; } + +/* ===================== 프로필 페이지 ===================== */ +.profile-hero { display: flex; align-items: center; gap: 20px; } +.profile-avatar-wrap { position: relative; flex: 0 0 auto; } +.profile-avatar { width: 96px; height: 96px; border-radius: 24px; object-fit: cover; background: var(--avatar-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent); display: block; } +.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-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); } + +/* ===================== 프로필 사진 크롭 모달 ===================== */ +.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; } +.crop-head { padding: 16px 20px; border-bottom: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; } +.crop-head h3 { margin: 0; font-size: 16px; font-weight: 800; color: var(--fg); } +.crop-body { padding: 18px 20px; } +.crop-stage { position: relative; width: 300px; height: 300px; max-width: 100%; margin: 0 auto; border-radius: 12px; overflow: hidden; background: var(--inset); touch-action: none; cursor: grab; user-select: none; } +.crop-stage:active { cursor: grabbing; } +#cropImg { position: absolute; top: 0; left: 0; max-width: none; transform-origin: 0 0; will-change: transform; pointer-events: none; } +.crop-veil { position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent); border-radius: 12px; } +.crop-zoom { margin-top: 14px; display: flex; align-items: center; gap: 10px; color: var(--fg2); } +.crop-zoom input[type=range] { flex: 1; accent-color: var(--link); } +.crop-hint { margin: 10px 0 0; font-size: 11.5px; color: var(--fg3); line-height: 1.5; } diff --git a/public/css/modal.css b/public/css/modal.css index 17e065a..975c388 100644 --- a/public/css/modal.css +++ b/public/css/modal.css @@ -4,7 +4,7 @@ .modal-overlay { position: fixed; inset: 0; z-index: 500; background: var(--overlay); display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; overflow-y: auto; animation: aidev-fade .16s ease-out; } .modal-overlay[hidden] { display: none; } /* margin:auto → 내용이 짧으면 화면 정중앙, 길면 위에 붙어 스크롤(잘림 방지). */ -.modal { width: 100%; max-width: 640px; 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; } +.modal { width: 100%; max-width: 760px; 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; } .modal-head { padding: 22px 24px 18px; border-bottom: 1px solid var(--divider); } .modal-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } .modal-titlewrap { min-width: 0; } @@ -44,6 +44,8 @@ .md-body th { background: var(--panel); font-weight: 700; } .md-body img { max-width: 100%; border-radius: 8px; } .modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +/* 소유자 액션(비공개 전환 · 삭제)을 우측에 한 덩어리로 묶어, 개별 버튼이 홀로 줄바꿈되지 않게 한다. */ +.modal-actions-owner { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .modal-action { display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg); diff --git a/public/js/profile.js b/public/js/profile.js new file mode 100644 index 0000000..2f7ccde --- /dev/null +++ b/public/js/profile.js @@ -0,0 +1,146 @@ +// 프로필 사진 업로드 — 파일 선택 → 정사각 크롭(드래그 이동 + 확대) → 256px webp 로 서버 전송. +// 서버 이미지 라이브러리 없이 브라우저 canvas 에서 자르고 축소한다. +import { $, toast } from "./util.js"; + +const OUT = 256; // 저장 이미지 한 변(px) + +export function initProfile() { + const editBtn = $("#avatarEditBtn"); + const modal = $("#cropModal"); + if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음 + + const stage = $("#cropStage"); + const img = $("#cropImg"); + const zoom = $("#cropZoom"); + const fileInput = $("#avatarFile"); + const saveBtn = $("#cropSave"); + const pickBtn = $("#cropPick"); + const closeBtn = $("#cropClose"); + const avatarEls = document.querySelectorAll('[data-avatar], #profileAvatar'); + + // 크롭 상태: 스테이지(정사각)를 뷰포트로 보고, 그 안에 이미지가 항상 뷰포트를 덮도록 배치. + const st = { nw: 0, nh: 0, base: 1, zoom: 1, tx: 0, ty: 0, S: 0 }; + + function stageSize() { return stage.clientWidth; } // 정사각(= clientHeight) + + function scale() { return st.base * st.zoom; } + + // 이미지가 뷰포트를 항상 덮도록 tx,ty 를 제한. + function clamp() { + const dispW = st.nw * scale(); + const dispH = st.nh * scale(); + st.tx = Math.min(0, Math.max(st.S - dispW, st.tx)); + st.ty = Math.min(0, Math.max(st.S - dispH, st.ty)); + } + + function render() { + const dispW = st.nw * scale(); + const dispH = st.nh * scale(); + img.style.width = dispW + "px"; + img.style.height = dispH + "px"; + img.style.transform = `translate(${st.tx}px, ${st.ty}px)`; + } + + function loadImage(src) { + img.onload = () => { + st.nw = img.naturalWidth; + st.nh = img.naturalHeight; + st.S = stageSize(); + // cover: 짧은 변이 스테이지를 채우도록. + st.base = st.S / Math.min(st.nw, st.nh); + st.zoom = 1; + if (zoom) zoom.value = "1"; + // 가운데 정렬 + st.tx = (st.S - st.nw * scale()) / 2; + st.ty = (st.S - st.nh * scale()) / 2; + clamp(); + render(); + }; + img.src = src; + } + + function openModal() { modal.hidden = false; document.body.style.overflow = "hidden"; } + function closeModal() { modal.hidden = true; document.body.style.overflow = ""; } + + editBtn.addEventListener("click", () => fileInput.click()); + if (pickBtn) pickBtn.addEventListener("click", () => fileInput.click()); + if (closeBtn) closeBtn.addEventListener("click", closeModal); + modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); + + fileInput.addEventListener("change", () => { + const f = fileInput.files && fileInput.files[0]; + if (!f) return; + if (!/^image\/(png|jpeg|webp)$/.test(f.type)) { toast("PNG·JPG·WEBP 이미지만 올릴 수 있어요"); return; } + const reader = new FileReader(); + reader.onload = () => { loadImage(reader.result); openModal(); }; + reader.readAsDataURL(f); + }); + + // 확대 슬라이더 — 스테이지 중심을 고정한 채 확대/축소. + if (zoom) zoom.addEventListener("input", () => { + const cx = st.S / 2, cy = st.S / 2; + const prev = scale(); + const ix = (cx - st.tx) / prev; // 중심에 대응하는 이미지 좌표 + const iy = (cy - st.ty) / prev; + st.zoom = parseFloat(zoom.value) || 1; + st.tx = cx - ix * scale(); + st.ty = cy - iy * scale(); + clamp(); + render(); + }); + + // 드래그 이동 (포인터 이벤트) + let dragging = false, px = 0, py = 0; + stage.addEventListener("pointerdown", (e) => { + dragging = true; px = e.clientX; py = e.clientY; + stage.setPointerCapture(e.pointerId); + }); + stage.addEventListener("pointermove", (e) => { + if (!dragging) return; + st.tx += e.clientX - px; st.ty += e.clientY - py; + px = e.clientX; py = e.clientY; + clamp(); render(); + }); + const endDrag = () => { dragging = false; }; + stage.addEventListener("pointerup", endDrag); + stage.addEventListener("pointercancel", endDrag); + + saveBtn.addEventListener("click", async () => { + if (!st.nw) return; + const sc = scale(); + // 스테이지 뷰포트(0..S) 를 이미지 원본 좌표로 역변환. + const sx = (0 - st.tx) / sc; + const sy = (0 - st.ty) / sc; + const sSize = st.S / sc; + const canvas = document.createElement("canvas"); + canvas.width = OUT; canvas.height = OUT; + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, OUT, OUT); + const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.85)); + if (!blob) { toast("이미지 변환에 실패했어요"); return; } + saveBtn.disabled = true; saveBtn.textContent = "저장 중…"; + try { + const res = await fetch("/profile/avatar", { + method: "POST", + headers: { "Content-Type": "image/webp", "X-Requested-With": "fetch" }, + body: blob, + }); + if (!res.ok) throw new Error("upload failed"); + // 캐시 무력화를 위해 모든 내 아바타 src 에 타임스탬프 부여. + const bust = "?t=" + Date.now(); + avatarEls.forEach((el) => { + const base = (el.getAttribute("src") || "").split("?")[0]; + if (base) el.setAttribute("src", base + bust); + }); + toast("프로필 사진이 변경됐어요"); + closeModal(); + } catch (e) { + toast("업로드에 실패했어요"); + } finally { + saveBtn.disabled = false; saveBtn.textContent = "저장"; + } + }); +} + +initProfile(); diff --git a/schema.sql b/schema.sql index ee5669d..d797fe1 100644 --- a/schema.sql +++ b/schema.sql @@ -5,10 +5,14 @@ CREATE TABLE IF NOT EXISTS users ( sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username) name text NOT NULL, email text, - avatar_seed text, -- 아바타 색/이니셜용 + avatar_seed text, -- 아바타 색/이니셜용(미업로드 시 identicon 시드) created_at timestamptz NOT NULL DEFAULT now(), last_login timestamptz ); +-- 업로드한 프로필 사진(1:1 크롭·256px webp 등, 브라우저에서 축소). 없으면 identicon 폴백. +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; -- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유) CREATE TABLE IF NOT EXISTS projects ( diff --git a/src/auth.js b/src/auth.js index ce24d72..befbc06 100644 --- a/src/auth.js +++ b/src/auth.js @@ -45,9 +45,14 @@ export async function handleCallback(req) { // 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다. // full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교. const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, "")); + // 표시 이름: 한국식 성+이름 순서(예: 홍길동). Keycloak `name` 클레임은 보통 이름+성("길동 홍")이라 + // family_name(성)+given_name(이름) 을 공백 없이 조합해 교정한다. 둘 다 없으면 name/사번으로 폴백. + const fam = (claims.family_name || "").trim(); + const giv = (claims.given_name || "").trim(); + const displayName = fam || giv ? fam + giv : claims.name || claims.preferred_username; return { sabun: claims.preferred_username, // 사번 - name: claims.name || claims.given_name || claims.preferred_username, + name: displayName, email: claims.email, groups, isAdmin: groups.includes(config.adminGroup), diff --git a/src/db.js b/src/db.js index fc40a72..4ece6e3 100644 --- a/src/db.js +++ b/src/db.js @@ -17,6 +17,32 @@ export async function upsertUser({ sabun, name, email }) { ); } +// 프로필 사진 저장(본인). 브라우저에서 1:1 크롭·축소한 작은 이미지 버퍼를 그대로 보관. +export async function setAvatar({ sabun, buf, mime }) { + await q( + `UPDATE users SET avatar=$2, avatar_mime=$3, avatar_updated_at=now() WHERE sabun=$1`, + [sabun, buf, mime] + ); +} + +// 프로필 사진 조회 → { 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]); + return rows[0] || null; +} + +// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분). +export async function getUserProfile(sabun) { + const { rows } = await q( + `SELECT u.sabun, u.name, u.email, u.created_at, + (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`, + [sabun] + ); + return rows[0] || null; +} + // 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절. // $1 = viewer 사번. const PROJECT_SELECT = ` @@ -40,6 +66,18 @@ export async function listProjects(viewer) { return rows; } +// 특정 사용자(owner)의 프로젝트 — 프로필 페이지용. +// 본인($1===owner)이면 비공개 포함 전체, 타인이면 공개 프로젝트만. +export async function listProjectsByOwner(owner, viewer) { + const { rows } = await q( + `${PROJECT_SELECT} + WHERE p.owner_sabun=$2 AND (p.is_public OR p.owner_sabun=$1) + ORDER BY p.created_at DESC`, + [viewer, owner] + ); + return rows; +} + // 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출) export async function getProject(id, viewer) { const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]); diff --git a/src/helpers.js b/src/helpers.js index ebbd260..e1ac169 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -101,12 +101,10 @@ function hashSeed(seed) { return h; } -// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각. -export function identicon(seed, size = 28) { +// 사번 기반 identicon(5x5 대칭 격자)의 rects+color 계산 코어. +function identiconCore(seed) { const h = hashSeed(seed); - const hue = h % 360; - const sat = 48 + (h % 22); - const color = `hsl(${hue},${sat}%,52%)`; + const color = `hsl(${h % 360},${48 + (h % 22)}%,52%)`; const grid = 5; const on = new Array(25).fill(false); for (let col = 0; col < 3; col++) { @@ -116,8 +114,7 @@ export function identicon(seed, size = 28) { on[row * 5 + (4 - col)] = !!bit; } } - const inner = Math.round(size * 0.74); - const cell = inner / grid; + const cell = 100 / grid; // viewBox 0..100 기준 let rects = ""; for (let r = 0; r < grid; r++) { for (let c = 0; c < grid; c++) { @@ -126,9 +123,37 @@ export function identicon(seed, size = 28) { } } } + return { color, rects }; +} + +// 사번 기반 identicon 아바타(인라인 span). 디자인과 동일한 시각. +export function identicon(seed, size = 28) { + const { color, rects } = identiconCore(seed); + const inner = Math.round(size * 0.74); const radius = Math.max(5, Math.round(size * 0.2)); - const svg = `${rects}`; + const svg = `${rects}`; return ( `${svg}` ); } + +// /avatar/:sabun 엔드포인트가 업로드 사진이 없을 때 응답하는 독립 SVG 문서 문자열. +export function identiconSvg(seed, size = 128) { + const { color, rects } = identiconCore(seed); + return `${rects}`; +} + +// 프로필 사진 . 실제 이미지는 /avatar/:sabun 엔드포인트가 업로드본 또는 identicon SVG 로 응답한다. +// (모든 아바타 렌더 지점을 이 헬퍼로 통일 → 뷰마다 업로드 여부를 조회할 필요가 없다.) +function escapeAttr(s) { + return String(s).replace(/[&"'<>]/g, (c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" }[c])); +} +export function avatar(sabun, size = 28) { + const radius = Math.max(5, Math.round(size * 0.2)); + const src = `/avatar/${encodeURIComponent(sabun)}`; + return ( + `` + ); +} diff --git a/src/server.js b/src/server.js index bb7b7ba..35897df 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,7 @@ import path from "path"; import { config } from "./config.js"; import { toolsForUser } from "./sites.js"; import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js"; -import { icon, identicon, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js"; +import { icon, identicon, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js"; import { fetchMainLang, fetchReadme } from "./gitea.js"; import * as db from "./db.js"; @@ -34,6 +34,7 @@ app.use((req, res, next) => { res.locals.user = req.session.user || null; res.locals.icon = icon; res.locals.identicon = identicon; + res.locals.avatar = avatar; res.locals.langColor = langColor; res.locals.renderMarkdown = renderMarkdown; res.locals.mdPlain = mdPlain; @@ -82,6 +83,50 @@ app.get("/logout", (req, res) => { }); }); +// --- 아바타 이미지 --- +// 업로드한 프로필 사진이 있으면 그 bytes 를, 없으면 사번 기반 identicon SVG 로 응답. +app.get("/avatar/:sabun", requireAuth, async (req, res) => { + const sabun = req.params.sabun; + try { + const row = await db.getAvatar(sabun); + if (row && row.avatar) { + res.set("Content-Type", row.avatar_mime || "image/webp"); + res.set("Cache-Control", "private, max-age=300"); + return res.send(row.avatar); + } + } catch (e) { + console.error("avatar load error", e); + } + res.set("Content-Type", "image/svg+xml; charset=utf-8"); + res.set("Cache-Control", "private, max-age=300"); + res.send(identiconSvg(sabun, 128)); +}); + +// --- 프로필 사진 업로드(본인) --- +// 브라우저에서 1:1 크롭·축소한 작은 이미지를 raw body 로 받는다(멀터/이미지 라이브러리 불필요). +app.post( + "/profile/avatar", + requireAuth, + express.raw({ type: ["image/webp", "image/jpeg", "image/png"], limit: "700kb" }), + async (req, res) => { + const buf = req.body; + const mime = (req.get("Content-Type") || "").split(";")[0].trim(); + if (!Buffer.isBuffer(buf) || buf.length === 0) return res.status(400).json({ ok: false, error: "empty" }); + if (!["image/webp", "image/jpeg", "image/png"].includes(mime)) return res.status(415).json({ ok: false, error: "type" }); + await db.setAvatar({ sabun: req.session.user.sabun, buf, mime }); + res.json({ ok: true }); + } +); + +// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 --- +app.get("/u/:sabun", requireAuth, async (req, res) => { + const me = req.session.user.sabun; + const profile = await db.getUserProfile(req.params.sabun); + if (!profile) return res.status(404).send("사용자를 찾을 수 없습니다."); + const projects = await db.listProjectsByOwner(profile.sabun, me); + res.render("profile", { profile, projects, isSelf: profile.sabun === me }); +}); + // --- 대시보드 --- app.get("/", requireAuth, async (req, res) => { const me = req.session.user.sabun; diff --git a/views/_header.ejs b/views/_header.ejs index 34d66c9..79dce95 100644 --- a/views/_header.ejs +++ b/views/_header.ejs @@ -48,7 +48,7 @@
+ <%- icon("repo", { size: 15 }) %>내 프로필 <%- icon("signout", { size: 15 }) %>로그아웃
diff --git a/views/_project_detail.ejs b/views/_project_detail.ejs index 8e1e752..2be76bd 100644 --- a/views/_project_detail.ejs +++ b/views/_project_detail.ejs @@ -23,8 +23,10 @@ "><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %> @@ -59,15 +61,17 @@ <%- icon("code", { size: 14 }) %>저장소 <% } %> <% if (p.owner_sabun === me) { %> -
- " /> - - -
-
- - -
+ <% } %> @@ -78,10 +82,10 @@ <% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
- <%- identicon(c.author_sabun, 28) %> + <%- avatar(c.author_sabun, 28) %>
- <%= c.author_name || c.author_sabun %> + <%= c.author_name || c.author_sabun %> <%= reltime(c.created_at) %> <% if (isLgtm(c.body)) { %><%- icon("check", { size: 11 }) %>LGTM<% } %>
@@ -101,10 +105,10 @@
<% reps.forEach(function (r) { %>
- <%- identicon(r.author_sabun, 24) %> + <%- avatar(r.author_sabun, 24) %>
- <%= r.author_name || r.author_sabun %> + <%= r.author_name || r.author_sabun %> <%= reltime(r.created_at) %> <% if (isLgtm(r.body)) { %><%- icon("check", { size: 11 }) %>LGTM<% } %>
@@ -134,7 +138,7 @@
- <%- identicon(me, 28) %> + <%- avatar(me, 28) %>
diff --git a/views/dashboard.ejs b/views/dashboard.ejs index a256be7..f7cc741 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -157,11 +157,11 @@
<% tags.forEach(function (t) { %><%= t %><% }); %>
<% } %>
-
- <%- identicon(p.owner_sabun, 22) %> - <%= p.owner_name || p.owner_sabun %> + + <%- avatar(p.owner_sabun, 22) %> + <%= p.owner_name || p.owner_sabun %> · <%= fdate(p.created_at) %> -
+
<% if (p.lang) { %><%= p.lang %><% } %> <%- icon("comment", { size: 14 }) %><%= p.comment_count %> diff --git a/views/profile.ejs b/views/profile.ejs new file mode 100644 index 0000000..76b19c2 --- /dev/null +++ b/views/profile.ejs @@ -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"); } +%> + +
+ +
+
+
+ <%= profile.name %> + <% if (isSelf) { %> + + <% } %> +
+
+

<%= profile.name %>

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

<%= isSelf ? "내 프로젝트" : (profile.name + " 님의 프로젝트") %>

+ <%= isSelf ? "공개·비공개 모두 표시" : "공개된 프로젝트만 표시" %> +
+
+ + <% if (!projects.length) { %> +
<%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %>
+ <% } %> + + +
+
+ +<% if (isSelf) { %> + + +<% } %> + + +<% if (isSelf) { %><% } %> + +