diff --git a/Gemfile b/Gemfile
index 8f9ae8c3..fd5ba21e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -23,6 +23,7 @@ gem 'puma', require: false
group :development do
gem 'byebug'
+ gem 'irb', require: false
gem 'rake', require: false
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index e87d1d9e..7a063a26 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -106,6 +106,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ date (3.5.1)
diff-lcs (1.6.2)
docile (1.4.1)
drb (2.2.3)
@@ -144,6 +145,7 @@ GEM
dry-initializer (~> 3.2)
dry-schema (~> 1.14)
zeitwerk (~> 2.6)
+ erb (6.0.2)
erubi (1.13.1)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
@@ -167,6 +169,11 @@ GEM
io-endpoint (0.17.2)
io-event (1.14.5)
io-stream (0.11.1)
+ irb (1.17.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
json (2.19.3)
json-schema (6.2.0)
addressable (~> 2.8)
@@ -212,6 +219,9 @@ GEM
parser (3.3.10.2)
ast (~> 2.4.1)
racc
+ pp (0.6.3)
+ prettyprint
+ prettyprint (0.2.0)
prism (1.9.0)
protocol-hpack (1.5.1)
protocol-http (0.60.0)
@@ -227,6 +237,9 @@ GEM
protocol-url (0.4.0)
protocol-websocket (0.20.2)
protocol-http (~> 0.2)
+ psych (5.3.1)
+ date
+ stringio
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
@@ -258,6 +271,10 @@ GEM
rbs (3.10.3)
logger
tsort
+ rdoc (7.2.0)
+ erb
+ psych (>= 4.0.0)
+ tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -335,6 +352,7 @@ GEM
simplecov_json_formatter (0.1.4)
ssrf_filter (1.3.0)
stackprof (0.2.28)
+ stringio (3.2.0)
thor (1.5.0)
traces (0.18.2)
tsort (0.2.0)
@@ -369,6 +387,7 @@ DEPENDENCIES
climate_control
html2rss!
html2rss-configs!
+ irb
parallel
puma
rack-cache
@@ -414,6 +433,7 @@ CHECKSUMS
console (1.34.3) sha256=869fbd74697efc4c606f102d2812b0b008e4e7fd738a91c591e8577140ec0dcc
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
+ date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
@@ -425,6 +445,7 @@ CHECKSUMS
dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
dry-validation (1.11.1) sha256=70900bb5a2d911c8aab566d3e360c6bff389b8bf92ea8e04885ce51c41ff8085
+ erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
@@ -441,6 +462,7 @@ CHECKSUMS
io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338
io-event (1.14.5) sha256=68ac367032a3873416dc2e0b67332dfaf2e23b65b58e6465d301c7e5cd9163b1
io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87
+ irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
@@ -465,6 +487,8 @@ CHECKSUMS
nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
+ pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
protocol-http (0.60.0) sha256=ca1354947676d663b6f23c49654aee464288774e7867c4a6e406fecce9691cec
@@ -473,6 +497,7 @@ CHECKSUMS
protocol-rack (0.22.0) sha256=b7c49c0b597ca2c6d20f8bcd746c4415a1b750eacfbe64f828e780c978a4293d
protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1
protocol-websocket (0.20.2) sha256=c41d93c35fba5dae85375c597f76975f3dbd75d8c5b2f21b33dab4dc22a5a511
+ psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
puppeteer-ruby (0.51.0) sha256=8a7637963f8cd5b88416dd8c669a3ec2fe40a42cda2449539d75525a4da2f233
@@ -487,6 +512,7 @@ CHECKSUMS
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6
+ rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af
@@ -515,6 +541,7 @@ CHECKSUMS
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
ssrf_filter (1.3.0) sha256=66882d7de7d09c019098d6d7372412950ae184ebbc7c51478002058307aba6f2
stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04
+ stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx
index 87d906a6..e5375a46 100644
--- a/frontend/src/__tests__/App.contract.test.tsx
+++ b/frontend/src/__tests__/App.contract.test.tsx
@@ -30,7 +30,7 @@ describe('App contract', () => {
})
);
}),
- http.get('/api/v1/feeds/generated-token', ({ request }) => {
+ http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
expect(request.headers.get('accept')).toBe('application/feed+json');
return HttpResponse.json(
diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx
index 87703b90..3eabb588 100644
--- a/frontend/src/__tests__/App.test.tsx
+++ b/frontend/src/__tests__/App.test.tsx
@@ -206,21 +206,22 @@ describe('App', () => {
});
it('renders the result panel when a feed is available', async () => {
- vi.spyOn(window, 'fetch').mockResolvedValue({
- ok: true,
- json: async () => ({ items: [] }),
- } as Response);
-
mockUseFeedConversion.mockReturnValue({
isConverting: false,
result: {
- id: 'feed-123',
- name: 'Example Feed',
- url: 'https://example.com/articles',
- strategy: 'faraday',
- feed_token: 'example-token',
- public_url: '/api/v1/feeds/example-token',
- json_public_url: '/api/v1/feeds/example-token.json',
+ feed: {
+ id: 'feed-123',
+ name: 'Example Feed',
+ url: 'https://example.com/articles',
+ strategy: 'faraday',
+ feed_token: 'example-token',
+ public_url: '/api/v1/feeds/example-token',
+ json_public_url: '/api/v1/feeds/example-token.json',
+ },
+ preview: {
+ items: [],
+ error: 'Preview unavailable right now.',
+ },
},
error: null,
convertFeed: mockConvertFeed,
@@ -233,6 +234,7 @@ describe('App', () => {
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
expect(screen.getByText('Example Feed')).toBeInTheDocument();
+ expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
});
it('surfaces conversion errors to the user', () => {
@@ -250,6 +252,24 @@ describe('App', () => {
expect(screen.getByText('Access denied')).toBeInTheDocument();
});
+ it('shows an explicit loading notice while feed creation is still resolving preview state', () => {
+ mockUseFeedConversion.mockReturnValue({
+ isConverting: true,
+ result: null,
+ error: null,
+ convertFeed: mockConvertFeed,
+ clearError: mockClearConversionError,
+ clearResult: mockClearResult,
+ });
+
+ render();
+
+ expect(screen.getByText('Preparing feed')).toBeInTheDocument();
+ expect(
+ screen.getByText('Creating the feed and loading its preview before showing the result.')
+ ).toBeInTheDocument();
+ });
+
it('clears stored token from instance info', () => {
mockUseAccessToken.mockReturnValue({
token: 'saved-token',
diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx
index b69fd86c..696dfb50 100644
--- a/frontend/src/__tests__/ResultDisplay.test.tsx
+++ b/frontend/src/__tests__/ResultDisplay.test.tsx
@@ -6,39 +6,41 @@ import { ResultDisplay } from '../components/ResultDisplay';
describe('ResultDisplay', () => {
const mockOnCreateAnother = vi.fn();
const mockResult = {
- id: 'test-id',
- name: 'Test Feed',
- url: 'https://example.com',
- strategy: 'faraday',
- feed_token: 'test-feed-token',
- public_url: 'https://example.com/feed.xml',
- json_public_url: 'https://example.com/feed.json',
+ feed: {
+ id: 'test-id',
+ name: 'Test Feed',
+ url: 'https://example.com',
+ strategy: 'faraday',
+ feed_token: 'test-feed-token',
+ public_url: 'https://example.com/feed.xml',
+ json_public_url: 'https://example.com/feed.json',
+ },
+ preview: {
+ items: [
+ {
+ title: 'Item One',
+ excerpt: 'First preview item with markup.',
+ url: 'https://example.com/item-one',
+ publishedLabel: 'Jan 1, 2024',
+ },
+ {
+ title: '56 points by canpan 1 hour ago | hide | 18 comments',
+ excerpt: '',
+ publishedLabel: 'Jan 2, 2024',
+ },
+ {
+ title: 'Item Two',
+ excerpt: '',
+ url: 'https://example.com/item-two',
+ publishedLabel: 'Jan 3, 2024',
+ },
+ ],
+ error: null,
+ },
};
beforeEach(() => {
vi.clearAllMocks();
- vi.spyOn(window, 'fetch').mockResolvedValue({
- ok: true,
- json: async () => ({
- items: [
- {
- title: 'Item One',
- content_text: '
First preview item with markup.
',
- url: 'https://example.com/item-one',
- date_published: '2024-01-01T00:00:00Z',
- },
- {
- content_text: '56 points by canpan 1 hour ago | hide | 18 comments',
- date_published: '2024-01-02T00:00:00Z',
- },
- {
- content_text: '2. Item Two ( example.com )',
- url: 'https://example.com/item-two',
- date_published: '2024-01-03T00:00:00Z',
- },
- ],
- }),
- } as Response);
});
it('renders the success state actions and richer preview cards', async () => {
@@ -60,18 +62,15 @@ describe('ResultDisplay', () => {
expect(screen.getByText('Item Two')).toBeInTheDocument();
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
});
- expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', {
- headers: { Accept: 'application/feed+json' },
- });
});
- it('surfaces preview fetch failures as a result-state message', async () => {
- vi.mocked(window.fetch).mockResolvedValueOnce({
- ok: false,
- json: async () => ({}),
- } as Response);
-
- render();
+ it('surfaces preview failures as a result-state message', async () => {
+ render(
+
+ );
await waitFor(() => {
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts
index 3594f61a..66c108f8 100644
--- a/frontend/src/__tests__/setup.ts
+++ b/frontend/src/__tests__/setup.ts
@@ -1,7 +1,8 @@
import '@testing-library/jest-dom';
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
import { cleanup } from '@testing-library/preact';
-import { server } from './mocks/server';
+
+let server: typeof import('./mocks/server').server;
// Mock window and document for tests
Object.defineProperty(window, 'matchMedia', {
@@ -49,10 +50,16 @@ const session = createStorageMock();
Object.defineProperty(window, 'localStorage', {
value: local.api,
});
+Object.defineProperty(globalThis, 'localStorage', {
+ value: local.api,
+});
Object.defineProperty(window, 'sessionStorage', {
value: session.api,
});
+Object.defineProperty(globalThis, 'sessionStorage', {
+ value: session.api,
+});
beforeEach(() => {
local.store.clear();
@@ -80,7 +87,10 @@ Object.assign(navigator, {
Element.prototype.scrollIntoView = vi.fn();
// Wire up MSW in node environment
-beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+beforeAll(async () => {
+ ({ server } = await import('./mocks/server'));
+ server.listen({ onUnhandledRequest: 'error' });
+});
afterEach(() => {
server.resetHandlers();
cleanup();
diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts
index a7f51a17..737a1572 100644
--- a/frontend/src/__tests__/useFeedConversion.contract.test.ts
+++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts
@@ -24,6 +24,20 @@ describe('useFeedConversion contract', () => {
}),
{ status: 201 }
);
+ }),
+ http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
+ expect(request.headers.get('accept')).toBe('application/feed+json');
+
+ return HttpResponse.json({
+ items: [
+ {
+ title: 'Generated item',
+ content_text: 'Contract preview',
+ url: 'https://example.com/items/generated',
+ date_published: '2024-01-02T00:00:00Z',
+ },
+ ],
+ });
})
);
@@ -35,9 +49,11 @@ describe('useFeedConversion contract', () => {
expect(receivedAuthorization).toBe('Bearer test-token-123');
expect(result.current.error).toBeNull();
- expect(result.current.result?.feed_token).toBe('generated-token');
- expect(result.current.result?.public_url).toBe('/api/v1/feeds/generated-token');
- expect(result.current.result?.json_public_url).toBe('/api/v1/feeds/generated-token.json');
+ expect(result.current.result?.feed.feed_token).toBe('generated-token');
+ expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token');
+ expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json');
+ expect(result.current.result?.preview.error).toBeNull();
+ expect(result.current.result?.preview.items).toHaveLength(1);
});
it('propagates API validation errors', async () => {
@@ -83,4 +99,31 @@ describe('useFeedConversion contract', () => {
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Invalid response format from feed creation API');
});
+
+ it('preserves the created feed when preview loading fails', async () => {
+ server.use(
+ http.post('/api/v1/feeds', async () =>
+ HttpResponse.json(
+ buildFeedResponse({
+ feed_token: 'generated-token',
+ public_url: '/api/v1/feeds/generated-token',
+ json_public_url: '/api/v1/feeds/generated-token.json',
+ }),
+ { status: 201 }
+ )
+ ),
+ http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(null, { status: 502 }))
+ );
+
+ const { result } = renderHook(() => useFeedConversion());
+
+ await act(async () => {
+ await result.current.convertFeed('https://example.com/articles', 'faraday', 'token');
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.result?.feed.feed_token).toBe('generated-token');
+ expect(result.current.result?.preview.items).toEqual([]);
+ expect(result.current.result?.preview.error).toBe('Preview unavailable right now.');
+ });
});
diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts
index 9fe72176..e58713e3 100644
--- a/frontend/src/__tests__/useFeedConversion.test.ts
+++ b/frontend/src/__tests__/useFeedConversion.test.ts
@@ -23,13 +23,13 @@ describe('useFeedConversion', () => {
});
it('should handle successful conversion', async () => {
- const mockResult = {
+ const mockFeed = {
id: 'test-id',
name: 'Test Feed',
url: 'https://example.com',
strategy: 'faraday',
feed_token: 'test-token',
- public_url: 'https://example.com/feed.xml',
+ public_url: 'https://example.com/feed',
json_public_url: 'https://example.com/feed.json',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
@@ -39,7 +39,7 @@ describe('useFeedConversion', () => {
new Response(
JSON.stringify({
success: true,
- data: { feed: mockResult },
+ data: { feed: mockFeed },
}),
{
status: 201,
@@ -47,6 +47,24 @@ describe('useFeedConversion', () => {
}
)
);
+ fetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ items: [
+ {
+ title: 'Preview item',
+ content_text: 'Preview excerpt',
+ url: 'https://example.com/item',
+ date_published: '2024-01-02T00:00:00Z',
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { 'Content-Type': 'application/feed+json' },
+ }
+ )
+ );
const { result } = renderHook(() => useFeedConversion());
@@ -55,9 +73,22 @@ describe('useFeedConversion', () => {
});
expect(result.current.isConverting).toBe(false);
- expect(result.current.result).toEqual(mockResult);
+ expect(result.current.result).toEqual({
+ feed: mockFeed,
+ preview: {
+ items: [
+ {
+ title: 'Preview item',
+ excerpt: 'Preview excerpt',
+ publishedLabel: 'Jan 2, 2024',
+ url: 'https://example.com/item',
+ },
+ ],
+ error: null,
+ },
+ });
expect(result.current.error).toBeNull();
- expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('should handle conversion error', async () => {
@@ -102,4 +133,50 @@ describe('useFeedConversion', () => {
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Network error');
});
+
+ it('preserves the created feed when preview loading fails after feed creation', async () => {
+ const createdFeed = {
+ id: 'test-id',
+ name: 'Test Feed',
+ url: 'https://example.com',
+ strategy: 'faraday',
+ feed_token: 'test-token',
+ public_url: 'https://example.com/feed',
+ json_public_url: 'https://example.com/feed.json',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ };
+
+ fetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: {
+ feed: createdFeed,
+ },
+ }),
+ {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ )
+ );
+ fetchMock.mockResolvedValueOnce(new Response('nope', { status: 502 }));
+
+ const { result } = renderHook(() => useFeedConversion());
+
+ await act(async () => {
+ await result.current.convertFeed('https://example.com', 'faraday', 'testtoken');
+ });
+
+ expect(result.current.isConverting).toBe(false);
+ expect(result.current.result).toEqual({
+ feed: createdFeed,
+ preview: {
+ items: [],
+ error: 'Preview unavailable right now.',
+ },
+ });
+ expect(result.current.error).toBeNull();
+ });
});
diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts
index 14867b84..a012dcea 100644
--- a/frontend/src/api/contracts.ts
+++ b/frontend/src/api/contracts.ts
@@ -2,6 +2,22 @@ import type { CreateFeedResponses, GetApiMetadataResponses, ListStrategiesRespon
export type FeedRecord = CreateFeedResponses[201]['data']['feed'];
export type StrategyRecord = ListStrategiesResponses[200]['data']['strategies'][number];
+export interface FeedPreviewItem {
+ title: string;
+ excerpt: string;
+ publishedLabel: string;
+ url?: string;
+}
+
+export interface FeedPreviewState {
+ items: FeedPreviewItem[];
+ error: string | null;
+}
+
+export interface CreatedFeedResult {
+ feed: FeedRecord;
+ preview: FeedPreviewState;
+}
export interface ApiMetadataRecord {
api: GetApiMetadataResponses[200]['data']['api'];
diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx
index cde0cd5a..29bc435e 100644
--- a/frontend/src/components/AppPanels.tsx
+++ b/frontend/src/components/AppPanels.tsx
@@ -111,7 +111,7 @@ export function CreateFeedPanel({
placeholder="https://example.com/article"
autoFocus
inputRef={urlInputRef}
- actionLabel={isConverting ? 'Generating feed URL' : 'Generate feed URL'}
+ actionLabel={isConverting ? 'Preparing feed' : 'Generate feed URL'}
actionText={isConverting ? '...' : '>'}
disabled={submitDisabled}
error={feedFieldErrors.url}
@@ -236,6 +236,13 @@ export function CreateFeedPanel({
)}
+ {isConverting && (
+
+
Preparing feed
+
Creating the feed and loading its preview before showing the result.
+
+ )}
+
{feedFieldErrors.form && (
{feedFieldErrors.form}
diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx
index 2a16d2b5..67afaadd 100644
--- a/frontend/src/components/ResultDisplay.tsx
+++ b/frontend/src/components/ResultDisplay.tsx
@@ -1,44 +1,23 @@
import { useEffect, useRef, useState } from 'preact/hooks';
-import type { FeedRecord } from '../api/contracts';
+import type { CreatedFeedResult } from '../api/contracts';
import { DominantField } from './DominantField';
-interface JsonFeedItem {
- title?: string;
- content_text?: string;
- content_html?: string;
- url?: string;
- external_url?: string;
- date_published?: string;
-}
-
-interface JsonFeedResponse {
- items?: JsonFeedItem[];
-}
-
-interface PreviewItem {
- title: string;
- excerpt: string;
- publishedLabel: string;
- url?: string;
-}
-
interface ResultDisplayProps {
- result: FeedRecord;
+ result: CreatedFeedResult;
onCreateAnother: () => void;
}
export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
const [copyNotice, setCopyNotice] = useState('');
- const [previewItems, setPreviewItems] = useState
([]);
- const [previewError, setPreviewError] = useState('');
const copyResetRef = useRef(undefined);
+ const { feed, preview } = result;
- const fullUrl = result.public_url.startsWith('http')
- ? result.public_url
- : `${window.location.origin}${result.public_url}`;
- const jsonFeedUrl = result.json_public_url.startsWith('http')
- ? result.json_public_url
- : `${window.location.origin}${result.json_public_url}`;
+ const fullUrl = feed.public_url.startsWith('http')
+ ? feed.public_url
+ : `${window.location.origin}${feed.public_url}`;
+ const jsonFeedUrl = feed.json_public_url.startsWith('http')
+ ? feed.json_public_url
+ : `${window.location.origin}${feed.json_public_url}`;
useEffect(() => {
return () => {
@@ -46,41 +25,6 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
};
}, []);
- useEffect(() => {
- let isCancelled = false;
-
- const loadPreview = async () => {
- try {
- const response = await window.fetch(fullUrl, {
- headers: { Accept: 'application/feed+json' },
- });
- if (!response.ok) throw new Error('Preview request failed');
- const payload = (await response.json()) as JsonFeedResponse;
- const items =
- payload.items
- ?.map((item) => normalizePreviewItem(item))
- .filter((item): item is PreviewItem => Boolean(item))
- .slice(0, 5) || [];
-
- if (!isCancelled) {
- setPreviewItems(items);
- setPreviewError('');
- }
- } catch {
- if (!isCancelled) {
- setPreviewItems([]);
- setPreviewError('Preview unavailable right now.');
- }
- }
- };
-
- void loadPreview();
-
- return () => {
- isCancelled = true;
- };
- }, [fullUrl]);
-
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
@@ -100,7 +44,7 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
>
Feed created
Your feed is ready
- {result.name}
+ {feed.name}
Subscribe to this URL in your RSS reader.
@@ -128,14 +72,14 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
- {previewItems.length > 0 && (
+ {preview.items.length > 0 && (
- {previewItems.map((item) => (
+ {preview.items.map((item) => (
-
{item.title}
@@ -155,13 +99,13 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
)}
- {previewError && (
+ {preview.error && (
- {previewError}
+ {preview.error}
)}
@@ -173,72 +117,3 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
);
}
-
-function normalizePreviewText(value?: string): string | null {
- if (!value) return null;
-
- const normalized = decodeHtmlEntities(value)
- .replace(/<[^>]*>/g, ' ')
- .replace(/\s+/g, ' ')
- .replace(/\s+([.,!?;:])/g, '$1')
- .replace(/^\d+\.\s+/, '')
- .replace(/\s+\([^)]*\)\s*$/, '')
- .trim();
-
- return normalized || null;
-}
-
-function normalizePreviewItem(item: JsonFeedItem): PreviewItem | null {
- const excerptSource = item.content_text || item.content_html;
- const title = normalizePreviewText(item.title) || normalizePreviewText(excerptSource) || 'Untitled item';
- const excerpt = normalizePreviewExcerpt(excerptSource, title);
-
- return {
- title,
- excerpt,
- publishedLabel: formatPublishedDate(item.date_published),
- url: normalizePreviewUrl(item.url || item.external_url),
- };
-}
-
-function normalizePreviewExcerpt(value: string | undefined, title: string): string {
- const excerpt = normalizePreviewText(value);
- if (!excerpt || excerpt === title) return '';
- return truncateText(excerpt, 220);
-}
-
-function normalizePreviewUrl(value?: string): string | undefined {
- if (!value) return undefined;
- if (!/^https?:\/\//i.test(value)) return undefined;
- return value;
-}
-
-function formatPublishedDate(value?: string): string {
- if (!value) return '';
-
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) return '';
-
- return new Intl.DateTimeFormat(undefined, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- }).format(parsed);
-}
-
-function truncateText(value: string, maxLength: number): string {
- if (value.length <= maxLength) return value;
-
- const clipped = value.slice(0, maxLength).trimEnd();
- const safeBoundary = clipped.lastIndexOf(' ');
-
- return `${(safeBoundary > maxLength * 0.6 ? clipped.slice(0, safeBoundary) : clipped).trimEnd()}...`;
-}
-
-function decodeHtmlEntities(value: string): string {
- if (typeof document === 'undefined') return value;
-
- const textarea = document.createElement('textarea');
- textarea.innerHTML = value;
- return textarea.value;
-}
diff --git a/frontend/src/hooks/useApiMetadata.ts b/frontend/src/hooks/useApiMetadata.ts
index 6feccba3..482b21a5 100644
--- a/frontend/src/hooks/useApiMetadata.ts
+++ b/frontend/src/hooks/useApiMetadata.ts
@@ -20,14 +20,13 @@ export function useApiMetadata() {
});
useEffect(() => {
- const controller = new AbortController();
+ let cancelled = false;
const load = async () => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const response = await fetch('/api/v1', {
- signal: controller.signal,
headers: { Accept: 'application/json' },
});
const payload = await parseMetadataPayload(response);
@@ -36,6 +35,7 @@ export function useApiMetadata() {
if (!response.ok || !payload.success || !metadata?.instance) {
throw new Error('Invalid response format from API metadata');
}
+ if (cancelled) return;
setState({
metadata,
@@ -43,7 +43,7 @@ export function useApiMetadata() {
error: null,
});
} catch (error) {
- if (controller.signal.aborted) return;
+ if (cancelled) return;
setState({
metadata: null,
@@ -54,7 +54,9 @@ export function useApiMetadata() {
};
load();
- return () => controller.abort();
+ return () => {
+ cancelled = true;
+ };
}, []);
return state;
diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts
index 134c2220..aeab5a3e 100644
--- a/frontend/src/hooks/useFeedConversion.ts
+++ b/frontend/src/hooks/useFeedConversion.ts
@@ -1,11 +1,24 @@
import { useState } from 'preact/hooks';
import { createFeed } from '../api/generated';
import { apiClient } from '../api/client';
-import type { FeedRecord } from '../api/contracts';
+import type { CreatedFeedResult, FeedPreviewItem, FeedRecord } from '../api/contracts';
+
+interface JsonFeedItem {
+ title?: string;
+ content_text?: string;
+ content_html?: string;
+ url?: string;
+ external_url?: string;
+ date_published?: string;
+}
+
+interface JsonFeedResponse {
+ items?: JsonFeedItem[];
+}
interface ConversionState {
isConverting: boolean;
- result: FeedRecord | null;
+ result: CreatedFeedResult | null;
error: string | null;
}
@@ -45,7 +58,13 @@ export function useFeedConversion() {
throw new Error('Invalid response format');
}
- const result = response.data.data.feed;
+ const feed = response.data.data.feed;
+ const preview = await loadPreview(feed).catch((error: unknown) => ({
+ items: [],
+ error: toPreviewErrorMessage(error),
+ }));
+ const result = { feed, preview };
+
setState((prev) => ({ ...prev, isConverting: false, result, error: null }));
return result;
} catch (error) {
@@ -84,6 +103,26 @@ export function useFeedConversion() {
};
}
+async function loadPreview(feed: FeedRecord): Promise {
+ const response = await window.fetch(feed.json_public_url, {
+ headers: { Accept: 'application/feed+json' },
+ });
+
+ if (!response.ok) throw new Error('Preview unavailable right now.');
+
+ const payload = (await response.json()) as JsonFeedResponse;
+ const items =
+ payload.items
+ ?.map((item) => normalizePreviewItem(item))
+ .filter((item): item is FeedPreviewItem => Boolean(item))
+ .slice(0, 5) || [];
+
+ return {
+ items,
+ error: items.length > 0 ? null : 'Preview unavailable right now.',
+ };
+}
+
const toErrorMessage = (error: unknown): string => {
if (error instanceof SyntaxError) return 'Invalid response format from feed creation API';
if (error instanceof Error) return error.message;
@@ -93,6 +132,12 @@ const toErrorMessage = (error: unknown): string => {
return message ?? 'An unexpected error occurred';
};
+const toPreviewErrorMessage = (error: unknown): string => {
+ if (error instanceof SyntaxError) return 'Preview unavailable right now.';
+ if (error instanceof Error && error.message.trim()) return error.message;
+ return 'Preview unavailable right now.';
+};
+
const extractMessage = (error: unknown): string | null => {
if (!error || typeof error !== 'object') return null;
@@ -102,3 +147,72 @@ const extractMessage = (error: unknown): string | null => {
return typeof candidate === 'string' && candidate.trim() ? candidate : null;
};
+
+function normalizePreviewText(value?: string): string | null {
+ if (!value) return null;
+
+ const normalized = decodeHtmlEntities(value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .replace(/\s+([.,!?;:])/g, '$1')
+ .replace(/^\d+\.\s+/, '')
+ .replace(/\s+\([^)]*\)\s*$/, '')
+ .trim();
+
+ return normalized || null;
+}
+
+function normalizePreviewItem(item: JsonFeedItem): FeedPreviewItem | null {
+ const excerptSource = item.content_text || item.content_html;
+ const title = normalizePreviewText(item.title) || normalizePreviewText(excerptSource) || 'Untitled item';
+ const excerpt = normalizePreviewExcerpt(excerptSource, title);
+
+ return {
+ title,
+ excerpt,
+ publishedLabel: formatPublishedDate(item.date_published),
+ url: normalizePreviewUrl(item.url || item.external_url),
+ };
+}
+
+function normalizePreviewExcerpt(value: string | undefined, title: string): string {
+ const excerpt = normalizePreviewText(value);
+ if (!excerpt || excerpt === title) return '';
+ return truncateText(excerpt, 220);
+}
+
+function normalizePreviewUrl(value?: string): string | undefined {
+ if (!value) return undefined;
+ if (!/^https?:\/\//i.test(value)) return undefined;
+ return value;
+}
+
+function formatPublishedDate(value?: string): string {
+ if (!value) return '';
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return '';
+
+ return new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(parsed);
+}
+
+function truncateText(value: string, maxLength: number): string {
+ if (value.length <= maxLength) return value;
+
+ const clipped = value.slice(0, maxLength).trimEnd();
+ const safeBoundary = clipped.lastIndexOf(' ');
+
+ return `${(safeBoundary > maxLength * 0.6 ? clipped.slice(0, safeBoundary) : clipped).trimEnd()}...`;
+}
+
+function decodeHtmlEntities(value: string): string {
+ if (typeof document === 'undefined') return value;
+
+ const textarea = document.createElement('textarea');
+ textarea.innerHTML = value;
+ return textarea.value;
+}