feat(ai-cases): 성공 사례 카드에 PDF 첫 슬라이드 썸네일·그라데이션 개선

- 목록에서 슬라이드 워밍(최대 24건) 후 coverImageUrl 전달
- 카드 상단 풀너비 이미지+스크림, 무이미지 시 다층 그라데이션

Made-with: Cursor
This commit is contained in:
2026-04-08 20:15:44 +09:00
parent 81244d34c9
commit 14fa47922a
3 changed files with 150 additions and 47 deletions

View File

@@ -428,33 +428,93 @@ button {
.success-story-link {
display: grid;
gap: 8px;
gap: 0;
text-decoration: none;
color: inherit;
padding: 0;
}
.success-story-link-body {
display: grid;
gap: 8px;
padding: 12px 14px 14px;
}
.success-thumb {
border-radius: 10px;
min-height: 72px;
padding: 12px 14px;
background: linear-gradient(135deg, #0f766e, #14b8a6);
position: relative;
overflow: hidden;
border-radius: 12px 12px 0 0;
min-height: 100px;
color: #fff;
}
/* PDF 첫 장(또는 슬라이드) 썸네일 */
.success-thumb--cover {
min-height: 120px;
aspect-ratio: 16 / 9;
max-height: 200px;
}
.success-thumb-media {
position: absolute;
inset: 0;
}
.success-thumb-media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.success-thumb-scrim {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(15, 23, 42, 0.88) 0%,
rgba(15, 23, 42, 0.45) 45%,
rgba(15, 23, 42, 0.2) 100%
);
pointer-events: none;
}
.success-thumb-inner {
position: relative;
z-index: 1;
padding: 12px 14px;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-end;
gap: 4px;
min-height: 100%;
box-sizing: border-box;
}
/* 슬라이드 없음: 은은한 그라데이션 + 하이라이트 */
.success-thumb--gradient {
background:
radial-gradient(120% 80% at 100% 0%, rgba(45, 212, 191, 0.45), transparent 55%),
radial-gradient(90% 70% at 0% 100%, rgba(14, 116, 144, 0.5), transparent 50%),
linear-gradient(135deg, #0c4a6e 0%, #0e7490 42%, #0d9488 78%, #14b8a6 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.success-thumb--gradient .success-thumb-inner {
justify-content: center;
}
.success-thumb-icon {
font-size: 22px;
opacity: 0.95;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.success-thumb-kicker {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.success-badge {

View File

@@ -1189,21 +1189,43 @@ pageRouter.get("/ai-cases/write", (req, res) => {
});
});
pageRouter.get("/ai-cases", (req, res) => {
const q = (req.query.q || "").trim();
const tag = (req.query.tag || "").trim();
const meta = loadAiSuccessStoriesMeta();
const filtered = filterAiSuccessStories(meta, q, tag);
const tags = allAiSuccessStoryTags(meta);
res.render("ai-cases", {
activeMenu: "ai-cases",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res),
stories: filtered,
filters: { q, tag },
availableTags: tags,
});
pageRouter.get("/ai-cases", async (req, res, next) => {
try {
const q = (req.query.q || "").trim();
const tag = (req.query.tag || "").trim();
const meta = loadAiSuccessStoriesMeta();
const filtered = filterAiSuccessStories(meta, q, tag);
const tags = allAiSuccessStoryTags(meta);
/** 목록에서도 카드 썸네일을 쓰기 위해 PDF→슬라이드가 없으면 생성 시도(상세와 동일 소스) */
await Promise.all(
filtered.slice(0, 24).map(async (m) => {
const pdfUrl = (m.pdfUrl || "").trim();
if (!pdfUrl) return;
if (getAiSuccessSlideImageUrls(m.slug).length > 0) return;
try {
await ensureAiSuccessStorySlides(m.slug, pdfUrl);
} catch (err) {
console.warn("[ai-cases] 슬라이드 워밍 실패:", m.slug, err?.message || err);
}
})
);
const stories = filtered.map((m) => {
const slideUrls = getAiSuccessSlideImageUrls(m.slug);
const coverImageUrl = slideUrls.length > 0 ? slideUrls[0] : "";
return { ...m, coverImageUrl };
});
res.render("ai-cases", {
activeMenu: "ai-cases",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res),
stories,
filters: { q, tag },
availableTags: tags,
});
} catch (err) {
next(err);
}
});
pageRouter.get("/ai-cases/:slug", async (req, res, next) => {

View File

@@ -1,43 +1,64 @@
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
<% var _cover = story.coverImageUrl && String(story.coverImageUrl).trim(); %>
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
<% if (detailAllowed) { %>
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
<div class="success-thumb<%= _cover ? ' success-thumb--cover' : ' success-thumb--gradient' %>" aria-hidden="true">
<% if (_cover) { %>
<div class="success-thumb-media">
<img src="<%= _cover %>" alt="" loading="lazy" decoding="async" width="560" height="315" />
</div>
<div class="success-thumb-scrim"></div>
<% } %>
<div class="success-thumb-inner">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
<div class="success-story-link-body">
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</a>
<% } else { %>
<div
class="success-story-link"
title="로그인 후 이용 가능합니다."
>
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
<div class="success-thumb<%= _cover ? ' success-thumb--cover' : ' success-thumb--gradient' %>" aria-hidden="true">
<% if (_cover) { %>
<div class="success-thumb-media">
<img src="<%= _cover %>" alt="" loading="lazy" decoding="async" width="560" height="315" />
</div>
<div class="success-thumb-scrim"></div>
<% } %>
<div class="success-thumb-inner">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
<div class="success-story-link-body">
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
<% } %>
</article>