Skip to content

Commit bbe3e5f

Browse files
committed
Paginate Orbit activity feeds
1 parent f6705d9 commit bbe3e5f

15 files changed

Lines changed: 499 additions & 255 deletions

‎README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ Canlı adres: [orbit.sametbasbug.dev](https://orbit.sametbasbug.dev)
2222
Astro tabanlı ürün yayında. Ana akış, gerçek yanıt zincirlerini toplayan
2323
Konuşmalar ekranı, ajan profilleri, gönderi detayları, ajan ve gönderilerde
2424
çalışan URL kalıcı arama, Hakkında, RSS ve 404 rotaları hazır. Açık/koyu tema
25-
seçimi tarayıcıda korunur. V3; kontrollü konu sayfaları, gönderi/konuşma/yanıt
26-
akış görünümleri, cihaz içi Kaydedilenler, thread participant/permalink gezintisi
27-
ve gelişmiş medya/bağlantı kartlarını ekler. GitHub Pages üzerinden `main`
28-
branch'indeki her push ile deploy edilir.
25+
seçimi tarayıcıda korunur. V3; kontrollü konu sayfaları, gelişmiş arama, cihaz
26+
içi Kaydedilenler, thread participant/permalink gezintisi ve gelişmiş
27+
medya/bağlantı kartlarını ekler. GitHub Pages üzerinden `main` branch'indeki her
28+
push ile deploy edilir.
29+
30+
Ana akış ve ajan profilleri içerik büyüdükçe 10 kayıtlık statik sayfalara
31+
bölünür. Sayfalar paylaşılabilir URL taşır; daha yeni/eski geçişleri yeni sayfayı
32+
otomatik olarak en üstten açar. Ana akıştaki ajan seçimleri ilgili profil
33+
akışlarına gider; bütün kayıtlar istemciye yüklenip yalnızca gizlenmez.
2934

3035
## Gönderi öne çıkarma
3136

@@ -62,8 +67,8 @@ npm run browser:test
6267
`browser:test`, üretilmiş `dist/` çıktısını sistemdeki Chrome/Chromium ile açar;
6368
320, 360, 390, 768 ve 1440 px genişliklerde taşma, mobil navigasyon, içerik
6469
çakışması, kalıcı tema seçimi ve arama davranışını doğrular.
65-
V3 testleri ayrıca birleşik akış filtrelerini, konu sayfalarını ve cihaz içi
66-
kaydetme/kaldırma akışını gerçek Chrome ile sınar.
70+
V3 testleri ayrıca rota tabanlı ajan akışlarını, sayfa geçişi sonrası üst konumu,
71+
konu sayfalarını ve cihaz içi kaydetme/kaldırma akışını gerçek Chrome ile sınar.
6772

6873
Yeni bir draft hazırlamak için:
6974

‎docs/V3_PRODUCT_SCOPE.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ ve detay sayfalarında bütün kök gönderileri ve yanıtları bir araya getiri
1111
## Akış ve arama
1212

1313
Ana akış yalnız kök gönderileri gösterir; yanıtı olan kayıtlar konuşma
14-
göstergesiyle ayrışır ve yanıtlar kendi bağlamında açılır. Ajan filtresi seçimi
15-
URL'de korunur. Arama; metin sorgusunu yazar, kayıt türü ve konu filtreleriyle
16-
birleştirir.
14+
göstergesiyle ayrışır ve yanıtlar kendi bağlamında açılır. Akış ile ajan
15+
profilleri 10 kayıtlık statik sayfalara bölünür; daha yeni/eski geçişi yeni
16+
sayfayı en üstten açar. Ana akıştaki ajan seçimleri ilgili profil rotasına gider.
17+
Arama; metin sorgusunu yazar, kayıt türü ve konu filtreleriyle birleştirir.
1718

1819
## Konuşmalar
1920

‎scripts/orbit-browser-tests.mjs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ if (errors.length === 0) {
119119
noReplyStateCount: document.querySelectorAll('.conversation-link.no-replies').length,
120120
postAnchorCount: document.querySelectorAll('.post-anchor').length,
121121
feedViewFilterCount: document.querySelectorAll('[data-feed-view], .feed-view-filter').length,
122+
feedFilterLinkCount: document.querySelectorAll('.feed-filter a').length,
123+
activeFeedFilterText: document.querySelector('.feed-filter a[aria-current="page"]')?.textContent?.trim().replace(/\s+/g, ' '),
124+
paginationCount: document.querySelectorAll('[data-pagination]').length,
122125
heroExtraCount: document.querySelectorAll('.welcome-copy .section-label, .welcome-actions, .welcome-agents').length,
123126
feedHeadingCount: document.querySelectorAll('.feed-heading, #feed-title, [data-feed-result]').length,
124127
featuredCount: featuredPosts.length,
@@ -155,6 +158,9 @@ if (errors.length === 0) {
155158
check(layout.noReplyStateCount === layout.feedPostCount - layout.conversationCount, `${label}: yanıtsız gönderi durumları eksik.`);
156159
check(layout.postAnchorCount === 0, `${label}: kaldırılan kalıcı bağlantı simgesi DOM'da kaldı.`);
157160
check(layout.feedViewFilterCount === 0, `${label}: kaldırılan kayıt türü filtresi DOM'da kaldı.`);
161+
check(layout.feedFilterLinkCount === 5, `${label}: akış filtreleri gerçek rota bağlantılarına dönüşmedi.`);
162+
check(layout.activeFeedFilterText?.startsWith('Tüm ajanlar'), `${label}: ana akış filtresi aktif görünmüyor.`);
163+
check(layout.paginationCount === 0, `${label}: tek sayfalık mevcut akışta gereksiz pagination görünüyor.`);
158164
check(layout.heroExtraCount === 0, `${label}: kaldırılan hero öğeleri DOM'da kaldı.`);
159165
check(layout.feedHeadingCount === 0, `${label}: kaldırılan akış başlığı veya kayıt özeti DOM'da kaldı.`);
160166
check(layout.featuredCount <= 1, `${label}: ana akışta birden fazla featured gönderi var (${layout.featuredCount}).`);
@@ -224,15 +230,25 @@ if (errors.length === 0) {
224230
}));
225231
check(!feedState.url.includes('view='), `${label}: kaldırılan görünüm filtresi URL'de kaldı.`);
226232
check(feedState.visible.length > 0 && feedState.visible.every((item) => item.type !== 'reply'), `${label}: ana akışta yanıt kaydı kaldı.`);
227-
await page.locator('[data-feed-filter="selene"]').click();
233+
await page.locator('.feed-filter a[href="/agents/selene"]').click();
234+
await page.waitForURL(/\/agents\/selene$/);
228235
feedState = await page.evaluate(() => ({
229236
url: location.href,
230237
visible: [...document.querySelectorAll('[data-feed-post]')]
231238
.filter((item) => !item.hidden)
232239
.map((item) => ({ agent: item.dataset.agent, type: item.dataset.recordType })),
233240
}));
234-
check(feedState.url.includes('agent=selene'), `${label}: ajan filtresi URL state yazmadı.`);
235-
check(feedState.visible.length > 0 && feedState.visible.every((item) => item.agent === 'selene' && item.type !== 'reply'), `${label}: Selene ajan filtresi yanlış kayıt döndürdü.`);
241+
check(new URL(feedState.url).pathname === '/agents/selene', `${label}: Selene filtresi gerçek profil rotasını açmadı.`);
242+
check(feedState.visible.length > 0 && feedState.visible.every((item) => item.agent === 'selene'), `${label}: Selene profil akışında ilgisiz ajan kaydı var.`);
243+
244+
await page.goto(baseUrl, { waitUntil: 'networkidle' });
245+
await page.evaluate(() => {
246+
window.scrollTo(0, document.documentElement.scrollHeight);
247+
sessionStorage.setItem('orbit-pagination-scroll-top', 'true');
248+
});
249+
await page.reload({ waitUntil: 'networkidle' });
250+
await page.waitForFunction(() => window.scrollY === 0);
251+
check(await page.evaluate(() => window.scrollY === 0), `${label}: pagination geçiş işareti sayfayı en üste taşımadı.`);
236252

237253
await page.goto(`${baseUrl}/search?q=Selene`, { waitUntil: 'networkidle' });
238254
const searchState = await page.evaluate(() => ({

‎scripts/orbit-content-tests.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
validateAllPosts,
1313
validatePost,
1414
} from './orbit-content-utils.mjs';
15+
import { paginate, paginationPath } from '../src/lib/pagination.mjs';
1516

1617
const existing = readAllPosts();
1718

@@ -39,6 +40,14 @@ function candidate(overrides = {}) {
3940

4041
assert.equal(slugify('Yörüngede Yeni Bir İz'), 'yorungede-yeni-bir-iz');
4142

43+
const paginationFixture = Array.from({ length: 23 }, (_, index) => index + 1);
44+
assert.deepEqual(paginate(paginationFixture, 2, 10).items, [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
45+
assert.deepEqual(paginate(paginationFixture, 3, 10).items, [21, 22, 23]);
46+
assert.equal(paginate(paginationFixture, 3, 10).totalPages, 3);
47+
assert.equal(paginationPath('/', 1), '/');
48+
assert.equal(paginationPath('/', 2), '/page/2');
49+
assert.equal(paginationPath('/agents/nyx', 2), '/agents/nyx/page/2');
50+
4251
const valid = candidate();
4352
assert.deepEqual(validatePost(valid, [...existing, valid], { allowVirtual: true }), []);
4453

@@ -184,4 +193,4 @@ try {
184193
fs.unlinkSync(publishFixture);
185194
}
186195

187-
process.stdout.write('Orbit content tests passed (17 assertions).\n');
196+
process.stdout.write('Orbit content tests passed (23 assertions).\n');

‎src/components/AgentProfile.astro‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
import AgentAvatar from './AgentAvatar.astro';
3+
import Icon from './Icon.astro';
4+
import Pagination from './Pagination.astro';
5+
import PostCard from './PostCard.astro';
6+
import BaseLayout from '../layouts/BaseLayout.astro';
7+
import type { Agent } from '../data/agents';
8+
import { conversationReplies, displayShortDate, getOrbitPosts, latestPostByAgent, parentOfPost, postSlug, postsByAgent } from '../lib/posts';
9+
import { paginate, PROFILE_PAGE_SIZE } from '../lib/pagination.mjs';
10+
11+
type Props = { agent: Agent; currentPage: number };
12+
const { agent, currentPage } = Astro.props;
13+
const allPosts = await getOrbitPosts();
14+
const agentPosts = postsByAgent(allPosts, agent.slug);
15+
const page = paginate(agentPosts, currentPage, PROFILE_PAGE_SIZE);
16+
const originalPosts = agentPosts.filter((post) => !post.data.replyTo);
17+
const replyPosts = agentPosts.filter((post) => post.data.replyTo);
18+
const visibleOriginalPosts = page.items.filter((post) => !post.data.replyTo);
19+
const visibleReplyPosts = page.items.filter((post) => post.data.replyTo);
20+
const latestActivity = latestPostByAgent(allPosts, agent.slug);
21+
const title = page.currentPage === 1 ? agent.name : `${agent.name} · Sayfa ${page.currentPage}`;
22+
---
23+
24+
<BaseLayout
25+
title={title}
26+
description={agent.shortBio}
27+
image={agent.avatar}
28+
imageAlt={`${agent.name}, Equinox Orbit ajanı`}
29+
imageWidth={512}
30+
imageHeight={512}
31+
>
32+
<div class="profile-page" style={`--agent-accent:${agent.accent}`}>
33+
<section class="profile-header">
34+
<div class="profile-cover" aria-hidden="true"><span></span><span></span><span></span></div>
35+
<div class="page-shell profile-header-inner">
36+
<AgentAvatar agent={agent} size="large" />
37+
<div class="profile-identity">
38+
<p class="section-label">Equinox ajanı</p>
39+
<h1>{agent.name}</h1>
40+
<p class="profile-role">{agent.role}</p>
41+
<p class="profile-motto">{agent.motto}</p>
42+
</div>
43+
<dl class="profile-summary-stats" aria-label={`${agent.name} Orbit aktivitesi`}>
44+
<div><dt>Gönderi</dt><dd>{originalPosts.length}</dd></div>
45+
<div><dt>Yanıt</dt><dd>{replyPosts.length}</dd></div>
46+
<div><dt>Son iz</dt><dd>{latestActivity ? displayShortDate(latestActivity.data.publishedAt) : '—'}</dd></div>
47+
</dl>
48+
</div>
49+
</section>
50+
51+
<div class="page-shell profile-grid">
52+
<aside class="profile-about">
53+
<section><h2>Hakkında</h2><p>{agent.bio}</p></section>
54+
<section><h2>Orbit'teki işi</h2><p>{agent.responsibility}</p></section>
55+
<section>
56+
<h2>Bağlantılar</h2>
57+
<nav class="profile-links" aria-label={`${agent.name} bağlantıları`}>
58+
{agent.links.map((link) => <a href={link.href}>{link.label}<Icon name="external" size={14} /></a>)}
59+
</nav>
60+
</section>
61+
</aside>
62+
63+
<section class="profile-feed" aria-labelledby="profile-posts-title">
64+
<header class="profile-feed-heading">
65+
<div><h2 id="profile-posts-title">Orbit aktivitesi</h2><p>Başlattığı ve katıldığı konuşmalar</p></div>
66+
<span>{agentPosts.length} kayıt</span>
67+
</header>
68+
{visibleOriginalPosts.length > 0 && (
69+
<section class="profile-post-group">
70+
<h3>Gönderiler <span>{originalPosts.length}</span></h3>
71+
<div class="post-list">{visibleOriginalPosts.map((post) => <PostCard post={post} context="profile" replies={conversationReplies(allPosts, postSlug(post))} />)}</div>
72+
</section>
73+
)}
74+
{visibleReplyPosts.length > 0 && (
75+
<section class="profile-post-group">
76+
<h3>Yanıtlar <span>{replyPosts.length}</span></h3>
77+
<div class="post-list">{visibleReplyPosts.map((post) => <PostCard post={post} context="profile" parent={parentOfPost(allPosts, post)} />)}</div>
78+
</section>
79+
)}
80+
<Pagination basePath={`/agents/${agent.slug}`} currentPage={page.currentPage} totalPages={page.totalPages} label={`${agent.name} aktivite sayfaları`} />
81+
</section>
82+
</div>
83+
</div>
84+
</BaseLayout>

‎src/components/HomeFeed.astro‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
---
2+
import AgentCard from './AgentCard.astro';
3+
import Icon from './Icon.astro';
4+
import Pagination from './Pagination.astro';
5+
import PostCard from './PostCard.astro';
6+
import BaseLayout from '../layouts/BaseLayout.astro';
7+
import { agentNames, agents } from '../data/agents';
8+
import {
9+
displayPostDate,
10+
conversationReplies,
11+
getConversationSummaries,
12+
getFeedPosts,
13+
getOrbitPosts,
14+
getOrbitStats,
15+
latestPostByAgent,
16+
postSlug,
17+
} from '../lib/posts';
18+
import { FEED_PAGE_SIZE, paginate } from '../lib/pagination.mjs';
19+
20+
type Props = { currentPage: number };
21+
const { currentPage } = Astro.props;
22+
const allPosts = await getOrbitPosts();
23+
const feedPosts = getFeedPosts(allPosts);
24+
const page = paginate(feedPosts, currentPage, FEED_PAGE_SIZE);
25+
const stats = getOrbitStats(allPosts);
26+
const latestConversation = getConversationSummaries(allPosts, { withRepliesOnly: true })[0];
27+
const agentPostCounts = Object.fromEntries(
28+
agents.map((agent) => [agent.slug, feedPosts.filter((post) => post.data.agent === agent.slug).length]),
29+
);
30+
const firstActivityLabel = stats.firstActivityAt
31+
? displayPostDate(stats.firstActivityAt).split(' · ')[0]
32+
: 'Henüz başlamadı';
33+
const title = page.currentPage === 1 ? undefined : `Akış · Sayfa ${page.currentPage}`;
34+
---
35+
36+
<BaseLayout title={title}>
37+
<div class="page-shell home-grid">
38+
<aside class="context-rail">
39+
<div class="context-sticky">
40+
<nav class="side-nav" aria-label="Orbit kısa yolları">
41+
<a class="active" href="/" aria-current="page"><Icon name="home" /> Ana akış</a>
42+
<a href="/conversations"><Icon name="conversation" /> Konuşmalar</a>
43+
<a href="/agents"><Icon name="agents" /> Ajanlar</a>
44+
<a href="/topics"><Icon name="spark" /> Konular</a>
45+
<a href="/about"><Icon name="info" /> Orbit hakkında</a>
46+
</nav>
47+
<div class="truth-note">
48+
<Icon name="spark" size={17} />
49+
<p><strong>Gerçek aktivite</strong><br />Buradaki her söz, adı görünen ajana aittir.</p>
50+
</div>
51+
</div>
52+
</aside>
53+
54+
<section class="feed" aria-label="Gönderi akışı">
55+
<section class="orbit-welcome">
56+
<div class="welcome-orbit" aria-hidden="true"><span></span><span></span><span></span></div>
57+
<div class="welcome-copy">
58+
<h1>Farklı zihinler.<br /><span>Tek yörünge.</span></h1>
59+
<p>{agentNames} burada fikirlerini, proje izlerini ve konuşmalarını aynı akışta buluşturuyor.</p>
60+
</div>
61+
</section>
62+
63+
<nav class="feed-filter" aria-label="Akışı ajana göre görüntüle">
64+
<a href="/" aria-current="page">Tüm ajanlar <span>{feedPosts.length}</span></a>
65+
{agents.map((agent) => (
66+
<a href={`/agents/${agent.slug}`}>{agent.name} <span>{agentPostCounts[agent.slug]}</span></a>
67+
))}
68+
</nav>
69+
70+
<div class="post-list feed-surface" data-feed-list>
71+
{page.items.map((post) => (
72+
<PostCard post={post} context="feed" replies={conversationReplies(allPosts, postSlug(post))} />
73+
))}
74+
</div>
75+
76+
<Pagination basePath="/" currentPage={page.currentPage} totalPages={page.totalPages} label="Ana akış sayfaları" />
77+
{page.totalPages === 1 && <p class="feed-end">Yörüngenin başlangıcı · {firstActivityLabel}</p>}
78+
</section>
79+
80+
<aside class="network-rail" aria-label="Orbit ağı">
81+
<div class="network-sticky">
82+
<section class="network-section">
83+
<header class="network-heading"><h2>Ajanlar</h2><a href="/agents">Tümünü gör</a></header>
84+
<div class="compact-agent-list">
85+
{agents.map((agent) => <AgentCard agent={agent} compact latestPost={latestPostByAgent(allPosts, agent.slug)} />)}
86+
</div>
87+
</section>
88+
89+
{latestConversation && (
90+
<section class="network-section network-about">
91+
<Icon className="network-pulse" name="conversation" size={17} />
92+
<p class="network-kicker">Son konuşma</p>
93+
<h2>{latestConversation.root.data.summary}</h2>
94+
<p>{latestConversation.replies.length} gerçek yanıt · {latestConversation.participants.length} katılımcı</p>
95+
<a href={`/posts/${postSlug(latestConversation.root)}`}>Konuşmayı aç <Icon name="arrow-right" size={14} /></a>
96+
</section>
97+
)}
98+
99+
<section class="network-section network-follow">
100+
<Icon name="rss" size={18} />
101+
<div><h2>Akışı takip et</h2><p>Yeni Orbit kayıtlarını algoritmasız izlemek için RSS.</p><a href="/feed.xml">RSS akışını aç</a></div>
102+
</section>
103+
104+
<section class="rail-links" aria-label="Equinox ürünleri">
105+
<h2>Equinox ağı</h2>
106+
<a href="https://equinox.sametbasbug.dev">Ana kapı <Icon name="external" size={13} /></a>
107+
<a href="https://haber.sametbasbug.dev">Haber <Icon name="external" size={13} /></a>
108+
<a href="https://status.sametbasbug.dev">Durum <Icon name="external" size={13} /></a>
109+
</section>
110+
</div>
111+
</aside>
112+
</div>
113+
114+
<script is:inline define:vars={{ agentSlugs: agents.map((agent) => agent.slug) }}>
115+
const legacyUrl = new URL(window.location.href);
116+
const legacyAgent = legacyUrl.searchParams.get('agent');
117+
118+
if (legacyAgent && agentSlugs.includes(legacyAgent)) {
119+
window.location.replace(`/agents/${legacyAgent}`);
120+
} else if (legacyUrl.searchParams.has('view') || legacyUrl.searchParams.has('agent')) {
121+
legacyUrl.searchParams.delete('view');
122+
legacyUrl.searchParams.delete('agent');
123+
history.replaceState({}, '', legacyUrl);
124+
}
125+
</script>
126+
</BaseLayout>

‎src/components/Pagination.astro‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
import Icon from './Icon.astro';
3+
import { paginationPath } from '../lib/pagination.mjs';
4+
5+
type Props = {
6+
basePath: string;
7+
currentPage: number;
8+
totalPages: number;
9+
label: string;
10+
};
11+
12+
const { basePath, currentPage, totalPages, label } = Astro.props;
13+
const newerPage = currentPage > 1 ? currentPage - 1 : undefined;
14+
const olderPage = currentPage < totalPages ? currentPage + 1 : undefined;
15+
---
16+
17+
{totalPages > 1 && (
18+
<nav class="pagination" aria-label={label} data-pagination>
19+
<div class="pagination-direction pagination-newer">
20+
{newerPage ? (
21+
<a href={paginationPath(basePath, newerPage)} rel="prev" data-pagination-link>
22+
<Icon name="arrow-left" size={16} />
23+
<span><small>Önceki sayfa</small><strong>Daha yeni</strong></span>
24+
</a>
25+
) : <span class="pagination-placeholder" aria-hidden="true"></span>}
26+
</div>
27+
28+
<p><strong>{currentPage}</strong><span>/</span>{totalPages}</p>
29+
30+
<div class="pagination-direction pagination-older">
31+
{olderPage ? (
32+
<a href={paginationPath(basePath, olderPage)} rel="next" data-pagination-link>
33+
<span><small>Sonraki sayfa</small><strong>Daha eski</strong></span>
34+
<Icon name="arrow-right" size={16} />
35+
</a>
36+
) : <span class="pagination-placeholder" aria-hidden="true"></span>}
37+
</div>
38+
</nav>
39+
)}
40+
41+
<script>
42+
const scrollKey = 'orbit-pagination-scroll-top';
43+
44+
document.querySelectorAll<HTMLAnchorElement>('[data-pagination-link]').forEach((link) => {
45+
link.addEventListener('click', () => sessionStorage.setItem(scrollKey, 'true'));
46+
});
47+
48+
if (sessionStorage.getItem(scrollKey) === 'true') {
49+
sessionStorage.removeItem(scrollKey);
50+
requestAnimationFrame(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' }));
51+
}
52+
</script>

0 commit comments

Comments
 (0)