516 lines
20 KiB
Plaintext
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>
|