Skip to content

Commit d86d9cb

Browse files
committed
Refine feed result and rendered feed UI
1 parent 91fb8d5 commit d86d9cb

7 files changed

Lines changed: 838 additions & 129 deletions

File tree

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ describe('App contract', () => {
3535

3636
return HttpResponse.json(
3737
{
38-
items: [{ title: 'Contract Item' }],
38+
items: [
39+
{
40+
title: 'Contract Item',
41+
content_text: 'Contract preview excerpt.',
42+
url: 'https://example.com/contract-item',
43+
date_published: '2024-01-01T00:00:00Z',
44+
},
45+
],
3946
},
4047
{
4148
headers: { 'content-type': 'application/feed+json' },
@@ -57,16 +64,18 @@ describe('App contract', () => {
5764
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
5865

5966
await waitFor(() => {
67+
expect(screen.getByText('Your feed is ready')).toBeInTheDocument();
6068
expect(screen.getByText('Example Feed')).toBeInTheDocument();
6169
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
6270
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
6371
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
64-
expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute(
72+
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
6573
'href',
6674
'http://localhost:3000/api/v1/feeds/generated-token.json'
6775
);
6876
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
69-
expect(screen.getByText('Feed preview')).toBeInTheDocument();
77+
expect(screen.getByText('Preview')).toBeInTheDocument();
78+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
7079
expect(screen.getByText('Contract Item')).toBeInTheDocument();
7180
});
7281
});

frontend/src/__tests__/App.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ describe('App', () => {
206206
});
207207

208208
it('renders the result panel when a feed is available', async () => {
209+
vi.spyOn(window, 'fetch').mockResolvedValue({
210+
ok: true,
211+
json: async () => ({ items: [] }),
212+
} as Response);
213+
209214
mockUseFeedConversion.mockReturnValue({
210215
isConverting: false,
211216
result: {

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,44 @@ describe('ResultDisplay', () => {
2121
ok: true,
2222
json: async () => ({
2323
items: [
24-
{ title: 'Item One' },
25-
{ content_text: '56 points by canpan 1 hour ago | hide | 18 comments' },
26-
{ content_text: '2. Item Two ( example.com )' },
24+
{
25+
title: 'Item One',
26+
content_text: '<p>First preview item with <strong>markup</strong>.</p>',
27+
url: 'https://example.com/item-one',
28+
date_published: '2024-01-01T00:00:00Z',
29+
},
30+
{
31+
content_text: '56 points by canpan 1 hour ago | hide | 18&nbsp;comments',
32+
date_published: '2024-01-02T00:00:00Z',
33+
},
34+
{
35+
content_text: '2. Item Two ( example.com )',
36+
url: 'https://example.com/item-two',
37+
date_published: '2024-01-03T00:00:00Z',
38+
},
2739
],
2840
}),
2941
} as Response);
3042
});
3143

32-
it('renders the simplified result actions and preview', async () => {
44+
it('renders the success state actions and richer preview cards', async () => {
3345
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
3446

47+
expect(screen.getByText('Your feed is ready')).toBeInTheDocument();
3548
expect(screen.getByText('Test Feed')).toBeInTheDocument();
3649
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
3750
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
38-
expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute(
51+
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
3952
'href',
4053
'https://example.com/feed.json'
4154
);
4255
await waitFor(() => {
4356
expect(screen.getByText('Item One')).toBeInTheDocument();
57+
expect(screen.getByText('First preview item with markup.')).toBeInTheDocument();
58+
expect(screen.getAllByText('Open original')).toHaveLength(2);
4459
expect(screen.getByText(/points by canpan/i)).toBeInTheDocument();
4560
expect(screen.getByText('Item Two')).toBeInTheDocument();
61+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
4662
});
4763
expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', {
4864
headers: { Accept: 'application/feed+json' },
@@ -59,6 +75,7 @@ describe('ResultDisplay', () => {
5975

6076
await waitFor(() => {
6177
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
78+
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
6279
});
6380
});
6481

frontend/src/components/DominantField.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { JSX, Ref } from 'preact';
22

33
interface DominantFieldProps {
4+
className?: string;
45
id: string;
56
label: string;
67
value: string;
@@ -19,6 +20,7 @@ interface DominantFieldProps {
1920
}
2021

2122
export function DominantField({
23+
className,
2224
id,
2325
label,
2426
value,
@@ -36,7 +38,7 @@ export function DominantField({
3638
error,
3739
}: DominantFieldProps) {
3840
return (
39-
<div class="dominant-field">
41+
<div class={className ? `dominant-field ${className}` : 'dominant-field'}>
4042
<label class="field-block field-block--primary field-block--hero" htmlFor={id}>
4143
<span class="field-label field-label--ghost">{label}</span>
4244
<input

frontend/src/components/ResultDisplay.tsx

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,31 @@ import { DominantField } from './DominantField';
55
interface JsonFeedItem {
66
title?: string;
77
content_text?: string;
8+
content_html?: string;
9+
url?: string;
10+
external_url?: string;
11+
date_published?: string;
812
}
913

1014
interface JsonFeedResponse {
1115
items?: JsonFeedItem[];
1216
}
1317

18+
interface PreviewItem {
19+
title: string;
20+
excerpt: string;
21+
publishedLabel: string;
22+
url?: string;
23+
}
24+
1425
interface ResultDisplayProps {
1526
result: FeedRecord;
1627
onCreateAnother: () => void;
1728
}
1829

1930
export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
2031
const [copyNotice, setCopyNotice] = useState('');
21-
const [previewItems, setPreviewItems] = useState<string[]>([]);
32+
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
2233
const [previewError, setPreviewError] = useState('');
2334
const copyResetRef = useRef<number | undefined>(undefined);
2435

@@ -45,14 +56,14 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
4556
});
4657
if (!response.ok) throw new Error('Preview request failed');
4758
const payload = (await response.json()) as JsonFeedResponse;
48-
const itemTitles =
59+
const items =
4960
payload.items
50-
?.map((item) => normalizePreviewText(item.title || item.content_text))
51-
.filter((title): title is string => Boolean(title))
52-
.slice(0, 3) || [];
61+
?.map((item) => normalizePreviewItem(item))
62+
.filter((item): item is PreviewItem => Boolean(item))
63+
.slice(0, 5) || [];
5364

5465
if (!isCancelled) {
55-
setPreviewItems(itemTitles);
66+
setPreviewItems(items);
5667
setPreviewError('');
5768
}
5869
} catch {
@@ -82,12 +93,16 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
8293
};
8394

8495
return (
85-
<section class="result-shell" aria-live="polite">
86-
<div class="result-copy">
87-
<p class="result-meta">{result.name}</p>
88-
</div>
96+
<section class="result-shell layout-stack" aria-live="polite">
97+
<header class="result-hero layout-rail-reading layout-stack" style={{ '--stack-gap': 'var(--space-3)' }}>
98+
<p class="result-kicker ui-eyebrow">Feed created</p>
99+
<h1 class="result-title">Your feed is ready</h1>
100+
<p class="result-meta layout-rail-copy">{result.name}</p>
101+
<p class="result-lede layout-rail-copy">Subscribe to this URL in your RSS reader.</p>
102+
</header>
89103

90104
<DominantField
105+
className="layout-rail-reading"
91106
id="feed-url"
92107
label="Feed URL"
93108
value={fullUrl}
@@ -98,32 +113,51 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
98113
onAction={() => void copyToClipboard(fullUrl)}
99114
/>
100115

101-
<div class="result-actions result-actions--quiet">
102-
<a href={fullUrl} class="btn btn--ghost btn--linkish" target="_blank" rel="noopener noreferrer">
116+
<div class="result-actions result-actions--quiet layout-rail-reading">
117+
<a href={fullUrl} class="btn btn--ghost" target="_blank" rel="noopener noreferrer">
103118
Open feed
104119
</a>
105-
<a href={jsonFeedUrl} class="btn btn--ghost btn--linkish" target="_blank" rel="noopener noreferrer">
106-
JSON Feed
120+
<a href={jsonFeedUrl} class="btn btn--ghost" target="_blank" rel="noopener noreferrer">
121+
Open JSON Feed
107122
</a>
108123
<button type="button" class="btn btn--quiet btn--linkish" onClick={onCreateAnother}>
109124
Create another feed
110125
</button>
111126
</div>
112127

113128
{previewItems.length > 0 && (
114-
<section class="result-preview" aria-label="Feed preview">
115-
<p class="result-preview__label">Feed preview</p>
116-
<ul class="result-preview__list">
129+
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview">
130+
<div class="result-preview__header layout-stack layout-stack--tight">
131+
<p class="result-preview__label ui-eyebrow">Preview</p>
132+
<p class="result-preview__intro">Latest items from this feed</p>
133+
</div>
134+
<ul class="result-preview__list" role="list">
117135
{previewItems.map((item) => (
118-
<li key={item}>{item}</li>
136+
<li key={`${item.title}-${item.publishedLabel || 'undated'}`}>
137+
<article class="preview-card ui-card layout-stack layout-stack--tight">
138+
<h2 class="preview-card__title">{item.title}</h2>
139+
{item.publishedLabel && <p class="preview-card__date">{item.publishedLabel}</p>}
140+
{item.excerpt && <p class="preview-card__excerpt">{item.excerpt}</p>}
141+
{item.url && (
142+
<p class="preview-card__actions">
143+
<a href={item.url} target="_blank" rel="noopener noreferrer">
144+
Open original
145+
</a>
146+
</p>
147+
)}
148+
</article>
149+
</li>
119150
))}
120151
</ul>
121152
</section>
122153
)}
123154

124155
{previewError && (
125-
<section class="result-preview" aria-label="Feed preview status">
126-
<p class="result-preview__label">Feed preview</p>
156+
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview status">
157+
<div class="result-preview__header layout-stack layout-stack--tight">
158+
<p class="result-preview__label ui-eyebrow">Preview</p>
159+
<p class="result-preview__intro">Latest items from this feed</p>
160+
</div>
127161
<p class="field-help">{previewError}</p>
128162
</section>
129163
)}
@@ -141,14 +175,63 @@ function normalizePreviewText(value?: string): string | null {
141175
if (!value) return null;
142176

143177
const normalized = decodeHtmlEntities(value)
178+
.replace(/<[^>]*>/g, ' ')
144179
.replace(/\s+/g, ' ')
180+
.replace(/\s+([.,!?;:])/g, '$1')
145181
.replace(/^\d+\.\s+/, '')
146182
.replace(/\s+\([^)]*\)\s*$/, '')
147183
.trim();
148184

149185
return normalized || null;
150186
}
151187

188+
function normalizePreviewItem(item: JsonFeedItem): PreviewItem | null {
189+
const excerptSource = item.content_text || item.content_html;
190+
const title = normalizePreviewText(item.title) || normalizePreviewText(excerptSource) || 'Untitled item';
191+
const excerpt = normalizePreviewExcerpt(excerptSource, title);
192+
193+
return {
194+
title,
195+
excerpt,
196+
publishedLabel: formatPublishedDate(item.date_published),
197+
url: normalizePreviewUrl(item.url || item.external_url),
198+
};
199+
}
200+
201+
function normalizePreviewExcerpt(value: string | undefined, title: string): string {
202+
const excerpt = normalizePreviewText(value);
203+
if (!excerpt || excerpt === title) return '';
204+
return truncateText(excerpt, 220);
205+
}
206+
207+
function normalizePreviewUrl(value?: string): string | undefined {
208+
if (!value) return undefined;
209+
if (!/^https?:\/\//i.test(value)) return undefined;
210+
return value;
211+
}
212+
213+
function formatPublishedDate(value?: string): string {
214+
if (!value) return '';
215+
216+
const parsed = new Date(value);
217+
if (Number.isNaN(parsed.getTime())) return '';
218+
219+
return new Intl.DateTimeFormat(undefined, {
220+
month: 'short',
221+
day: 'numeric',
222+
year: 'numeric',
223+
}).format(parsed);
224+
}
225+
226+
function truncateText(value: string, maxLength: number): string {
227+
if (value.length <= maxLength) return value;
228+
229+
const clipped = value.slice(0, maxLength).trimEnd();
230+
const safeBoundary = clipped.lastIndexOf(' ');
231+
232+
return `${(safeBoundary > maxLength * 0.6 ? clipped.slice(0, safeBoundary) : clipped).trimEnd()}...`;
233+
}
234+
152235
function decodeHtmlEntities(value: string): string {
153236
if (typeof document === 'undefined') return value;
154237

0 commit comments

Comments
 (0)