feat(projects): 등록 폼·카드 미리보기·README 가져오기 개선

- 등록 모달: 저장소 URL 필드를 제목 아래(설명 위)로 이동 — README 가져오기 흐름 자연화
- URL 미입력 상태로 README 가져오기 시 입력란에 빨간 인라인 안내 표시(토스트 대신)
- 피드 카드: 설명 미리보기 2줄 클램프 + flex:1 로 태그·푸터를 카드 하단에 정렬
- fetchReadme: CRLF→LF·줄끝공백·연속빈줄 정리로 불필요한 개행 유입 방지
- mdPlain: 개행을 살려(빈 줄만 제거) 카드 미리보기가 README 줄 구조로 보이게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:15:20 +09:00
parent e21d622ba9
commit 1f5179cd04
6 changed files with 52 additions and 12 deletions

View File

@@ -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; }

View File

@@ -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); }

View File

@@ -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(); } });
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -15,21 +15,22 @@
<span class="share-label">제목 <span class="req">*</span></span>
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
</label>
<label>
<span class="share-label">저장소 URL <span class="hint">선택 · 먼저 넣으면 README·언어를 자동으로 가져와요</span></span>
<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>
<span class="share-label share-label-row">
<span>설명 <span class="hint">Markdown 지원 · 제목·목록·코드·링크</span></span>
<button type="button" class="readme-btn" id="readmeImport"><%- icon("repo", { size: 12 }) %>저장소 README 가져오기</button>
</span>
<textarea class="share-input share-textarea" name="description" rows="5" placeholder="무엇을 하는 프로젝트인가요?&#10;&#10;## 예시&#10;- Markdown 문법을 그대로 쓸 수 있어요&#10;- 저장소 URL 을 넣으면 README 를 가져올 수 있어요"></textarea>
<textarea class="share-input share-textarea" name="description" rows="5" placeholder="무엇을 하는 프로젝트인가요?&#10;&#10;## 예시&#10;- Markdown 문법을 그대로 쓸 수 있어요&#10;- 저장소 URL 을 넣고 'README 가져오기' 를 누르면 채워져요"></textarea>
</label>
<label>
<span class="share-label">태그 <span class="hint">쉼표로 구분 · 최대 5개</span></span>
<input class="share-input" name="tags" placeholder="infra, react, poc" />
</label>
<label>
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
</label>
<label>
<span class="share-label">메인 언어 <span class="hint">선택 · 기본은 저장소에서 자동 감지</span></span>
<select class="share-input" name="lang">