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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
_preview.mjs
|
_preview.mjs
|
||||||
|
|
||||||
|
# bkit 플러그인 런타임/감사 상태 (앱과 무관)
|
||||||
|
.bkit/
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ OIDC_CLIENT_ID=portal
|
|||||||
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
||||||
|
|
||||||
# --- 권한 ---
|
# --- 권한 ---
|
||||||
# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
# 이 Keycloak 그룹(group)에 속한 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
||||||
ADMIN_ROLE=admin
|
# (portal 클라이언트의 group-membership 매퍼가 ID 토큰에 groups 클레임을 넣어줌)
|
||||||
|
ADMIN_GROUP=DevOps
|
||||||
|
|
||||||
# --- Gitea 언어 자동 판별 (선택) ---
|
# --- Gitea 언어 자동 판별 (선택) ---
|
||||||
# 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다.
|
# 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다.
|
||||||
|
|||||||
@@ -135,3 +135,36 @@
|
|||||||
.fcard-meta { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.modal-overlay[hidden] { display: none; }
|
||||||
/* margin:auto → 내용이 짧으면 화면 정중앙, 길면 위에 붙어 스크롤(잘림 방지). */
|
/* 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-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-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
||||||
.modal-titlewrap { min-width: 0; }
|
.modal-titlewrap { min-width: 0; }
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
.md-body th { background: var(--panel); font-weight: 700; }
|
.md-body th { background: var(--panel); font-weight: 700; }
|
||||||
.md-body img { max-width: 100%; border-radius: 8px; }
|
.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 { 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 {
|
.modal-action {
|
||||||
display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px;
|
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);
|
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();
|
||||||
@@ -5,10 +5,14 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username)
|
sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username)
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
email text,
|
email text,
|
||||||
avatar_seed text, -- 아바타 색/이니셜용
|
avatar_seed text, -- 아바타 색/이니셜용(미업로드 시 identicon 시드)
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
last_login timestamptz
|
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 를 포털에 공유)
|
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ export async function handleCallback(req) {
|
|||||||
// 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
|
// 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
|
||||||
// full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
|
// full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
|
||||||
const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, ""));
|
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 {
|
return {
|
||||||
sabun: claims.preferred_username, // 사번
|
sabun: claims.preferred_username, // 사번
|
||||||
name: claims.name || claims.given_name || claims.preferred_username,
|
name: displayName,
|
||||||
email: claims.email,
|
email: claims.email,
|
||||||
groups,
|
groups,
|
||||||
isAdmin: groups.includes(config.adminGroup),
|
isAdmin: groups.includes(config.adminGroup),
|
||||||
|
|||||||
38
src/db.js
38
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 절.
|
// 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
|
||||||
// $1 = viewer 사번.
|
// $1 = viewer 사번.
|
||||||
const PROJECT_SELECT = `
|
const PROJECT_SELECT = `
|
||||||
@@ -40,6 +66,18 @@ export async function listProjects(viewer) {
|
|||||||
return rows;
|
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 이거나 본인일 때만 노출)
|
// 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
|
||||||
export async function getProject(id, viewer) {
|
export async function getProject(id, viewer) {
|
||||||
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
|
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
|
||||||
|
|||||||
@@ -101,12 +101,10 @@ function hashSeed(seed) {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각.
|
// 사번 기반 identicon(5x5 대칭 격자)의 rects+color 계산 코어.
|
||||||
export function identicon(seed, size = 28) {
|
function identiconCore(seed) {
|
||||||
const h = hashSeed(seed);
|
const h = hashSeed(seed);
|
||||||
const hue = h % 360;
|
const color = `hsl(${h % 360},${48 + (h % 22)}%,52%)`;
|
||||||
const sat = 48 + (h % 22);
|
|
||||||
const color = `hsl(${hue},${sat}%,52%)`;
|
|
||||||
const grid = 5;
|
const grid = 5;
|
||||||
const on = new Array(25).fill(false);
|
const on = new Array(25).fill(false);
|
||||||
for (let col = 0; col < 3; col++) {
|
for (let col = 0; col < 3; col++) {
|
||||||
@@ -116,8 +114,7 @@ export function identicon(seed, size = 28) {
|
|||||||
on[row * 5 + (4 - col)] = !!bit;
|
on[row * 5 + (4 - col)] = !!bit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const inner = Math.round(size * 0.74);
|
const cell = 100 / grid; // viewBox 0..100 기준
|
||||||
const cell = inner / grid;
|
|
||||||
let rects = "";
|
let rects = "";
|
||||||
for (let r = 0; r < grid; r++) {
|
for (let r = 0; r < grid; r++) {
|
||||||
for (let c = 0; c < grid; c++) {
|
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 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 (
|
return (
|
||||||
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
|
`<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) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" }[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)}" />`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from "path";
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { toolsForUser } from "./sites.js";
|
import { toolsForUser } from "./sites.js";
|
||||||
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.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 { fetchMainLang, fetchReadme } from "./gitea.js";
|
||||||
import * as db from "./db.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.user = req.session.user || null;
|
||||||
res.locals.icon = icon;
|
res.locals.icon = icon;
|
||||||
res.locals.identicon = identicon;
|
res.locals.identicon = identicon;
|
||||||
|
res.locals.avatar = avatar;
|
||||||
res.locals.langColor = langColor;
|
res.locals.langColor = langColor;
|
||||||
res.locals.renderMarkdown = renderMarkdown;
|
res.locals.renderMarkdown = renderMarkdown;
|
||||||
res.locals.mdPlain = mdPlain;
|
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) => {
|
app.get("/", requireAuth, async (req, res) => {
|
||||||
const me = req.session.user.sabun;
|
const me = req.session.user.sabun;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<div class="usermenu">
|
<div class="usermenu">
|
||||||
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="who"><%= user.sabun %></span>
|
<span class="who"><%= user.sabun %></span>
|
||||||
<%- identicon(user.sabun, 26) %>
|
<%- avatar(user.sabun, 26) %>
|
||||||
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
|
<span class="caret"><%- icon("caret", { size: 14 }) %></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="usermenu-pop" id="userMenuPop" hidden>
|
<div class="usermenu-pop" id="userMenuPop" hidden>
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
<div class="usermenu-sabun"><%= user.sabun %></div>
|
<div class="usermenu-sabun"><%= user.sabun %></div>
|
||||||
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="usermenu-item" href="/u/<%= user.sabun %>"><span class="usermenu-ico"><%- icon("repo", { size: 15 }) %></span>내 프로필</a>
|
||||||
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
|
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
|
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-meta">
|
<div class="modal-meta">
|
||||||
<%- identicon(p.owner_sabun, 24) %>
|
<a class="meta-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
|
||||||
<span class="sabun" title="<%= p.owner_sabun %>"><%= p.owner_name || p.owner_sabun %></span>
|
<%- avatar(p.owner_sabun, 24) %>
|
||||||
|
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
|
||||||
|
</a>
|
||||||
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
|
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
|
||||||
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,15 +61,17 @@
|
|||||||
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
|
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (p.owner_sabun === me) { %>
|
<% if (p.owner_sabun === me) { %>
|
||||||
<form method="post" action="/projects/<%= p.id %>/visibility" style="margin-left:auto">
|
<div class="modal-actions-owner">
|
||||||
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
|
<form method="post" action="/projects/<%= p.id %>/visibility">
|
||||||
<input type="hidden" name="return_to" value="<%= returnTo %>" />
|
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
|
||||||
<button type="submit" class="modal-action"><%- icon(p.is_public ? "lock" : "globe", { size: 14 }) %><%= p.is_public ? "비공개로 전환" : "공개로 전환" %></button>
|
<input type="hidden" name="return_to" value="<%= returnTo %>" />
|
||||||
</form>
|
<button type="submit" class="modal-action"><%- icon(p.is_public ? "lock" : "globe", { size: 14 }) %><%= p.is_public ? "비공개로 전환" : "공개로 전환" %></button>
|
||||||
<form method="post" action="/projects/<%= p.id %>/delete">
|
</form>
|
||||||
<input type="hidden" name="return_to" value="/" />
|
<form method="post" action="/projects/<%= p.id %>/delete">
|
||||||
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</button>
|
<input type="hidden" name="return_to" value="/" />
|
||||||
</form>
|
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,10 +82,10 @@
|
|||||||
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
|
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
|
||||||
<div class="comment-item">
|
<div class="comment-item">
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<%- identicon(c.author_sabun, 28) %>
|
<a class="cavatar" href="/u/<%= c.author_sabun %>" title="<%= c.author_name || c.author_sabun %> 프로필"><%- avatar(c.author_sabun, 28) %></a>
|
||||||
<div class="comment-main">
|
<div class="comment-main">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
<span class="comment-author" title="<%= c.author_sabun %>"><%= c.author_name || c.author_sabun %></span>
|
<a class="comment-author" href="/u/<%= c.author_sabun %>"><%= c.author_name || c.author_sabun %></a>
|
||||||
<span class="comment-time"><%= reltime(c.created_at) %></span>
|
<span class="comment-time"><%= reltime(c.created_at) %></span>
|
||||||
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,10 +105,10 @@
|
|||||||
<div class="replies">
|
<div class="replies">
|
||||||
<% reps.forEach(function (r) { %>
|
<% reps.forEach(function (r) { %>
|
||||||
<div class="reply">
|
<div class="reply">
|
||||||
<%- identicon(r.author_sabun, 24) %>
|
<a class="cavatar" href="/u/<%= r.author_sabun %>" title="<%= r.author_name || r.author_sabun %> 프로필"><%- avatar(r.author_sabun, 24) %></a>
|
||||||
<div class="comment-main">
|
<div class="comment-main">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
<span class="comment-author" title="<%= r.author_sabun %>"><%= r.author_name || r.author_sabun %></span>
|
<a class="comment-author" href="/u/<%= r.author_sabun %>"><%= r.author_name || r.author_sabun %></a>
|
||||||
<span class="comment-time"><%= reltime(r.created_at) %></span>
|
<span class="comment-time"><%= reltime(r.created_at) %></span>
|
||||||
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +138,7 @@
|
|||||||
<button type="button" data-cancel-reply>취소</button>
|
<button type="button" data-cancel-reply>취소</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
<%- identicon(me, 28) %>
|
<%- avatar(me, 28) %>
|
||||||
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
|
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
|
||||||
<button type="submit" class="submit-btn">등록</button>
|
<button type="submit" class="submit-btn">등록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,11 +157,11 @@
|
|||||||
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="fcard-foot">
|
<div class="fcard-foot">
|
||||||
<div class="fcard-author">
|
<a class="fcard-author" href="/u/<%= p.owner_sabun %>" title="<%= p.owner_name || p.owner_sabun %> 프로필">
|
||||||
<%- identicon(p.owner_sabun, 22) %>
|
<%- avatar(p.owner_sabun, 22) %>
|
||||||
<span class="sabun" title="<%= p.owner_sabun %>"><%= p.owner_name || p.owner_sabun %></span>
|
<span class="sabun"><%= p.owner_name || p.owner_sabun %></span>
|
||||||
<span class="date">· <%= fdate(p.created_at) %></span>
|
<span class="date">· <%= fdate(p.created_at) %></span>
|
||||||
</div>
|
</a>
|
||||||
<div class="fcard-meta">
|
<div class="fcard-meta">
|
||||||
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
||||||
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
|
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
|
||||||
|
|||||||
104
views/profile.ejs
Normal file
104
views/profile.ejs
Normal file
@@ -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"); }
|
||||||
|
%>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<!-- 프로필 히어로 -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="profile-hero">
|
||||||
|
<div class="profile-avatar-wrap">
|
||||||
|
<img class="profile-avatar" id="profileAvatar" src="/avatar/<%= profile.sabun %>" alt="<%= profile.name %>" width="96" height="96" />
|
||||||
|
<% if (isSelf) { %>
|
||||||
|
<button type="button" class="profile-avatar-edit" id="avatarEditBtn" title="프로필 사진 변경" aria-label="프로필 사진 변경"><%- icon("plus", { size: 15, fill: "#fff" }) %></button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<h1 class="profile-name"><%= profile.name %></h1>
|
||||||
|
<div class="profile-sabun"><%= profile.sabun %></div>
|
||||||
|
<div class="profile-metaline">
|
||||||
|
<span><%= profile.project_count %>개 프로젝트</span>
|
||||||
|
<span>· 가입 <%= fdate(profile.created_at) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 프로젝트 목록 -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-titlewrap">
|
||||||
|
<h2 class="section-title"><%= isSelf ? "내 프로젝트" : (profile.name + " 님의 프로젝트") %></h2>
|
||||||
|
<span class="section-sub"><%= isSelf ? "공개·비공개 모두 표시" : "공개된 프로젝트만 표시" %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!projects.length) { %>
|
||||||
|
<div class="empty"><%= isSelf ? "아직 등록한 프로젝트가 없습니다." : "아직 공개된 프로젝트가 없습니다." %></div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="feed-grid">
|
||||||
|
<% projects.forEach(function (p) { var tags = tagList(p.tags); %>
|
||||||
|
<a class="fcard" href="/projects/<%= p.id %>">
|
||||||
|
<div class="fcard-top">
|
||||||
|
<div class="fcard-titlewrap">
|
||||||
|
<span class="pcard-repoico"><%- icon("repo", { size: 16 }) %></span>
|
||||||
|
<span class="fcard-title"><%= p.title %></span>
|
||||||
|
</div>
|
||||||
|
<span class="vis-badge <%= p.is_public ? "public" : "private" %>">
|
||||||
|
<%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fcard-desc"><%= mdPlain(p.description) || "설명 없음" %></div>
|
||||||
|
<% if (tags.length) { %>
|
||||||
|
<div class="fcard-tags"><% tags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
|
||||||
|
<% } %>
|
||||||
|
<div class="fcard-foot">
|
||||||
|
<div class="fcard-meta">
|
||||||
|
<% if (p.lang) { %><span class="stat"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
|
||||||
|
<span class="stat"><span class="ico-heart"><%- icon("heartFill", { size: 13 }) %></span><span class="tabnum"><%= p.heart_count %></span></span>
|
||||||
|
<span class="stat"><span class="ico-star"><%- icon("starFill", { size: 13 }) %></span><span class="tabnum"><%= p.star_count %></span></span>
|
||||||
|
<span class="stat"><span class="ico-comment"><%- icon("comment", { size: 14 }) %></span><span class="tabnum"><%= p.comment_count %></span></span>
|
||||||
|
</div>
|
||||||
|
<span class="date"><%= fdate(p.created_at) %></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<% if (isSelf) { %>
|
||||||
|
<!-- 프로필 사진 크롭 모달 -->
|
||||||
|
<div class="modal-overlay" id="cropModal" hidden>
|
||||||
|
<div class="crop-modal">
|
||||||
|
<div class="crop-head">
|
||||||
|
<h3>프로필 사진</h3>
|
||||||
|
<button type="button" class="modal-close" id="cropClose" title="닫기"><%- icon("x", { size: 16 }) %></button>
|
||||||
|
</div>
|
||||||
|
<div class="crop-body">
|
||||||
|
<div class="crop-stage" id="cropStage">
|
||||||
|
<img id="cropImg" alt="" draggable="false" />
|
||||||
|
<div class="crop-veil"></div>
|
||||||
|
</div>
|
||||||
|
<label class="crop-zoom">
|
||||||
|
<span><%- icon("search", { size: 13 }) %></span>
|
||||||
|
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
|
||||||
|
</label>
|
||||||
|
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot-actions">
|
||||||
|
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
|
||||||
|
<button type="button" class="btn" id="cropPick">다른 사진 선택</button>
|
||||||
|
<button type="button" class="btn btn-success" id="cropSave">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="module" src="/public/js/main.js"></script>
|
||||||
|
<% if (isSelf) { %><script type="module" src="/public/js/profile.js"></script><% } %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user