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:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
146
public/js/profile.js
Normal file
146
public/js/profile.js
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user