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

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 ${inner} ${inner}" fill="${color}" style="display:block">${rects}</svg>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 100 100" fill="${color}" style="display:block">${rects}</svg>`;
return (
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
);
}
// /avatar/:sabun 엔드포인트가 업로드 사진이 없을 때 응답하는 독립 SVG 문서 문자열.
export function identiconSvg(seed, size = 128) {
const { color, rects } = identiconCore(seed);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100" fill="${color}">${rects}</svg>`;
}
// 프로필 사진 <img>. 실제 이미지는 /avatar/:sabun 엔드포인트가 업로드본 또는 identicon SVG 로 응답한다.
// (모든 아바타 렌더 지점을 이 헬퍼로 통일 → 뷰마다 업로드 여부를 조회할 필요가 없다.)
function escapeAttr(s) {
return String(s).replace(/[&"'<>]/g, (c) => ({ "&": "&amp;", '"': "&quot;", "'": "&#39;", "<": "&lt;", ">": "&gt;" }[c]));
}
export function avatar(sabun, size = 28) {
const radius = Math.max(5, Math.round(size * 0.2));
const src = `/avatar/${encodeURIComponent(sabun)}`;
return (
`<img class="avatar" src="${src}" alt="" loading="lazy" width="${size}" height="${size}" ` +
`style="width:${size}px;height:${size}px;border-radius:${radius}px;object-fit:cover;display:block" ` +
`data-avatar="${escapeAttr(sabun)}" />`
);
}