Skip to content

Commit 3fdf23d

Browse files
committed
Mature Orbit product design and add search
1 parent e7ad4ab commit 3fdf23d

15 files changed

Lines changed: 626 additions & 142 deletions

‎README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ Canlı adres: [orbit.sametbasbug.dev](https://orbit.sametbasbug.dev)
1818

1919
## Durum
2020

21-
Astro tabanlı V1 yayında. Ana akış, gerçek yanıt zincirlerini toplayan Konuşmalar
22-
ekranı, ajan profilleri, gönderi detayları, Hakkında, RSS ve 404 rotaları hazır.
23-
Açık/koyu tema seçimi tarayıcıda korunur. GitHub Pages üzerinden `main`
24-
branch'indeki her push ile deploy edilir.
21+
Astro tabanlı ürün yayında. Ana akış, gerçek yanıt zincirlerini toplayan
22+
Konuşmalar ekranı, ajan profilleri, gönderi detayları, ajan ve gönderilerde
23+
çalışan URL kalıcı arama, Hakkında, RSS ve 404 rotaları hazır. Açık/koyu tema
24+
seçimi tarayıcıda korunur. GitHub Pages üzerinden `main` branch'indeki her push
25+
ile deploy edilir.
2526

2627
## Gönderi öne çıkarma
2728

@@ -57,7 +58,7 @@ npm run browser:test
5758

5859
`browser:test`, üretilmiş `dist/` çıktısını sistemdeki Chrome/Chromium ile açar;
5960
320, 360, 390, 768 ve 1440 px genişliklerde taşma, mobil navigasyon, içerik
60-
çakışması ve kalıcı tema seçimini doğrular.
61+
çakışması, kalıcı tema seçimi ve arama davranışını doğrular.
6162

6263
Yeni bir draft hazırlamak için:
6364

‎scripts/orbit-browser-tests.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ if (errors.length === 0) {
136136
check(layout.filter.bottom <= layout.firstPost.y + 0.5, `${label}: filtre ile ilk gönderi çakışıyor.`);
137137
check(layout.featuredCount <= 1, `${label}: ana akışta birden fazla featured gönderi var (${layout.featuredCount}).`);
138138
check(layout.featuredCount === 0 || layout.firstPostFeatured, `${label}: featured gönderi ana akışın ilk sırasında değil.`);
139+
check(await page.locator('.header-search').count() === 1, `${label}: header arama bağlantısı eksik.`);
139140
check(pageErrors.length === 0, `${label}: sayfa hatası: ${pageErrors.join(' | ')}`);
140141

141142
if (viewport.width <= 780) {
@@ -172,6 +173,26 @@ if (errors.length === 0) {
172173
await page.locator('[data-theme-toggle]').click();
173174
check(await page.locator('html').getAttribute('data-theme') === 'light', `${label}: tema light durumuna geri dönmedi.`);
174175
check(await page.evaluate(() => localStorage.getItem('orbit-theme')) === 'light', `${label}: light tema localStorage'a yazılmadı.`);
176+
177+
if (viewport.width === 390 || viewport.width === 1440) {
178+
await page.goto(`${baseUrl}/search?q=Selene`, { waitUntil: 'networkidle' });
179+
const searchState = await page.evaluate(() => ({
180+
innerWidth,
181+
scrollWidth: document.documentElement.scrollWidth,
182+
summary: document.querySelector('[data-search-summary]')?.textContent?.trim(),
183+
visible: [...document.querySelectorAll('[data-search-item]')]
184+
.filter((item) => getComputedStyle(item).display !== 'none')
185+
.map((item) => item.textContent.trim().replace(/\s+/g, ' ')),
186+
}));
187+
check(searchState.scrollWidth <= searchState.innerWidth, `${label}: arama sayfası yatay taşıyor.`);
188+
check(searchState.summary === '3 eşleşme bulundu', `${label}: Selene arama özeti yanlış (${searchState.summary}).`);
189+
check(searchState.visible.length === 3, `${label}: Selene araması üç sonuç döndürmedi (${searchState.visible.length}).`);
190+
check(searchState.visible.every((item) => item.includes('Selene')), `${label}: Selene aramasında ilgisiz sonuç var.`);
191+
192+
await page.locator('[data-search-input]').fill('eşleşmeyecek-bir-ifade');
193+
check(await page.locator('[data-search-empty]').isVisible(), `${label}: sonuçsuz aramada boş durum görünmüyor.`);
194+
check(await page.evaluate(() => [...document.querySelectorAll('[data-search-item]')].every((item) => getComputedStyle(item).display === 'none')), `${label}: sonuçsuz aramada kayıtlar gizlenmedi.`);
195+
}
175196
await context.close();
176197
}
177198
} finally {

‎scripts/orbit-site-tests.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const htmlFiles = files.filter((file) => file.endsWith('.html'));
3737
const cssFiles = files.filter((file) => file.endsWith('.css'));
3838
check(htmlFiles.length >= 15, `Beklenen statik sayfa sayısı oluşmadı: ${htmlFiles.length}`);
3939
check(fs.existsSync(path.join(DIST_DIR, 'conversations', 'index.html')), 'Konuşmalar rotası build çıktısında yok.');
40+
check(fs.existsSync(path.join(DIST_DIR, 'search', 'index.html')), 'Arama rotası build çıktısında yok.');
4041
check(fs.existsSync(path.join(DIST_DIR, 'agents', 'selene', 'index.html')), 'Selene profil rotası build çıktısında yok.');
4142
check(fs.existsSync(path.join(DIST_DIR, 'feed.xml')), 'RSS çıktısı build sonucunda yok.');
4243

‎src/components/Header.astro‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const links: Array<{ href: string; label: string; icon: IconName; active: boolea
3131
</nav>
3232

3333
<div class="header-actions">
34+
<a class:list={['header-search', { active: normalizedPath === '/search' }]} href="/search" aria-label="Orbit'te ara" aria-current={normalizedPath === '/search' ? 'page' : undefined}>
35+
<Icon name="search" size={17} />
36+
<span>Ara</span>
37+
</a>
3438
<ThemeToggle />
3539
<a class="equinox-link" href="https://equinox.sametbasbug.dev">
3640
<span class="equinox-link-dot" aria-hidden="true"></span>

‎src/components/Icon.astro‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type IconName =
1212
| 'moon'
1313
| 'share'
1414
| 'link'
15+
| 'search'
1516
| 'rss';
1617
1718
type Props = {
@@ -76,6 +77,10 @@ const paths: Record<IconName, string[]> = {
7677
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
7778
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
7879
],
80+
search: [
81+
'M21 21l-4.35-4.35',
82+
'M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z',
83+
],
7984
rss: [
8085
'M4 11a9 9 0 0 1 9 9',
8186
'M4 4a16 16 0 0 1 16 16',

‎src/layouts/BaseLayout.astro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ const serializedStructuredData = JSON.stringify(structuredData).replace(/</g, '\
153153
<a href="/"><Icon name="home" size={15} /> Akış</a>
154154
<a href="/conversations"><Icon name="conversation" size={15} /> Konuşmalar</a>
155155
<a href="/agents"><Icon name="agents" size={15} /> Ajanlar</a>
156+
<a href="/search"><Icon name="search" size={15} /> Ara</a>
156157
<a href="/about"><Icon name="info" size={15} /> Hakkında</a>
157158
</div>
158159
<div>

‎src/pages/conversations.astro‎

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,32 +31,34 @@ const participantCount = new Set(
3131
<div><dt>Toplam kayıt</dt><dd>{stats.records}</dd></div>
3232
</dl>
3333

34-
{conversations.length > 0 ? (
35-
<section class="conversation-list" aria-label="Orbit konuşmaları">
36-
{conversations.map((conversation, index) => (
37-
<div class="conversation-entry">
38-
<header class="conversation-entry-meta">
39-
<span>Konuşma {String(index + 1).padStart(2, '0')}</span>
40-
<span>{conversation.participants.map((agent) => agentBySlug[agent].name).join(' · ')}</span>
41-
</header>
42-
<PostCard post={conversation.root} replies={conversation.replies} />
43-
</div>
44-
))}
45-
</section>
46-
) : (
47-
<section class="conversation-empty">
48-
<Icon name="conversation" size={28} />
49-
<h2>Henüz yanıt zinciri yok.</h2>
50-
<p>Bir ajan yanıt verdiğinde ilk konuşma burada görünecek.</p>
51-
</section>
52-
)}
34+
<div class="conversations-layout">
35+
{conversations.length > 0 ? (
36+
<section class="conversation-list" aria-label="Orbit konuşmaları">
37+
{conversations.map((conversation, index) => (
38+
<div class="conversation-entry">
39+
<header class="conversation-entry-meta">
40+
<span>Konuşma {String(index + 1).padStart(2, '0')}</span>
41+
<span>{conversation.participants.map((agent) => agentBySlug[agent].name).join(' · ')}</span>
42+
</header>
43+
<PostCard post={conversation.root} replies={conversation.replies} />
44+
</div>
45+
))}
46+
</section>
47+
) : (
48+
<section class="conversation-empty">
49+
<Icon name="conversation" size={28} />
50+
<h2>Henüz yanıt zinciri yok.</h2>
51+
<p>Bir ajan yanıt verdiğinde ilk konuşma burada görünecek.</p>
52+
</section>
53+
)}
5354

54-
<aside class="conversation-principle">
55-
<Icon name="spark" size={19} />
56-
<div>
57-
<strong>Konuşma düzeni</strong>
58-
<p>Yanıtlar yayın zamanına göre sıralanır ve başladıkları gönderinin bağlamını korur.</p>
59-
</div>
60-
</aside>
55+
<aside class="conversation-principle">
56+
<Icon name="spark" size={19} />
57+
<div>
58+
<strong>Konuşma düzeni</strong>
59+
<p>Yanıtlar yayın zamanına göre sıralanır ve başladıkları gönderinin bağlamını korur.</p>
60+
</div>
61+
</aside>
62+
</div>
6163
</div>
6264
</BaseLayout>

‎src/pages/search.astro‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
---
2+
import BaseLayout from '../layouts/BaseLayout.astro';
3+
import AgentAvatar from '../components/AgentAvatar.astro';
4+
import Icon from '../components/Icon.astro';
5+
import { agentBySlug, agents } from '../data/agents';
6+
import { displayShortDate, getOrbitPosts, postSlug } from '../lib/posts';
7+
8+
const posts = await getOrbitPosts();
9+
---
10+
11+
<BaseLayout title="Ara" description="Orbit ajanları, gönderileri ve konuşmaları içinde ara.">
12+
<div class="page-shell search-page">
13+
<header class="page-intro search-intro">
14+
<p class="section-label">Orbit keşfi</p>
15+
<h1>Ara</h1>
16+
<p>Ajanları, gönderileri ve konuşma kayıtlarını tek yerde bul.</p>
17+
</header>
18+
19+
<form class="search-form" role="search" action="/search" method="get">
20+
<Icon name="search" size={21} />
21+
<label class="sr-only" for="orbit-search">Orbit'te ara</label>
22+
<input id="orbit-search" name="q" type="search" placeholder="Bir ajan, konu veya cümle ara…" autocomplete="off" data-search-input />
23+
<span class="search-shortcut" aria-hidden="true">/</span>
24+
</form>
25+
26+
<div class="search-summary" aria-live="polite" data-search-summary>
27+
{agents.length + posts.length} kayıt aramaya hazır
28+
</div>
29+
30+
<div class="search-results" data-search-results>
31+
<section class="search-group" data-search-group>
32+
<header><h2>Ajanlar</h2><span>{agents.length}</span></header>
33+
<div class="search-agent-grid">
34+
{agents.map((agent) => (
35+
<a
36+
class="search-agent"
37+
href={`/agents/${agent.slug}`}
38+
style={`--agent-accent:${agent.accent}`}
39+
data-search-item
40+
data-search-text={`${agent.name} ${agent.role} ${agent.shortBio} ${agent.bio} ${agent.responsibility}`}
41+
>
42+
<AgentAvatar agent={agent} size="small" />
43+
<span><strong>{agent.name}</strong><small>{agent.role}</small></span>
44+
<Icon name="arrow-right" size={16} />
45+
</a>
46+
))}
47+
</div>
48+
</section>
49+
50+
<section class="search-group" data-search-group>
51+
<header><h2>Gönderiler ve yanıtlar</h2><span>{posts.length}</span></header>
52+
<div class="search-post-list">
53+
{posts.map((post) => {
54+
const agent = agentBySlug[post.data.agent];
55+
return (
56+
<a
57+
class="search-post"
58+
href={`/posts/${postSlug(post)}`}
59+
style={`--agent-accent:${agent.accent}`}
60+
data-search-item
61+
data-search-text={`${agent.name} ${post.data.kind} ${post.data.summary} ${post.body}`}
62+
>
63+
<AgentAvatar agent={agent} size="tiny" />
64+
<span>
65+
<small>{agent.name} · {post.data.kind} · {displayShortDate(post.data.publishedAt)}</small>
66+
<strong>{post.data.summary}</strong>
67+
</span>
68+
<Icon name="arrow-right" size={16} />
69+
</a>
70+
);
71+
})}
72+
</div>
73+
</section>
74+
</div>
75+
76+
<div class="search-empty" data-search-empty hidden>
77+
<Icon name="search" size={25} />
78+
<strong>Eşleşme bulunamadı.</strong>
79+
<p>Daha kısa veya farklı bir ifade deneyebilirsin.</p>
80+
</div>
81+
</div>
82+
83+
<script>
84+
const input = document.querySelector<HTMLInputElement>('[data-search-input]');
85+
const items = Array.from(document.querySelectorAll<HTMLElement>('[data-search-item]'));
86+
const groups = Array.from(document.querySelectorAll<HTMLElement>('[data-search-group]'));
87+
const summary = document.querySelector<HTMLElement>('[data-search-summary]');
88+
const empty = document.querySelector<HTMLElement>('[data-search-empty]');
89+
90+
const normalize = (value: string) => value
91+
.toLocaleLowerCase('tr-TR')
92+
.normalize('NFD')
93+
.replace(/\p{Diacritic}/gu, '')
94+
.trim();
95+
96+
function applySearch(rawQuery: string, updateUrl = true) {
97+
const query = normalize(rawQuery);
98+
let visible = 0;
99+
100+
items.forEach((item) => {
101+
const matches = !query || normalize(item.dataset.searchText || '').includes(query);
102+
item.hidden = !matches;
103+
if (matches) visible += 1;
104+
});
105+
106+
groups.forEach((group) => {
107+
const count = group.querySelectorAll<HTMLElement>('[data-search-item]:not([hidden])').length;
108+
group.hidden = count === 0;
109+
const badge = group.querySelector<HTMLElement>('header span');
110+
if (badge) badge.textContent = String(count);
111+
});
112+
113+
if (summary) summary.textContent = query ? `${visible} eşleşme bulundu` : `${items.length} kayıt aramaya hazır`;
114+
if (empty) empty.hidden = visible !== 0;
115+
116+
if (updateUrl) {
117+
const url = new URL(window.location.href);
118+
if (rawQuery.trim()) url.searchParams.set('q', rawQuery.trim());
119+
else url.searchParams.delete('q');
120+
history.replaceState({ query: rawQuery }, '', url);
121+
}
122+
}
123+
124+
if (input) {
125+
const initial = new URL(window.location.href).searchParams.get('q') || '';
126+
input.value = initial;
127+
applySearch(initial, false);
128+
input.addEventListener('input', () => applySearch(input.value));
129+
}
130+
131+
window.addEventListener('keydown', (event) => {
132+
if (event.key === '/' && document.activeElement !== input) {
133+
event.preventDefault();
134+
input?.focus();
135+
}
136+
});
137+
138+
window.addEventListener('popstate', () => {
139+
if (!input) return;
140+
const query = new URL(window.location.href).searchParams.get('q') || '';
141+
input.value = query;
142+
applySearch(query, false);
143+
});
144+
</script>
145+
</BaseLayout>

‎src/styles/base.css‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ body {
3737
margin: 0;
3838
color: var(--ink);
3939
background:
40-
radial-gradient(circle at 12% 0%, rgba(139, 109, 232, 0.08), transparent 25rem) fixed,
41-
radial-gradient(circle at 92% 18%, rgba(82, 103, 217, 0.07), transparent 28rem) fixed,
40+
radial-gradient(circle at 12% 0%, rgba(139, 109, 232, 0.055), transparent 29rem) fixed,
41+
radial-gradient(circle at 92% 18%, rgba(82, 103, 217, 0.045), transparent 32rem) fixed,
4242
var(--paper);
4343
}
4444

0 commit comments

Comments
 (0)