Compare commits
5 Commits
0ed41849ca
...
d95f968800
| Author | SHA1 | Date | |
|---|---|---|---|
| d95f968800 | |||
| 75685e41da | |||
| 56430b7701 | |||
| ef3d495878 | |||
| 84ce8567e7 |
@@ -56,14 +56,4 @@ svg { display: block; }
|
|||||||
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
|
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
|
||||||
@keyframes aidev-toast { 0%{transform:translateX(-50%) translateY(12px);opacity:0} 12%{transform:translateX(-50%) translateY(0);opacity:1} 88%{transform:translateX(-50%) translateY(0);opacity:1} 100%{transform:translateX(-50%) translateY(8px);opacity:0} }
|
@keyframes aidev-toast { 0%{transform:translateX(-50%) translateY(12px);opacity:0} 12%{transform:translateX(-50%) translateY(0);opacity:1} 88%{transform:translateX(-50%) translateY(0);opacity:1} 100%{transform:translateX(-50%) translateY(8px);opacity:0} }
|
||||||
|
|
||||||
/* 반응형 */
|
/* 반응형 규칙은 public/css/responsive.css 로 이동(로드 순서상 마지막이어야 실제 적용됨). */
|
||||||
@media (max-width: 900px) {
|
|
||||||
.tools-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.myproj-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.feed-grid { grid-template-columns: 1fr; }
|
|
||||||
.brand-sub { display: none; }
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.tools-grid, .myproj-grid { grid-template-columns: 1fr; }
|
|
||||||
.hdr-search { display: none; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -157,9 +157,21 @@
|
|||||||
.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 { 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-avatar-edit:hover { filter: brightness(1.08); }
|
||||||
.profile-info { min-width: 0; }
|
.profile-info { min-width: 0; }
|
||||||
|
.profile-idline { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.admin-badge-wrap { display: inline-flex; align-self: center; margin-left: -2px; cursor: default; }
|
||||||
.profile-name { margin: 0; font-size: 24px; font-weight: 800; color: var(--fg); letter-spacing: -.01em; }
|
.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-sabun { 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); }
|
.profile-metaline { margin-top: 6px; display: flex; gap: 6px; font-size: 12.5px; color: var(--fg3); }
|
||||||
|
|
||||||
|
/* 한줄 소개 */
|
||||||
|
.profile-bio-row { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.profile-bio { margin: 0; font-size: 13.5px; color: var(--fg2); line-height: 1.5; word-break: keep-all; overflow-wrap: break-word; }
|
||||||
|
.profile-bio.is-empty { color: var(--fg3); font-style: italic; }
|
||||||
|
.profile-bio-edit { flex: 0 0 auto; width: 24px; height: 24px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--fg3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.profile-bio-edit:hover { color: var(--fg); border-color: var(--fg3); }
|
||||||
|
.profile-bio-form { margin-top: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.profile-bio-form input { flex: 1 1 240px; min-width: 0; padding: 6px 10px; font-size: 13.5px; color: var(--fg); background: var(--card); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.profile-bio-form input:focus { outline: none; border-color: var(--link); }
|
||||||
|
|
||||||
/* ===================== 프로필 사진 크롭 모달 ===================== */
|
/* ===================== 프로필 사진 크롭 모달 ===================== */
|
||||||
.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-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; }
|
||||||
|
|||||||
59
public/css/responsive.css
Normal file
59
public/css/responsive.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* 반응형(모바일) — 데스크톱 규칙을 덮어써야 하므로 반드시 다른 CSS 이후 마지막에 로드한다.
|
||||||
|
(_header.ejs 의 <link> 순서가 바뀌면 미디어쿼리가 무력화됨. 특히 헤더 검색창 재노출이 로드 순서에 의존.)
|
||||||
|
데스크톱(>640px 등)은 아래 규칙이 적용되지 않아 기존과 완전히 동일하다. */
|
||||||
|
|
||||||
|
/* ===================== 그리드 단계 축소 ===================== */
|
||||||
|
/* (기존 base.css 에 있었으나 로드 순서 때문에 무력했던 규칙 — 여기로 옮겨 실제 작동) */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tools-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.myproj-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.feed-grid { grid-template-columns: 1fr; }
|
||||||
|
.brand-sub { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.tools-grid, .myproj-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== 모바일 레이아웃 · 터치 다듬기 ===================== */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* 헤더: 고정 높이 해제 → 줄바꿈 허용. 검색창은 둘째 줄 전체 폭으로. */
|
||||||
|
.hdr-inner { flex-wrap: wrap; height: auto; min-height: 56px; padding: 8px 16px; row-gap: 8px; }
|
||||||
|
.brand { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.hdr-right { margin-left: auto; } /* 브랜드와 같은 첫 줄, 우측 정렬 유지 */
|
||||||
|
.hdr-search { display: flex; order: 10; flex-basis: 100%; max-width: none; } /* base 의 display:none 을 로드순서로 이김 → 둘째 줄 */
|
||||||
|
|
||||||
|
/* 터치 영역 ≥44px */
|
||||||
|
.icon-btn, .modal-close { width: 44px; height: 44px; }
|
||||||
|
.hdr-link, .usermenu-btn { min-height: 44px; }
|
||||||
|
.profile-bio-edit { width: 40px; height: 40px; } /* 배지형이라 44는 과함 */
|
||||||
|
.profile-avatar-edit { width: 36px; height: 36px; }
|
||||||
|
.filter-tab, .sort-tab, .vis-opt, .react-btn, .readme-btn,
|
||||||
|
.toggle-share-btn, .share-mini-btn, .new-proj-btn, .feed-star-btn,
|
||||||
|
.comment-link, .modal-action, .btn { min-height: 44px; }
|
||||||
|
|
||||||
|
/* 크롭 스테이지 정사각 유지(profile.js 의 clientWidth==clientHeight 가정 충족) */
|
||||||
|
.crop-stage { width: 100%; height: auto; aspect-ratio: 1 / 1; }
|
||||||
|
|
||||||
|
/* 입력창 16px — iOS 포커스 시 자동확대 방지 */
|
||||||
|
.hdr-search input, .share-input, .share-input.mono,
|
||||||
|
.comment-input, .profile-bio-form input { font-size: 16px; }
|
||||||
|
|
||||||
|
/* 좌우 여백 축소 (24px → 16px) */
|
||||||
|
.main { padding: 20px 16px 48px; }
|
||||||
|
.detail-wrap { padding: 20px 16px 48px; } /* project.ejs 단독 상세 */
|
||||||
|
.footer-inner { padding: 16px; }
|
||||||
|
|
||||||
|
/* 줄바꿈 / 프로필 히어로 세로 스택 */
|
||||||
|
.section-head, .feed-head, .feed-head-right { flex-wrap: wrap; }
|
||||||
|
.profile-hero { flex-direction: column; align-items: center; text-align: center; gap: 14px; }
|
||||||
|
.profile-idline, .profile-metaline, .profile-bio-row, .profile-bio-form { justify-content: center; }
|
||||||
|
|
||||||
|
/* 모달 여백 축소 */
|
||||||
|
.modal-overlay { padding: 16px 12px; }
|
||||||
|
.modal-head { padding: 16px 16px 14px; }
|
||||||
|
.modal-body { padding: 14px 16px 4px; }
|
||||||
|
.modal-foot { padding: 12px 16px 18px; }
|
||||||
|
.modal-foot-actions{ padding: 12px 16px; }
|
||||||
|
.share-head, .share-body, .crop-body { padding: 16px; }
|
||||||
|
.crop-head { padding: 14px 16px; }
|
||||||
|
}
|
||||||
@@ -4,7 +4,53 @@ import { $, toast } from "./util.js";
|
|||||||
|
|
||||||
const OUT = 256; // 저장 이미지 한 변(px)
|
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; saveBtn.textContent = "저장 중…"; }
|
||||||
|
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; saveBtn.textContent = "저장"; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function initProfile() {
|
export function initProfile() {
|
||||||
|
initBio();
|
||||||
|
|
||||||
const editBtn = $("#avatarEditBtn");
|
const editBtn = $("#avatarEditBtn");
|
||||||
const modal = $("#cropModal");
|
const modal = $("#cropModal");
|
||||||
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음
|
if (!editBtn || !modal) return; // 본인 프로필이 아니면 크롭 UI 없음
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar bytea;
|
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_mime text;
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_updated_at timestamptz;
|
||||||
|
-- 한줄 소개(프로필에 표시). 본인만 편집.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text DEFAULT '';
|
||||||
|
-- 관리자 여부. Keycloak 그룹 기준으로 로그인 시마다 갱신(소스 오브 트루스는 Keycloak).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
|
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
|||||||
19
src/db.js
19
src/db.js
@@ -6,14 +6,14 @@ export const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
|||||||
|
|
||||||
export const q = (text, params) => pool.query(text, params);
|
export const q = (text, params) => pool.query(text, params);
|
||||||
|
|
||||||
// 로그인 시 사용자 upsert
|
// 로그인 시 사용자 upsert. is_admin 은 Keycloak 그룹 판별값을 매 로그인마다 갱신.
|
||||||
export async function upsertUser({ sabun, name, email }) {
|
export async function upsertUser({ sabun, name, email, isAdmin }) {
|
||||||
await q(
|
await q(
|
||||||
`INSERT INTO users (sabun, name, email, avatar_seed, last_login)
|
`INSERT INTO users (sabun, name, email, avatar_seed, is_admin, last_login)
|
||||||
VALUES ($1,$2,$3,$1, now())
|
VALUES ($1,$2,$3,$1,$4, now())
|
||||||
ON CONFLICT (sabun) DO UPDATE
|
ON CONFLICT (sabun) DO UPDATE
|
||||||
SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`,
|
SET name=EXCLUDED.name, email=EXCLUDED.email, is_admin=EXCLUDED.is_admin, last_login=now()`,
|
||||||
[sabun, name, email || null]
|
[sabun, name, email || null, !!isAdmin]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ export async function setAvatar({ sabun, buf, mime }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 한줄 소개 저장(본인). 80자 이내로 잘라 보관.
|
||||||
|
export async function setBio({ sabun, bio }) {
|
||||||
|
await q(`UPDATE users SET bio=$2 WHERE sabun=$1`, [sabun, String(bio || "").slice(0, 80)]);
|
||||||
|
}
|
||||||
|
|
||||||
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
|
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
|
||||||
export async function getAvatar(sabun) {
|
export async function getAvatar(sabun) {
|
||||||
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
|
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
|
||||||
@@ -34,7 +39,7 @@ export async function getAvatar(sabun) {
|
|||||||
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
||||||
export async function getUserProfile(sabun) {
|
export async function getUserProfile(sabun) {
|
||||||
const { rows } = await q(
|
const { rows } = await q(
|
||||||
`SELECT u.sabun, u.name, u.email, u.created_at,
|
`SELECT u.sabun, u.name, u.email, u.created_at, u.bio, u.is_admin,
|
||||||
(u.avatar IS NOT NULL) AS has_avatar,
|
(u.avatar IS NOT NULL) AS has_avatar,
|
||||||
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
||||||
FROM users u WHERE u.sabun=$1`,
|
FROM users u WHERE u.sabun=$1`,
|
||||||
|
|||||||
@@ -42,6 +42,49 @@ export function icon(name, opts = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 인증 뱃지 — 골드 스캘럽 씰 + 흰 체크(다색이라 icon() 대신 전용 SVG).
|
||||||
|
// 관리자 판별은 Keycloak 그룹 기준(users.is_admin 에 로그인 시 저장). 프로필 사번 옆에 표시.
|
||||||
|
function scallopPath(cx, cy, n, rValley, rOuter) {
|
||||||
|
// 계곡점(rValley)들을 바깥으로 볼록한 원호로 이어 씰 톱니를 만든다.
|
||||||
|
const half = Math.PI / n;
|
||||||
|
const sag = rOuter - rValley * Math.cos(half); // 볼록 높이(sagitta)
|
||||||
|
const c = 2 * rValley * Math.sin(half); // 현 길이
|
||||||
|
const r = (sag * sag + (c / 2) * (c / 2)) / (2 * sag); // 원호 반지름
|
||||||
|
let d = "";
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a0 = (i / n) * 2 * Math.PI - Math.PI / 2;
|
||||||
|
const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2;
|
||||||
|
const x0 = (cx + rValley * Math.cos(a0)).toFixed(2), y0 = (cy + rValley * Math.sin(a0)).toFixed(2);
|
||||||
|
const x1 = (cx + rValley * Math.cos(a1)).toFixed(2), y1 = (cy + rValley * Math.sin(a1)).toFixed(2);
|
||||||
|
if (i === 0) d += `M ${x0} ${y0} `;
|
||||||
|
d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${x1} ${y1} `;
|
||||||
|
}
|
||||||
|
return d + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminBadge(opts = {}) {
|
||||||
|
const size = opts.size || 15;
|
||||||
|
const title = opts.title || "관리자 인증";
|
||||||
|
const scallop = scallopPath(8, 8, 11, 5.7, 7.5);
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" ` +
|
||||||
|
`class="admin-badge" role="img" aria-label="${title}" style="display:block"><title>${title}</title>` +
|
||||||
|
`<defs>` +
|
||||||
|
`<linearGradient id="aidevAdminGold" x1="0" y1="0" x2="0" y2="1">` +
|
||||||
|
`<stop offset="0" stop-color="#fbe58c"/><stop offset="0.45" stop-color="#eabf34"/><stop offset="1" stop-color="#c68f16"/>` +
|
||||||
|
`</linearGradient>` +
|
||||||
|
`<radialGradient id="aidevAdminGloss" cx="0.5" cy="0.32" r="0.65">` +
|
||||||
|
`<stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/><stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>` +
|
||||||
|
`</radialGradient>` +
|
||||||
|
`</defs>` +
|
||||||
|
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGold)"/>` +
|
||||||
|
`<path d="${scallop}" fill="url(#aidevAdminGold)" stroke="#a9790f" stroke-opacity="0.55" stroke-width="0.5"/>` +
|
||||||
|
`<circle cx="8" cy="8" r="5.7" fill="url(#aidevAdminGloss)"/>` +
|
||||||
|
`<path d="M4.8 8.25 L6.85 10.25 L11.15 5.55" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>` +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 프로젝트 설명(README 형식) 마크다운 렌더링.
|
// 프로젝트 설명(README 형식) 마크다운 렌더링.
|
||||||
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
|
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
|
||||||
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
|
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
|
||||||
|
|||||||
@@ -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, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
|
import { icon, identicon, identiconSvg, avatar, adminBadge, 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";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ app.use((req, res, next) => {
|
|||||||
res.locals.icon = icon;
|
res.locals.icon = icon;
|
||||||
res.locals.identicon = identicon;
|
res.locals.identicon = identicon;
|
||||||
res.locals.avatar = avatar;
|
res.locals.avatar = avatar;
|
||||||
|
res.locals.adminBadge = adminBadge;
|
||||||
res.locals.langColor = langColor;
|
res.locals.langColor = langColor;
|
||||||
res.locals.renderMarkdown = renderMarkdown;
|
res.locals.renderMarkdown = renderMarkdown;
|
||||||
res.locals.mdPlain = mdPlain;
|
res.locals.mdPlain = mdPlain;
|
||||||
@@ -122,6 +123,16 @@ app.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- 한줄 소개 저장(본인) ---
|
||||||
|
// JS 있으면 fetch(JSON), 없으면 폼 POST → 프로필로 리다이렉트(점진적 향상).
|
||||||
|
app.post("/profile/bio", requireAuth, async (req, res) => {
|
||||||
|
const sabun = req.session.user.sabun;
|
||||||
|
const bio = String(req.body.bio || "").trim().slice(0, 80);
|
||||||
|
await db.setBio({ sabun, bio });
|
||||||
|
if (isAjax(req)) return res.json({ ok: true, bio });
|
||||||
|
res.redirect("/u/" + encodeURIComponent(sabun));
|
||||||
|
});
|
||||||
|
|
||||||
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
|
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
|
||||||
app.get("/u/:sabun", requireAuth, async (req, res) => {
|
app.get("/u/:sabun", requireAuth, async (req, res) => {
|
||||||
const me = req.session.user.sabun;
|
const me = req.session.user.sabun;
|
||||||
|
|||||||
12
views/_footer.ejs
Normal file
12
views/_footer.ejs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<%
|
||||||
|
// 공용 푸터. 대시보드·프로필 등 모든 화면에서 include 해서 재사용.
|
||||||
|
%>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-left">
|
||||||
|
<span class="footer-brand"><span><%- icon("code", { size: 14 }) %></span><%= brand %></span>
|
||||||
|
<span class="footer-bug">· 버그 제보 - <a href="https://gitea.bokdev.in/playground/ai-dev-portal/issues" target="_blank" rel="noopener">gitea 이슈</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-tech">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<link rel="stylesheet" href="/public/css/layout.css" />
|
<link rel="stylesheet" href="/public/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/public/css/components.css" />
|
<link rel="stylesheet" href="/public/css/components.css" />
|
||||||
<link rel="stylesheet" href="/public/css/modal.css" />
|
<link rel="stylesheet" href="/public/css/modal.css" />
|
||||||
|
<!-- 반응형(모바일) — 데스크톱 규칙을 덮어써야 하므로 반드시 맨 마지막에 로드. 순서 바꾸지 말 것. -->
|
||||||
|
<link rel="stylesheet" href="/public/css/responsive.css" />
|
||||||
<script>
|
<script>
|
||||||
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
|
// FOUC 방지: 저장된 테마를 페인트 전에 documentElement 에 적용
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
|
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span class="share-label">저장소 URL <span class="hint">선택 · 먼저 넣으면 README·언어를 자동으로 가져와요</span></span>
|
<span class="share-label">Git 저장소 URL <span class="hint">선택 · 먼저 넣으면 README·언어를 자동으로 가져와요</span></span>
|
||||||
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
||||||
<small class="share-err" id="repoErr" hidden>git URL 을 입력하면 README 를 가져옵니다</small>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span class="share-label share-label-row">
|
<span class="share-label share-label-row">
|
||||||
|
|||||||
@@ -176,15 +176,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<%- include("_footer") %>
|
||||||
<div class="footer-inner">
|
|
||||||
<div class="footer-left">
|
|
||||||
<span class="footer-brand"><span><%- icon("code", { size: 14 }) %></span><%= brand %></span>
|
|
||||||
<span class="footer-bug">· 버그 제보: <a href="https://gitea.bokdev.in/playground/ai-dev-portal/issues" target="_blank" rel="noopener">gitea 이슈</a></span>
|
|
||||||
</div>
|
|
||||||
<div class="footer-tech">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
|
<!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
|
||||||
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
|
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
|
||||||
|
|||||||
@@ -17,12 +17,31 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
|
<div class="profile-idline">
|
||||||
<h1 class="profile-name"><%= profile.name %></h1>
|
<h1 class="profile-name"><%= profile.name %></h1>
|
||||||
<div class="profile-sabun"><%= profile.sabun %></div>
|
<span class="profile-sabun"><%= profile.sabun %></span>
|
||||||
|
<% if (profile.is_admin) { %><span class="admin-badge-wrap" title="관리자 인증"><%- adminBadge({ size: 16 }) %></span><% } %>
|
||||||
|
</div>
|
||||||
<div class="profile-metaline">
|
<div class="profile-metaline">
|
||||||
<span><%= profile.project_count %>개 프로젝트</span>
|
<span><%= profile.project_count %>개 프로젝트</span>
|
||||||
<span>· 가입 <%= fdate(profile.created_at) %></span>
|
<span>· 가입 <%= fdate(profile.created_at) %></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (profile.bio || isSelf) { %>
|
||||||
|
<div class="profile-bio-row" id="bioRow">
|
||||||
|
<p class="profile-bio<%= profile.bio ? "" : " is-empty" %>" id="bioText"><%= profile.bio || "한줄 소개를 남겨보세요" %></p>
|
||||||
|
<% if (isSelf) { %>
|
||||||
|
<button type="button" class="profile-bio-edit" id="bioEditBtn" title="한줄 소개 편집" aria-label="한줄 소개 편집"><%- icon("pencil", { size: 13 }) %></button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (isSelf) { %>
|
||||||
|
<form class="profile-bio-form" id="bioForm" method="post" action="/profile/bio" hidden>
|
||||||
|
<input type="text" name="bio" id="bioInput" maxlength="80" value="<%= profile.bio || "" %>" placeholder="한줄 소개 (최대 80자)" autocomplete="off" />
|
||||||
|
<button type="submit" class="btn btn-success" id="bioSave">저장</button>
|
||||||
|
<button type="button" class="btn" id="bioCancel">취소</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -72,6 +91,8 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<%- include("_footer") %>
|
||||||
|
|
||||||
<% if (isSelf) { %>
|
<% if (isSelf) { %>
|
||||||
<!-- 프로필 사진 크롭 모달 -->
|
<!-- 프로필 사진 크롭 모달 -->
|
||||||
<div class="modal-overlay" id="cropModal" hidden>
|
<div class="modal-overlay" id="cropModal" hidden>
|
||||||
@@ -89,7 +110,7 @@
|
|||||||
<span><%- icon("search", { size: 13 }) %></span>
|
<span><%- icon("search", { size: 13 }) %></span>
|
||||||
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
|
<input type="range" id="cropZoom" min="1" max="4" step="0.01" value="1" />
|
||||||
</label>
|
</label>
|
||||||
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.</p>
|
<p class="crop-hint">사진을 드래그해 위치를 맞추고 슬라이더로 확대하세요. 정사각형으로 저장됩니다.<br>변경한 사진이 모든 화면에 반영되기까지 최대 몇 분 걸릴 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot-actions">
|
<div class="modal-foot-actions">
|
||||||
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
|
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp" hidden />
|
||||||
|
|||||||
Reference in New Issue
Block a user