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 @@
제목 *
+
-