Files
termi-blog/frontend/src/pages/articles/index.astro

516 lines
20 KiB
Plaintext

---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Category, Post, Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
export const prerender = false;
let allPosts: Post[] = [];
let allTags: Tag[] = [];
let allCategories: Category[] = [];
const url = new URL(Astro.request.url);
const selectedSearch = url.searchParams.get('search') || '';
const { t } = getI18n(Astro);
try {
const [posts, categories, rawTags] = await Promise.all([
selectedSearch ? api.searchPosts(selectedSearch) : api.getPosts(),
api.getCategories(),
api.getTags(),
]);
allPosts = posts;
allCategories = categories;
const seenTagIds = new Set<string>();
allTags = rawTags.filter(tag => {
const key = `${tag.slug}:${tag.name}`.toLowerCase();
if (seenTagIds.has(key)) return false;
seenTagIds.add(key);
return true;
});
} catch (error) {
console.error('API Error:', error);
}
const selectedType = url.searchParams.get('type') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedCategory = url.searchParams.get('category') || '';
const currentPage = parseInt(url.searchParams.get('page') || '1');
const postsPerPage = 10;
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag;
const isSelectedTag = (tag: Tag) =>
tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag;
const filteredPosts = allPosts.filter(post => {
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedTag && !post.tags?.some(isMatchingTag)) return false;
if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false;
return true;
});
const totalPosts = filteredPosts.length;
const totalPages = Math.ceil(totalPosts / postsPerPage);
const startIndex = (currentPage - 1) * postsPerPage;
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
const postTypeFilters = [
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const typePromptCommand =
selectedType === 'all'
? `grep -E "^type: (article|tweet)$" ./posts/*.md`
: `grep -E "^type: ${selectedType}$" ./posts/*.md`;
const categoryPromptCommand = selectedCategory
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
: `cut -d: -f2 ./categories.index | sort -u`;
const tagPromptCommand = selectedTag
? `grep -Ril "#${selectedTag}" ./posts`
: `cut -d: -f2 ./tags.index | sort -u`;
const categoryAccentMap = Object.fromEntries(
allCategories.map((category) => [category.name.toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
const tagAccentMap = Object.fromEntries(
allTags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
const buildArticlesUrl = ({
type = selectedType,
search = selectedSearch,
tag = selectedTag,
category = selectedCategory,
page,
}: {
type?: string;
search?: string;
tag?: string;
category?: string;
page?: number;
}) => {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (search) params.set('search', search);
if (tag) params.set('tag', tag);
if (category) params.set('category', category);
if (page && page > 1) params.set('page', String(page));
const queryString = params.toString();
return queryString ? `/articles?${queryString}` : '/articles';
};
---
<BaseLayout title={`${t('articlesPage.title')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/articles/index" class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command="find ./posts -type f -name '*.md' | sort" />
<div class="ml-4 mt-4 space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{t('articlesPage.description')}
</p>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
<span id="articles-total-posts">{t('articlesPage.totalPosts', { count: filteredPosts.length })}</span>
</span>
{selectedSearch && (
<span class="terminal-stat-pill">
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
grep: {selectedSearch}
</span>
)}
<span
id="articles-current-category-pill"
class:list={[
'terminal-stat-pill terminal-stat-pill--accent',
!selectedCategory && 'hidden'
]}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open"></i>
<span id="articles-current-category">{selectedCategory}</span>
</span>
<span
id="articles-current-tag-pill"
class:list={[
'terminal-stat-pill terminal-stat-pill--accent',
!selectedTag && 'hidden'
]}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
>
<i class="fas fa-hashtag"></i>
<span id="articles-current-tag">{selectedTag}</span>
</span>
</div>
</div>
</div>
<div class="px-4 pb-2 space-y-4">
<div class="ml-4">
<CommandPrompt promptId="articles-type-prompt" command={typePromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
{postTypeFilters.map(filter => (
<FilterPill
href={buildArticlesUrl({ type: filter.id, page: 1 })}
data-articles-type={filter.id}
tone={filter.id === 'all' ? 'neutral' : 'accent'}
active={selectedType === filter.id}
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
>
<i class={`fas ${filter.icon}`}></i>
<span class="font-medium">{filter.name}</span>
</FilterPill>
))}
</div>
</div>
{allCategories.length > 0 && (
<div class="ml-4">
<CommandPrompt promptId="articles-category-prompt" command={categoryPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ category: '', page: 1 })}
data-articles-category=""
tone="amber"
active={!selectedCategory}
>
<i class="fas fa-folder-tree"></i>
<span class="font-medium">{t('articlesPage.allCategories')}</span>
</FilterPill>
{allCategories.map(category => (
<FilterPill
href={buildArticlesUrl({ category: category.name, page: 1 })}
data-articles-category={category.name}
tone="accent"
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
style={getAccentVars(getCategoryTheme(category.name))}
>
<i class="fas fa-folder-open"></i>
<span class="font-medium">{category.name}</span>
<span class="text-xs text-[var(--text-tertiary)]">{category.count}</span>
</FilterPill>
))}
</div>
</div>
)}
{allTags.length > 0 && (
<div class="ml-4">
<CommandPrompt promptId="articles-tag-prompt" command={tagPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ tag: '', page: 1 })}
data-articles-tag=""
tone="teal"
active={!selectedTag}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">{t('articlesPage.allTags')}</span>
</FilterPill>
{allTags.map(tag => (
<FilterPill
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
data-articles-tag={tag.slug || tag.name}
tone="accent"
active={isSelectedTag(tag)}
style={getAccentVars(getTagTheme(tag.name))}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">{tag.name}</span>
</FilterPill>
))}
</div>
</div>
)}
</div>
<div class="px-4">
{allPosts.length > 0 ? (
<div class="ml-4 mt-4 space-y-4">
{allPosts.map((post, index) => {
const matchesCurrentFilter =
(selectedType === 'all' || post.type === selectedType) &&
(!selectedTag || post.tags?.some(isMatchingTag)) &&
(!selectedCategory || post.category?.toLowerCase() === selectedCategory.toLowerCase());
const filteredIndex = matchesCurrentFilter
? filteredPosts.findIndex((item) => item.slug === post.slug)
: -1;
const isVisible = matchesCurrentFilter && filteredIndex >= startIndex && filteredIndex < startIndex + postsPerPage;
return (
<div
data-article-card
data-article-type={post.type}
data-article-category={post.category?.toLowerCase() || ''}
data-article-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
data-article-index={index}
class:list={[!isVisible && 'hidden']}
>
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
</div>
);
})}
</div>
) : null}
<div id="articles-empty-state" class:list={['terminal-empty ml-4 mt-4', paginatedPosts.length > 0 && 'hidden']}>
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
<span class="terminal-section-icon">
<i class="fas fa-folder-open"></i>
</span>
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
<p class="text-sm leading-7 text-[var(--text-secondary)]">
{t('articlesPage.emptyDescription')}
</p>
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-rotate-left"></i>
<span>{t('common.resetFilters')}</span>
</a>
</div>
</div>
</div>
<div class="px-4 py-6">
<div id="articles-pagination" class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
<span class="text-sm text-[var(--text-secondary)]">
<span id="articles-page-summary">{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}</span>
</span>
<div class="flex flex-wrap gap-2">
<button
id="articles-prev-btn"
type="button"
class:list={['terminal-action-button', currentPage <= 1 && 'hidden']}
>
<i class="fas fa-chevron-left"></i>
<span>{t('articlesPage.previous')}</span>
</button>
<button
id="articles-next-btn"
type="button"
class:list={['terminal-action-button terminal-action-button-primary', currentPage >= totalPages && 'hidden']}
>
<span>{t('articlesPage.next')}</span>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
postsPerPage,
selectedSearch,
categoryAccentMap,
tagAccentMap,
initialArticlesState: {
type: selectedType,
category: selectedCategory,
tag: selectedTag,
page: currentPage,
},
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const articleCards = Array.from(document.querySelectorAll('[data-article-card]'));
const typeFilters = Array.from(document.querySelectorAll('[data-articles-type]'));
const categoryFilters = Array.from(document.querySelectorAll('[data-articles-category]'));
const tagFilters = Array.from(document.querySelectorAll('[data-articles-tag]'));
const totalPostsEl = document.getElementById('articles-total-posts');
const categoryPill = document.getElementById('articles-current-category-pill');
const categoryText = document.getElementById('articles-current-category');
const tagPill = document.getElementById('articles-current-tag-pill');
const tagText = document.getElementById('articles-current-tag');
const emptyState = document.getElementById('articles-empty-state');
const pagination = document.getElementById('articles-pagination');
const pageSummary = document.getElementById('articles-page-summary');
const prevBtn = document.getElementById('articles-prev-btn');
const nextBtn = document.getElementById('articles-next-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
type: initialArticlesState.type || 'all',
category: initialArticlesState.category || '',
tag: initialArticlesState.tag || '',
page: Number(initialArticlesState.page || 1),
};
function updateArticlePrompts() {
const typeCommand = state.type === 'all'
? 'grep -E "^type: (article|tweet)$" ./posts/*.md'
: `grep -E "^type: ${state.type}$" ./posts/*.md`;
const categoryCommand = state.category
? `grep -El "^category: ${state.category}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
const tagCommand = state.tag
? `grep -Ril "#${state.tag}" ./posts`
: 'cut -d: -f2 ./tags.index | sort -u';
promptApi?.set?.('articles-type-prompt', typeCommand, { typing: false });
promptApi?.set?.('articles-category-prompt', categoryCommand, { typing: false });
promptApi?.set?.('articles-tag-prompt', tagCommand, { typing: false });
}
function syncActiveFilters(elements, key, emptyValue = '') {
elements.forEach((element) => {
const value = (element.getAttribute(key) || '').trim();
const activeValue =
key === 'data-articles-type'
? state.type
: key === 'data-articles-category'
? state.category
: state.tag;
element.classList.toggle('is-active', value === (activeValue || emptyValue));
});
}
function updateSummaryPills() {
if (categoryPill && categoryText) {
if (state.category) {
categoryPill.classList.remove('hidden');
categoryText.textContent = state.category;
categoryPill.setAttribute('style', categoryAccentMap[String(state.category).toLowerCase()] || '');
} else {
categoryPill.classList.add('hidden');
categoryPill.removeAttribute('style');
}
}
if (tagPill && tagText) {
if (state.tag) {
tagPill.classList.remove('hidden');
tagText.textContent = state.tag;
tagPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
} else {
tagPill.classList.add('hidden');
tagPill.removeAttribute('style');
}
}
}
function updateUrl(totalPages) {
const params = new URLSearchParams();
if (state.type && state.type !== 'all') params.set('type', state.type);
if (selectedSearch) params.set('search', selectedSearch);
if (state.tag) params.set('tag', state.tag);
if (state.category) params.set('category', state.category);
if (state.page > 1 && totalPages > 1) params.set('page', String(state.page));
const nextUrl = params.toString() ? `/articles?${params.toString()}` : '/articles';
window.history.replaceState({}, '', nextUrl);
}
function applyArticleFilters(pushHistory = true) {
const filtered = articleCards.filter((card) => {
const type = card.getAttribute('data-article-type') || '';
const category = (card.getAttribute('data-article-category') || '').toLowerCase();
const tags = `|${(card.getAttribute('data-article-tags') || '').toLowerCase()}|`;
const typeMatch = state.type === 'all' || type === state.type;
const categoryMatch = !state.category || category === state.category.toLowerCase();
const tagMatch = !state.tag || tags.includes(`|${state.tag.toLowerCase()}|`);
return typeMatch && categoryMatch && tagMatch;
});
const total = filtered.length;
const totalPages = Math.max(Math.ceil(total / postsPerPage), 1);
if (state.page > totalPages) state.page = totalPages;
if (state.page < 1) state.page = 1;
const startIndex = (state.page - 1) * postsPerPage;
const endIndex = startIndex + postsPerPage;
articleCards.forEach((card) => card.classList.add('hidden'));
filtered.slice(startIndex, endIndex).forEach((card) => card.classList.remove('hidden'));
syncActiveFilters(typeFilters, 'data-articles-type', 'all');
syncActiveFilters(categoryFilters, 'data-articles-category', '');
syncActiveFilters(tagFilters, 'data-articles-tag', '');
updateSummaryPills();
updateArticlePrompts();
if (totalPostsEl) {
totalPostsEl.textContent = t('articlesPage.totalPosts', { count: total });
}
if (emptyState) {
emptyState.classList.toggle('hidden', total > 0);
}
if (pagination) {
pagination.classList.toggle('hidden', totalPages <= 1);
}
if (pageSummary) {
pageSummary.textContent = t('articlesPage.pageSummary', { current: state.page, total: totalPages, count: total });
}
if (prevBtn) {
prevBtn.classList.toggle('hidden', state.page <= 1 || totalPages <= 1);
}
if (nextBtn) {
nextBtn.classList.toggle('hidden', state.page >= totalPages || totalPages <= 1);
}
if (pushHistory) {
updateUrl(totalPages);
}
}
typeFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.type = filter.getAttribute('data-articles-type') || 'all';
state.page = 1;
applyArticleFilters();
});
});
categoryFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.category = filter.getAttribute('data-articles-category') || '';
state.page = 1;
applyArticleFilters();
});
});
tagFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.tag = filter.getAttribute('data-articles-tag') || '';
state.page = 1;
applyArticleFilters();
});
});
prevBtn?.addEventListener('click', () => {
state.page -= 1;
applyArticleFilters();
});
nextBtn?.addEventListener('click', () => {
state.page += 1;
applyArticleFilters();
});
applyArticleFilters(false);
})();
</script>