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 && ( - {previewItems.length > 0 && ( + {preview.items.length > 0 && (

Preview

Latest items from this feed

    - {previewItems.map((item) => ( + {preview.items.map((item) => (
  • {item.title}

    @@ -155,13 +99,13 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) {
)} - {previewError && ( + {preview.error && (

Preview

Latest items from this feed

-

{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; +}