Skip to content

Commit 4a8c1d1

Browse files
committed
Align result screen with simplified create flow
1 parent b8b6ee8 commit 4a8c1d1

6 files changed

Lines changed: 264 additions & 85 deletions

File tree

frontend/src/__tests__/App.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ describe('App', () => {
130130

131131
render(<App />);
132132

133-
expect(screen.getByText('Feed URL ready')).toBeInTheDocument();
134133
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
135134
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
136135
expect(screen.getByText('Example Feed')).toBeInTheDocument();

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,22 @@ describe('ResultDisplay', () => {
1616

1717
beforeEach(() => {
1818
vi.clearAllMocks();
19+
vi.spyOn(window, 'fetch').mockResolvedValue({
20+
text: async () =>
21+
`<?xml version="1.0"?><rss><channel><item><description>1. Item One ( example.com )</description></item><item><description>2. Item Two ( example.com )</description></item><item><description>3. Item Three ( example.com )</description></item></channel></rss>`,
22+
} as Response);
1923
});
2024

21-
it('renders the simplified result actions', () => {
25+
it('renders the simplified result actions and preview', async () => {
2226
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
2327

24-
expect(screen.getByText('Feed URL ready')).toBeInTheDocument();
2528
expect(screen.getByText('Test Feed')).toBeInTheDocument();
2629
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
2730
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
31+
await waitFor(() => {
32+
expect(screen.getByText('Item One')).toBeInTheDocument();
33+
expect(screen.getByText('Item Three')).toBeInTheDocument();
34+
});
2835
});
2936

3037
it('calls onCreateAnother when the reset button is clicked', () => {

frontend/src/components/AppPanels.tsx

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
22
import { Bookmarklet } from './Bookmarklet';
3+
import { DominantField } from './DominantField';
34

45
export interface Strategy {
56
id: string;
@@ -64,6 +65,11 @@ export function CreateFeedPanel({
6465
}: CreateFeedPanelProps) {
6566
const selectedStrategy = strategies.find((strategy) => strategy.id === feedFormData.strategy);
6667
const urlInputRef = useRef<HTMLInputElement | null>(null);
68+
const strategyOptionLabel = (strategy: Strategy) => {
69+
if (strategy.id === 'ssrf_filter') return 'Standard rendering';
70+
if (strategy.id === 'browserless') return 'JavaScript pages';
71+
return strategy.display_name;
72+
};
6773

6874
useLayoutEffect(() => {
6975
if (!urlInputRef.current || typeof window === 'undefined') return;
@@ -82,25 +88,22 @@ export function CreateFeedPanel({
8288
return (
8389
<form class="form-shell form-shell--minimal" onSubmit={onFeedSubmit}>
8490
<div class="field-stack field-stack--dense">
85-
<label class="field-block field-block--primary field-block--hero" htmlFor="url">
86-
<span class="field-label field-label--ghost">Page URL</span>
87-
<input
88-
type="url"
89-
id="url"
90-
name="url"
91-
class="input input--mono input--hero"
92-
placeholder="https://example.com/article"
93-
autocomplete="url"
94-
autoFocus
95-
ref={urlInputRef}
96-
value={feedFormData.url}
97-
onInput={(event) => onFeedFieldChange('url', (event.target as HTMLInputElement).value)}
98-
/>
99-
<span class="field-error">{feedFieldErrors.url}</span>
100-
</label>
91+
<DominantField
92+
id="url"
93+
label="Page URL"
94+
type="url"
95+
value={feedFormData.url}
96+
placeholder="https://example.com/article"
97+
autoFocus
98+
inputRef={urlInputRef}
99+
actionLabel={isConverting ? 'Generating feed URL' : 'Generate feed URL'}
100+
actionText={isConverting ? '...' : '>'}
101+
disabled={isConverting || !feedCreationEnabled}
102+
error={feedFieldErrors.url}
103+
onInput={(event) => onFeedFieldChange('url', (event.target as HTMLInputElement).value)}
104+
/>
101105

102106
<label class="field-block field-block--select field-block--subtle" htmlFor="strategy">
103-
<span class="field-label field-label--inline">Rendering</span>
104107
<select
105108
id="strategy"
106109
name="strategy"
@@ -114,7 +117,7 @@ export function CreateFeedPanel({
114117
) : (
115118
strategies.map((strategy) => (
116119
<option key={strategy.id} value={strategy.id}>
117-
{strategy.display_name}
120+
{strategyOptionLabel(strategy)}
118121
</option>
119122
))
120123
)}
@@ -125,13 +128,6 @@ export function CreateFeedPanel({
125128
<p class="field-help">{strategyHint(selectedStrategy)}</p>
126129
)}
127130

128-
<button
129-
type="submit"
130-
class="btn btn--primary btn--hero"
131-
disabled={isConverting || !feedCreationEnabled}
132-
>
133-
{isConverting ? 'Generating…' : 'Generate feed URL'}
134-
</button>
135131
{!feedCreationEnabled && (
136132
<p class="field-help field-help--alert">Custom feed generation is disabled for this instance.</p>
137133
)}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { JSX, Ref } from 'preact';
2+
3+
interface DominantFieldProps {
4+
id: string;
5+
label: string;
6+
value: string;
7+
placeholder?: string;
8+
type?: string;
9+
readOnly?: boolean;
10+
autoFocus?: boolean;
11+
disabled?: boolean;
12+
actionLabel: string;
13+
actionText: string;
14+
onAction?: () => void;
15+
onInput?: JSX.GenericEventHandler<HTMLInputElement>;
16+
inputRef?: Ref<HTMLInputElement>;
17+
error?: string;
18+
}
19+
20+
export function DominantField({
21+
id,
22+
label,
23+
value,
24+
placeholder,
25+
type = 'text',
26+
readOnly = false,
27+
autoFocus = false,
28+
disabled = false,
29+
actionLabel,
30+
actionText,
31+
onAction,
32+
onInput,
33+
inputRef,
34+
error,
35+
}: DominantFieldProps) {
36+
return (
37+
<div class="dominant-field">
38+
<label class="field-block field-block--primary field-block--hero" htmlFor={id}>
39+
<span class="field-label field-label--ghost">{label}</span>
40+
<input
41+
id={id}
42+
name={id}
43+
type={type}
44+
class="input input--mono input--hero"
45+
placeholder={placeholder}
46+
autocomplete={type === 'url' ? 'url' : 'off'}
47+
autoFocus={autoFocus}
48+
ref={inputRef}
49+
value={value}
50+
readOnly={readOnly}
51+
disabled={disabled}
52+
onInput={onInput}
53+
/>
54+
<span class="field-error">{error ?? ''}</span>
55+
</label>
56+
<button
57+
type={onAction ? 'button' : 'submit'}
58+
class={`dominant-field__action${actionText.length > 1 ? ' dominant-field__action--text' : ''}`}
59+
disabled={disabled}
60+
aria-label={actionLabel}
61+
onClick={onAction}
62+
>
63+
{actionText}
64+
</button>
65+
</div>
66+
);
67+
}
Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useRef, useState } from 'preact/hooks';
22
import type { FeedRecord } from '../api/contracts';
3+
import { DominantField } from './DominantField';
34

45
interface ResultDisplayProps {
56
result: FeedRecord;
@@ -8,6 +9,7 @@ interface ResultDisplayProps {
89

910
export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
1011
const [copyNotice, setCopyNotice] = useState('');
12+
const [previewItems, setPreviewItems] = useState<string[]>([]);
1113
const copyResetRef = useRef<number | undefined>(undefined);
1214

1315
const fullUrl = result.public_url.startsWith('http')
@@ -20,6 +22,44 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
2022
};
2123
}, []);
2224

25+
useEffect(() => {
26+
let isCancelled = false;
27+
28+
const loadPreview = async () => {
29+
try {
30+
const response = await window.fetch(fullUrl);
31+
const xml = await response.text();
32+
const document = new DOMParser().parseFromString(xml, 'application/xml');
33+
const explicitTitles = Array.from(document.querySelectorAll('item > title, entry > title')).map(
34+
(node) => node.textContent?.trim()
35+
);
36+
const derivedDescriptions = Array.from(document.querySelectorAll('item > description'))
37+
.map((node) => node.textContent?.trim())
38+
.filter((description): description is string => Boolean(description))
39+
.filter((description) => /^\d+\.\s+/.test(description))
40+
.map((description) =>
41+
description
42+
.replace(/^\d+\.\s+/, '')
43+
.replace(/\s+\([^)]*\)\s*$/, '')
44+
.trim()
45+
);
46+
const itemTitles = [...explicitTitles, ...derivedDescriptions]
47+
.filter((title): title is string => Boolean(title))
48+
.slice(0, 3);
49+
50+
if (!isCancelled) setPreviewItems(itemTitles);
51+
} catch {
52+
if (!isCancelled) setPreviewItems([]);
53+
}
54+
};
55+
56+
void loadPreview();
57+
58+
return () => {
59+
isCancelled = true;
60+
};
61+
}, [fullUrl]);
62+
2363
const copyToClipboard = async (text: string) => {
2464
try {
2565
await navigator.clipboard.writeText(text);
@@ -34,40 +74,44 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
3474
return (
3575
<section class="result-shell" aria-live="polite">
3676
<div class="result-copy">
37-
<p class="result-title">Feed URL ready</p>
3877
<p class="result-meta">{result.name}</p>
3978
</div>
4079

41-
<label class="field-block field-block--primary field-block--hero result-url" htmlFor="feed-url">
42-
<span class="field-label field-label--ghost">Feed URL</span>
43-
<input
44-
id="feed-url"
45-
name="feed-url"
46-
type="text"
47-
value={fullUrl}
48-
readOnly
49-
class="input input--mono input--hero"
50-
/>
51-
</label>
80+
<DominantField
81+
id="feed-url"
82+
label="Feed URL"
83+
value={fullUrl}
84+
readOnly
85+
actionLabel="Copy feed URL"
86+
actionText="Copy"
87+
onAction={() => void copyToClipboard(fullUrl)}
88+
/>
5289

53-
<div class="result-actions">
54-
<button type="button" class="btn btn--primary btn--hero" onClick={() => copyToClipboard(fullUrl)}>
55-
Copy feed URL
56-
</button>
90+
{previewItems.length > 0 && (
91+
<section class="result-preview" aria-label="Feed preview">
92+
<p class="result-preview__label">Latest items</p>
93+
<ul class="result-preview__list">
94+
{previewItems.map((item) => (
95+
<li key={item}>{item}</li>
96+
))}
97+
</ul>
98+
</section>
99+
)}
100+
101+
<div class="result-actions result-actions--quiet">
57102
<a href={fullUrl} class="btn btn--ghost btn--linkish" target="_blank" rel="noopener noreferrer">
58103
Open feed
59104
</a>
105+
<button type="button" class="btn btn--quiet btn--linkish" onClick={onCreateAnother}>
106+
Create another feed
107+
</button>
60108
</div>
61109

62110
{copyNotice && (
63111
<div class="notice notice--success" role="status">
64112
<p>{copyNotice}</p>
65113
</div>
66114
)}
67-
68-
<button type="button" class="btn btn--quiet btn--linkish" onClick={onCreateAnother}>
69-
Create another feed
70-
</button>
71115
</section>
72116
);
73117
}

0 commit comments

Comments
 (0)