Files
emdash-patch-imageupload/fixtures/perf-site/src/pages/index.astro
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

464 lines
11 KiB
Plaintext

---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>