diff --git a/web/index.html b/web/index.html index 6aab230b2..c8a9bb7c6 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,21 @@ ChicoryTV +
diff --git a/web/scripts/generate-openapi-types.mjs b/web/scripts/generate-openapi-types.mjs index cb8d7478a..7bc7ad41c 100644 --- a/web/scripts/generate-openapi-types.mjs +++ b/web/scripts/generate-openapi-types.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const webRoot = resolve(scriptDir, '..'); @@ -8,9 +9,6 @@ const repoRoot = resolve(webRoot, '..'); const inputPath = resolve(repoRoot, 'ErsatzTV/wwwroot/openapi/v1.json'); const outputPath = resolve(webRoot, 'src/api/generated/v1.d.ts'); -const document = JSON.parse(await readFile(inputPath, 'utf8')); -const schemas = document.components?.schemas ?? {}; - function schemaNameFromRef(ref) { return ref.replace('#/components/schemas/', ''); } @@ -92,52 +90,34 @@ function objectTypeFromSchema(schema) { return lines.join('\n'); } -function operationType(operation) { - if (!operation) { - return 'never'; +export function generateTypes(document) { + const schemas = document.components?.schemas ?? {}; + const lines = [ + '// This file is generated by web/scripts/generate-openapi-types.mjs.', + '// Do not edit by hand.', + '', + 'export interface components {', + ' schemas: {' + ]; + + for (const [name, schema] of Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`${formatDescription(schema.description, ' ')} ${JSON.stringify(name)}: ${typeFromSchema(schema)};`); } - const responses = operation.responses ?? {}; - const successResponse = responses['200'] ?? responses['201'] ?? responses['204']; - const jsonContent = successResponse?.content?.['application/json'] - ?? successResponse?.content?.['text/json'] - ?? successResponse?.content?.['text/plain']; + lines.push(' };'); + lines.push('}'); + lines.push(''); - if (!jsonContent?.schema) { - return 'void'; - } - - return typeFromSchema(jsonContent.schema); + return `${lines.join('\n')}\n`; } -const lines = [ - '// This file is generated by web/scripts/generate-openapi-types.mjs.', - '// Do not edit by hand.', - '', - 'export interface components {', - ' schemas: {' -]; +async function main() { + const document = JSON.parse(await readFile(inputPath, 'utf8')); -for (const [name, schema] of Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`${formatDescription(schema.description, ' ')} ${JSON.stringify(name)}: ${typeFromSchema(schema)};`); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, generateTypes(document)); } -lines.push(' };'); -lines.push('}'); -lines.push(''); -lines.push('export interface operations {'); - -for (const [path, methods] of Object.entries(document.paths ?? {}).sort(([a], [b]) => a.localeCompare(b))) { - for (const [method, operation] of Object.entries(methods).sort(([a], [b]) => a.localeCompare(b))) { - const name = operation.operationId ?? `${method.toUpperCase()} ${path}`; - lines.push(` ${JSON.stringify(name)}: {`); - lines.push(` response: ${operationType(operation)};`); - lines.push(' };'); - } +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); } - -lines.push('}'); -lines.push(''); - -await mkdir(dirname(outputPath), { recursive: true }); -await writeFile(outputPath, `${lines.join('\n')}\n`); diff --git a/web/scripts/generate-openapi-types.test.mjs b/web/scripts/generate-openapi-types.test.mjs new file mode 100644 index 000000000..2f9fd25e8 --- /dev/null +++ b/web/scripts/generate-openapi-types.test.mjs @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { generateTypes } from './generate-openapi-types.mjs'; + +describe('generate-openapi-types', () => { + it('emits only schemas and ignores non-operation path item keys', () => { + const output = generateTypes({ + components: { + schemas: { + ProblemDetails: { + properties: { + detail: { type: 'string' } + }, + type: 'object' + } + } + }, + paths: { + '/api/items/{id}': { + parameters: [ + { + in: 'path', + name: 'id', + schema: { type: 'integer' } + } + ], + get: { + operationId: 'GetItem', + responses: { + 200: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ProblemDetails' } + } + } + } + } + } + } + } + }); + + expect(output).toContain('export interface components'); + expect(output).toContain('"ProblemDetails"'); + expect(output).not.toContain('export interface operations'); + expect(output).not.toContain('"parameters"'); + }); +}); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 76541c121..d284d47ad 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; -import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Tooltip } from './components'; +import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components'; import { applyDesignSystemTheme, designSystemStylesheet, @@ -282,6 +282,18 @@ describe('ChicoryTV SPA scaffold', () => { fireEvent.mouseEnter(screen.getByText('Reset')); expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout'); }); + + it('uses distinct semantic icons for warning and error toasts', () => { + const { container } = render( + <> + + + + ); + + expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument(); + expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument(); + }); }); function jsonResponse(body: unknown, status = 200): Response { diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts index b3532bb28..df1b23d91 100644 --- a/web/src/api/auth.test.ts +++ b/web/src/api/auth.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { clearStoredApiKey, getStoredApiKey, setStoredApiKey } from './auth'; describe('API key storage', () => { @@ -27,4 +27,16 @@ describe('API key storage', () => { expect(getStoredApiKey()).toBeNull(); }); + + it('ignores unavailable local storage when reading the API key', () => { + const localStorageGetter = vi + .spyOn(window, 'localStorage', 'get') + .mockImplementation(() => { + throw new Error('localStorage unavailable'); + }); + + expect(getStoredApiKey()).toBeNull(); + + localStorageGetter.mockRestore(); + }); }); diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 95f1fed7b..9cb28502e 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,7 +1,19 @@ const apiKeyStorageKey = 'ctv-api-key'; +function getStorage(): Storage | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return window.localStorage; + } catch { + return undefined; + } +} + export function getStoredApiKey(): string | null { - const value = window.localStorage.getItem(apiKeyStorageKey); + const value = getStorage()?.getItem(apiKeyStorageKey); return value && value.trim().length > 0 ? value : null; } @@ -13,9 +25,9 @@ export function setStoredApiKey(apiKey: string): void { return; } - window.localStorage.setItem(apiKeyStorageKey, trimmed); + getStorage()?.setItem(apiKeyStorageKey, trimmed); } export function clearStoredApiKey(): void { - window.localStorage.removeItem(apiKeyStorageKey); + getStorage()?.removeItem(apiKeyStorageKey); } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 39bc31a74..f0c6d1dd9 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -56,4 +56,42 @@ describe('API request client', () => { status: 422 } satisfies Partial); }); + + it('returns undefined for successful responses without a JSON body', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + await expect(request('/api/channels', { method: 'POST' })).resolves.toBeUndefined(); + }); + + it('reads problem details from application/problem+json responses', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ status: 401, title: 'Unauthorized', detail: 'A valid API key is required for write requests.' }), { + headers: { 'Content-Type': 'application/problem+json' }, + status: 401 + }) + ); + + await expect(request('/api/channels', { method: 'POST' })).rejects.toMatchObject({ + detail: 'A valid API key is required for write requests.', + message: 'Unauthorized', + status: 401 + } satisfies Partial); + }); + + it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + + await request('/api/channels', { + body: { name: 'News' }, + headers: { 'content-type': 'application/merge-patch+json' }, + method: 'PATCH' + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/channels', + expect.objectContaining({ + headers: expect.not.objectContaining({ 'Content-Type': 'application/json' }) + }) + ); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1a0069fbd..b9e3e94b8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -29,10 +29,10 @@ export async function request( options: ApiRequestOptions = {} ): Promise { const method = (options.method ?? 'GET').toUpperCase(); - const headers: Record = { + const headers = normalizeHeaders({ Accept: 'application/json', ...headersToRecord(options.headers) - }; + }); const body = serializeBody(options.body, headers); const apiKey = getStoredApiKey(); @@ -56,7 +56,21 @@ export async function request( return undefined as TResponse; } - return await response.json() as TResponse; + return await readJsonResponse(response) as TResponse; +} + +function normalizeHeaders(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [canonicalHeaderName(key), value]) + ); +} + +function canonicalHeaderName(header: string): string { + return header + .toLowerCase() + .split('-') + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join('-'); } function headersToRecord(headers?: HeadersInit): Record { @@ -95,9 +109,32 @@ function serializeBody(body: RequestBody | undefined, headers: Record { const contentType = response.headers.get('Content-Type') ?? ''; - if (!contentType.includes('application/json')) { + if (!isJsonContentType(contentType)) { return undefined; } return await response.json() as ProblemDetails; } + +async function readJsonResponse(response: Response): Promise { + const contentLength = response.headers.get('Content-Length'); + const contentType = response.headers.get('Content-Type') ?? ''; + + if (contentLength === '0' || !isJsonContentType(contentType)) { + return undefined; + } + + try { + return await response.json(); + } catch (error) { + if (error instanceof SyntaxError) { + return undefined; + } + + throw error; + } +} + +function isJsonContentType(contentType: string): boolean { + return contentType.includes('application/json') || contentType.includes('problem+json'); +} diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index b9dab2ac7..135ff5c75 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -515,138 +515,3 @@ export interface components { }; } -export interface operations { - "GET /api/channels": { - response: Array; - }; - "POST /api/channels": { - response: components["schemas"]["ChannelViewModel"]; - }; - "POST /api/channels/{channelNumber}/playout/reset": { - response: void; - }; - "DELETE /api/channels/{id}": { - response: void; - }; - "GetChannelById": { - response: components["schemas"]["ChannelViewModel"]; - }; - "PUT /api/channels/{id}": { - response: components["schemas"]["ChannelViewModel"]; - }; - "GET /api/collections": { - response: Array; - }; - "POST /api/collections": { - response: components["schemas"]["MediaCollectionViewModel"]; - }; - "DELETE /api/collections/{id}": { - response: void; - }; - "GetCollectionById": { - response: components["schemas"]["MediaCollectionViewModel"]; - }; - "PUT /api/collections/{id}": { - response: components["schemas"]["MediaCollectionViewModel"]; - }; - "POST /api/collections/{id}/items": { - response: void; - }; - "DELETE /api/collections/{id}/items/{mediaItemId}": { - response: void; - }; - "GetFFmpegProfiles": { - response: Array; - }; - "CreateFFmpegProfile": { - response: components["schemas"]["FFmpegFullProfileResponseModel"]; - }; - "DeleteFFmpegProfile": { - response: void; - }; - "GetFFmpegProfileById": { - response: components["schemas"]["FFmpegFullProfileResponseModel"]; - }; - "UpdateFFmpegProfile": { - response: components["schemas"]["FFmpegFullProfileResponseModel"]; - }; - "GetResolutionByName": { - response: components["schemas"]["ResolutionViewModel"]; - }; - "POST /api/libraries/{id}/scan": { - response: void; - }; - "POST /api/libraries/{id}/scan-show": { - response: void; - }; - "POST /api/maintenance/clean_artwork": { - response: void; - }; - "POST /api/maintenance/empty_trash": { - response: void; - }; - "GET /api/maintenance/gc": { - response: void; - }; - "POST /api/playouts": { - response: components["schemas"]["PlayoutResponseModel"]; - }; - "DELETE /api/playouts/{id}": { - response: void; - }; - "GetPlayoutById": { - response: components["schemas"]["PlayoutResponseModel"]; - }; - "GET /api/schedules": { - response: Array; - }; - "POST /api/schedules": { - response: components["schemas"]["ProgramScheduleViewModel"]; - }; - "DELETE /api/schedules/{id}": { - response: void; - }; - "GetScheduleById": { - response: components["schemas"]["ProgramScheduleViewModel"]; - }; - "PUT /api/schedules/{id}": { - response: components["schemas"]["ProgramScheduleViewModel"]; - }; - "GET /api/schedules/{id}/items": { - response: Array; - }; - "POST /api/schedules/{id}/items": { - response: components["schemas"]["ProgramScheduleItemViewModel"]; - }; - "PUT /api/schedules/{id}/items": { - response: Array; - }; - "DELETE /api/schedules/{id}/items/{itemId}": { - response: void; - }; - "DELETE /api/session/{channelNumber}": { - response: void; - }; - "GET /api/sessions": { - response: Array; - }; - "GET /api/smart-collections": { - response: Array; - }; - "POST /api/smart-collections": { - response: components["schemas"]["SmartCollectionViewModel"]; - }; - "DELETE /api/smart-collections/{id}": { - response: void; - }; - "GetSmartCollectionById": { - response: components["schemas"]["SmartCollectionViewModel"]; - }; - "PUT /api/smart-collections/{id}": { - response: components["schemas"]["SmartCollectionViewModel"]; - }; - "GetVersion": { - response: components["schemas"]["CombinedVersion"]; - }; -} - diff --git a/web/src/components/feedback.tsx b/web/src/components/feedback.tsx index 7fcf98015..cf67ca7bb 100644 --- a/web/src/components/feedback.tsx +++ b/web/src/components/feedback.tsx @@ -1,5 +1,5 @@ import { useState, type CSSProperties, type ReactNode } from 'react'; -import { Check, Info, X } from 'lucide-react'; +import { Check, CircleX, Info, TriangleAlert, X } from 'lucide-react'; function classNames(...values: Array): string { return values.filter(Boolean).join(' '); @@ -47,7 +47,12 @@ const toastStyle: Record, { color: string; bg: s export function Toast({ tone = 'info', title, message, onClose, style }: ToastProps) { const toneStyle = toastStyle[tone]; - const Icon = tone === 'ok' ? Check : Info; + const Icon = { + error: CircleX, + info: Info, + ok: Check, + warn: TriangleAlert + }[tone]; return (