From 1f5179cd0489fafdba550a6acccbbb989e5c3a80 Mon Sep 17 00:00:00 2001 From: 2620227 Date: Tue, 7 Jul 2026 13:15:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(projects):=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=ED=8F=BC=C2=B7=EC=B9=B4=EB=93=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=C2=B7README=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 모달: 저장소 URL 필드를 제목 아래(설명 위)로 이동 — README 가져오기 흐름 자연화 - URL 미입력 상태로 README 가져오기 시 입력란에 빨간 인라인 안내 표시(토스트 대신) - 피드 카드: 설명 미리보기 2줄 클램프 + flex:1 로 태그·푸터를 카드 하단에 정렬 - fetchReadme: CRLF→LF·줄끝공백·연속빈줄 정리로 불필요한 개행 유입 방지 - mdPlain: 개행을 살려(빈 줄만 제거) 카드 미리보기가 README 줄 구조로 보이게 Co-Authored-By: Claude Opus 4.8 --- public/css/components.css | 3 ++- public/css/modal.css | 3 +++ public/js/modals.js | 19 ++++++++++++++++++- src/gitea.js | 16 +++++++++++++++- src/helpers.js | 12 ++++++++---- views/_project_form_modal.ejs | 11 ++++++----- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/public/css/components.css b/public/css/components.css index d5fa4a1..b44b298 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -130,7 +130,8 @@ .react-btn .ico-on, .modal-action .ico-on { display: none; } .react-btn.on .ico-on, .modal-action.on .ico-on { display: inline-flex; } .react-btn.on .ico-off, .modal-action.on .ico-off { display: none; } -.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } +/* flex:1 로 설명 영역이 남는 높이를 채워, 태그+푸터를 카드 하단에 정렬한다(짧은 카드의 큰 빈 공간 방지). 미리보기는 2줄까지만. */ +.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; flex: 1; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } .fcard-tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; } .fcard-foot { margin-top: 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .fcard-author { display: flex; align-items: center; gap: 8px; min-width: 0; } diff --git a/public/css/modal.css b/public/css/modal.css index 63c47b5..ed0b076 100644 --- a/public/css/modal.css +++ b/public/css/modal.css @@ -111,6 +111,9 @@ .readme-btn:hover { background: var(--btn-hover); color: var(--fg); } .readme-btn:disabled { opacity: .6; cursor: default; } .share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); } +/* README 가져오기 실패(URL 미입력) 시 빨간 안내 + 입력란 강조 */ +.share-err { display: block; margin-top: 6px; font-size: 12px; font-weight: 600; color: var(--danger); } +.share-input.err { border-color: var(--danger); } .vis-toggle { display: inline-flex; background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; } .vis-opt { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; } .vis-opt.on-public { background: var(--success-bg); color: var(--success-fg); box-shadow: inset 0 0 0 1px var(--success-bd); } diff --git a/public/js/modals.js b/public/js/modals.js index 1589972..b0ae8dd 100644 --- a/public/js/modals.js +++ b/public/js/modals.js @@ -2,14 +2,25 @@ import { $, $$, toast } from "./util.js"; import { applyReactions } from "./reactions.js"; +// 저장소 URL 입력란의 빨간 안내(README 가져오기 전제)를 지운다. +function clearRepoErr(m) { + const err = $("#repoErr", m); + const repoInput = $('input[name="repo_url"]', m); + if (err) err.hidden = true; + if (repoInput) repoInput.classList.remove("err"); +} + // 등록 모달의 "저장소 README 가져오기" — repo_url 의 README 를 받아 설명란을 채운다. async function importReadme() { const m = $("#shareModal"); if (!m) return; const btn = $("#readmeImport", m); const repoInput = $('input[name="repo_url"]', m); + const err = $("#repoErr", m); const ta = $('textarea[name="description"]', m); const repo = ((repoInput && repoInput.value) || "").trim(); - if (!repo) { toast("먼저 저장소 URL 을 입력하세요"); if (repoInput) repoInput.focus(); return; } + // URL 미입력 → 입력란 옆에 빨간 안내를 띄우고 포커스만 준다(토스트 대신 인라인). + if (!repo) { if (err) err.hidden = false; if (repoInput) { repoInput.classList.add("err"); repoInput.focus(); } return; } + clearRepoErr(m); if (ta && ta.value.trim() && !window.confirm("현재 설명 내용을 README 로 덮어쓸까요?")) return; const label = btn ? btn.innerHTML : ""; if (btn) { btn.disabled = true; btn.textContent = "가져오는 중…"; } @@ -74,6 +85,7 @@ function openShare() { const h = $("#shareTitle", m); if (h) h.textContent = "새 프로젝트 등록"; const sub = $("#shareSubmit", m); if (sub) sub.textContent = "등록"; ["title", "description", "tags", "repo_url", "app_url", "lang", "return_to"].forEach((n) => fieldSet(m, n, "")); + clearRepoErr(m); setVis(m, true); m.hidden = false; document.body.style.overflow = "hidden"; const t = $('input[name="title"]', m); if (t) t.focus(); @@ -94,6 +106,7 @@ function openEdit(btn) { fieldSet(m, "lang", btn.getAttribute("data-lang")); fieldSet(m, "return_to", btn.getAttribute("data-return")); setVis(m, btn.getAttribute("data-public") === "1"); + clearRepoErr(m); m.hidden = false; document.body.style.overflow = "hidden"; const t = $('input[name="title"]', m); if (t) { t.focus(); t.select(); } } @@ -138,5 +151,9 @@ export function initModals() { if (hint) hint.textContent = val === "public" ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능"; } }); + // 저장소 URL 을 입력하기 시작하면 빨간 안내를 즉시 지운다. + document.addEventListener("input", (e) => { + if (e.target && e.target.name === "repo_url") { const m = $("#shareModal"); if (m) clearRepoErr(m); } + }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeShare(); } }); } diff --git a/src/gitea.js b/src/gitea.js index 3c23c20..71133df 100644 --- a/src/gitea.js +++ b/src/gitea.js @@ -49,6 +49,20 @@ export async function fetchMainLang(repoUrl) { } } +// raw README 의 개행을 정리한다. Gitea 화면(렌더링)에는 없지만 raw 원문에 남아 있는 +// 군더더기 개행이 설명란에 "없던 엔터"처럼 들어오는 문제를 막는다. +// - CRLF/CR → LF 로 통일(Windows 개행의 \r 제거) +// - 줄 끝 공백 제거 +// - 3줄 이상 연속 빈 줄 → 빈 줄 1개(2개 개행)로 축소 (마크다운상 동일) +// - 문서 앞뒤 공백/개행 제거 +function normalizeReadme(text) { + return String(text) + .replace(/\r\n?/g, "\n") + .replace(/[ \t]+$/gm, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + // 레포의 README 원문(마크다운)을 반환. 실패/없음이면 "". // Gitea raw 엔드포인트로 기본 브랜치의 README 파일을 후보 순서대로 시도한다. export async function fetchReadme(repoUrl) { @@ -65,7 +79,7 @@ export async function fetchReadme(repoUrl) { for (const name of candidates) { const res = await fetch(`${base}/${name}`, { headers, signal: ctrl.signal }); if (!res.ok) continue; - const text = await res.text(); + const text = normalizeReadme(await res.text()); // 너무 큰 README 는 앞부분만(설명란이 감당할 범위). 20KB 컷. return text.length > 20000 ? text.slice(0, 20000) : text; } diff --git a/src/helpers.js b/src/helpers.js index f97b85d..bf3f2ec 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -59,19 +59,23 @@ export function renderMarkdown(src) { return md.render(String(src || "")); } -// 카드 미리보기용 — 마크다운 기호를 걷어낸 순수 텍스트 한 덩어리. -// (제목 #, 목록 -, 강조 *_`, 링크 [텍스트](url) → 텍스트, 코드펜스 ``` 제거) +// 카드 미리보기용 — 마크다운 기호를 걷어낸 순수 텍스트. 줄바꿈은 살린다. +// (제목 #, 목록 -, 강조 *_`, 링크 [텍스트](url) → 텍스트, 코드펜스/이미지/배지 제거) +// 개행을 공백으로 뭉개지 않고 유지해, 2줄 클램프 미리보기가 README 앞부분의 +// 줄 구조 그대로 보이도록 한다(제목 줄 + 첫 설명 줄 등). 빈 줄은 없애 줄 낭비를 막는다. export function mdPlain(src) { return String(src || "") .replace(/```[\s\S]*?```/g, " ") // 코드펜스 블록 제거 .replace(/`([^`]*)`/g, "$1") // 인라인 코드 .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // 이미지 - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 링크 → 텍스트 + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 링크(배지 포함) → 텍스트 .replace(/^\s{0,3}#{1,6}\s+/gm, "") // 제목 마커 .replace(/^\s{0,3}>\s?/gm, "") // 인용 마커 .replace(/^\s*[-*+]\s+/gm, "") // 목록 마커 .replace(/[*_~]{1,3}/g, "") // 강조 기호 - .replace(/\s+/g, " ") + .replace(/[ \t]+/g, " ") // 줄 안의 연속 공백만 축소(개행은 유지) + .replace(/ *\n */g, "\n") // 줄 앞뒤 공백 제거 + .replace(/\n{2,}/g, "\n") // 빈 줄 제거 → 내용 줄만 남김 .trim(); } diff --git a/views/_project_form_modal.ejs b/views/_project_form_modal.ejs index 4d62d54..05f60f6 100644 --- a/views/_project_form_modal.ejs +++ b/views/_project_form_modal.ejs @@ -15,21 +15,22 @@ + -