텍스트 저장/취소 버튼이 줄바꿈되며 프로필 카드를 밀어버리던 문제 수정. input과 같은 줄에 24px check(저장)/x(취소) 아이콘 버튼 2개 배치. JS의 textContent 토글 제거(아이콘 지워지지 않도록 disabled만). Co-Authored-By: Claude <noreply@anthropic.com>
193 lines
7.0 KiB
JavaScript
193 lines
7.0 KiB
JavaScript
// 프로필 사진 업로드 — 파일 선택 → 정사각 크롭(드래그 이동 + 확대) → 256px webp 로 서버 전송.
|
|
// 서버 이미지 라이브러리 없이 브라우저 canvas 에서 자르고 축소한다.
|
|
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;
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function initProfile() {
|
|
initBio();
|
|
|
|
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();
|