From 556d5cc2c0b15160f20249574e59293f52f6fe4b Mon Sep 17 00:00:00 2001 From: santosral Date: Mon, 19 Jan 2026 16:36:35 +0800 Subject: [PATCH 01/11] feat: add admin route folder --- src/routes/admin/.keep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/routes/admin/.keep diff --git a/src/routes/admin/.keep b/src/routes/admin/.keep new file mode 100644 index 00000000..e69de29b From 9e26e4a09cebe991eaea5c03a4d97b876e92c4e9 Mon Sep 17 00:00:00 2001 From: santosral <74949176+santosral@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:05:10 +0800 Subject: [PATCH 02/11] feat(admin): group main app routes (#478) --- src/routes/{ => (main)}/(protected)/(core)/+layout.server.ts | 0 src/routes/{ => (main)}/(protected)/(core)/+layout.svelte | 0 src/routes/{ => (main)}/(protected)/(core)/home/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/(core)/home/+page.svelte | 0 .../{ => (main)}/(protected)/(core)/learning/+page.server.ts | 0 .../{ => (main)}/(protected)/(core)/learning/+page.svelte | 0 .../(protected)/(core)/learning/collection/[id]/+page.server.ts | 0 .../(core)/learning/collection/[id]/+page@(protected).svelte | 0 src/routes/{ => (main)}/(protected)/+layout.server.ts | 0 src/routes/{ => (main)}/(protected)/+layout.svelte | 0 src/routes/{ => (main)}/(protected)/bites/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/bites/+page.svelte | 0 .../{ => (main)}/(protected)/collection/[id]/+page.server.ts | 0 .../{ => (main)}/(protected)/collection/[id]/+page.svelte | 0 .../{ => (main)}/(protected)/podcasts/[...key]/+server.ts | 0 src/routes/{ => (main)}/(protected)/profile/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/profile/+page.svelte | 0 src/routes/{ => (main)}/(protected)/todos/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/todos/+page.svelte | 0 src/routes/{ => (main)}/(protected)/unit/[id]/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/unit/[id]/+page.svelte | 0 .../{ => (main)}/(protected)/unit/[id]/quiz/+page.server.ts | 0 src/routes/{ => (main)}/(protected)/unit/[id]/quiz/+page.svelte | 0 src/routes/{ => (main)}/+error.svelte | 0 src/routes/{ => (main)}/+layout.svelte | 2 +- src/routes/{ => (main)}/api/health/+server.ts | 0 src/routes/{ => (main)}/api/learningjourney/+server.ts | 0 src/routes/{ => (main)}/api/messages/+server.ts | 0 src/routes/{ => (main)}/api/types.ts | 0 src/routes/{ => (main)}/auth/google/+server.ts | 0 src/routes/{ => (main)}/auth/google/callback/+server.ts | 0 src/routes/{ => (main)}/login/+page.svelte | 0 src/routes/{ => (main)}/logout/+server.ts | 0 src/routes/{ => (main)}/privacy/+page.svelte | 0 src/routes/{ => (main)}/terms/+page.svelte | 0 35 files changed, 1 insertion(+), 1 deletion(-) rename src/routes/{ => (main)}/(protected)/(core)/+layout.server.ts (100%) rename src/routes/{ => (main)}/(protected)/(core)/+layout.svelte (100%) rename src/routes/{ => (main)}/(protected)/(core)/home/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/(core)/home/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/(core)/learning/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/(core)/learning/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/(core)/learning/collection/[id]/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/(core)/learning/collection/[id]/+page@(protected).svelte (100%) rename src/routes/{ => (main)}/(protected)/+layout.server.ts (100%) rename src/routes/{ => (main)}/(protected)/+layout.svelte (100%) rename src/routes/{ => (main)}/(protected)/bites/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/bites/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/collection/[id]/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/collection/[id]/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/podcasts/[...key]/+server.ts (100%) rename src/routes/{ => (main)}/(protected)/profile/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/profile/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/todos/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/todos/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/unit/[id]/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/unit/[id]/+page.svelte (100%) rename src/routes/{ => (main)}/(protected)/unit/[id]/quiz/+page.server.ts (100%) rename src/routes/{ => (main)}/(protected)/unit/[id]/quiz/+page.svelte (100%) rename src/routes/{ => (main)}/+error.svelte (100%) rename src/routes/{ => (main)}/+layout.svelte (97%) rename src/routes/{ => (main)}/api/health/+server.ts (100%) rename src/routes/{ => (main)}/api/learningjourney/+server.ts (100%) rename src/routes/{ => (main)}/api/messages/+server.ts (100%) rename src/routes/{ => (main)}/api/types.ts (100%) rename src/routes/{ => (main)}/auth/google/+server.ts (100%) rename src/routes/{ => (main)}/auth/google/callback/+server.ts (100%) rename src/routes/{ => (main)}/login/+page.svelte (100%) rename src/routes/{ => (main)}/logout/+server.ts (100%) rename src/routes/{ => (main)}/privacy/+page.svelte (100%) rename src/routes/{ => (main)}/terms/+page.svelte (100%) diff --git a/src/routes/(protected)/(core)/+layout.server.ts b/src/routes/(main)/(protected)/(core)/+layout.server.ts similarity index 100% rename from src/routes/(protected)/(core)/+layout.server.ts rename to src/routes/(main)/(protected)/(core)/+layout.server.ts diff --git a/src/routes/(protected)/(core)/+layout.svelte b/src/routes/(main)/(protected)/(core)/+layout.svelte similarity index 100% rename from src/routes/(protected)/(core)/+layout.svelte rename to src/routes/(main)/(protected)/(core)/+layout.svelte diff --git a/src/routes/(protected)/(core)/home/+page.server.ts b/src/routes/(main)/(protected)/(core)/home/+page.server.ts similarity index 100% rename from src/routes/(protected)/(core)/home/+page.server.ts rename to src/routes/(main)/(protected)/(core)/home/+page.server.ts diff --git a/src/routes/(protected)/(core)/home/+page.svelte b/src/routes/(main)/(protected)/(core)/home/+page.svelte similarity index 100% rename from src/routes/(protected)/(core)/home/+page.svelte rename to src/routes/(main)/(protected)/(core)/home/+page.svelte diff --git a/src/routes/(protected)/(core)/learning/+page.server.ts b/src/routes/(main)/(protected)/(core)/learning/+page.server.ts similarity index 100% rename from src/routes/(protected)/(core)/learning/+page.server.ts rename to src/routes/(main)/(protected)/(core)/learning/+page.server.ts diff --git a/src/routes/(protected)/(core)/learning/+page.svelte b/src/routes/(main)/(protected)/(core)/learning/+page.svelte similarity index 100% rename from src/routes/(protected)/(core)/learning/+page.svelte rename to src/routes/(main)/(protected)/(core)/learning/+page.svelte diff --git a/src/routes/(protected)/(core)/learning/collection/[id]/+page.server.ts b/src/routes/(main)/(protected)/(core)/learning/collection/[id]/+page.server.ts similarity index 100% rename from src/routes/(protected)/(core)/learning/collection/[id]/+page.server.ts rename to src/routes/(main)/(protected)/(core)/learning/collection/[id]/+page.server.ts diff --git a/src/routes/(protected)/(core)/learning/collection/[id]/+page@(protected).svelte b/src/routes/(main)/(protected)/(core)/learning/collection/[id]/+page@(protected).svelte similarity index 100% rename from src/routes/(protected)/(core)/learning/collection/[id]/+page@(protected).svelte rename to src/routes/(main)/(protected)/(core)/learning/collection/[id]/+page@(protected).svelte diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(main)/(protected)/+layout.server.ts similarity index 100% rename from src/routes/(protected)/+layout.server.ts rename to src/routes/(main)/(protected)/+layout.server.ts diff --git a/src/routes/(protected)/+layout.svelte b/src/routes/(main)/(protected)/+layout.svelte similarity index 100% rename from src/routes/(protected)/+layout.svelte rename to src/routes/(main)/(protected)/+layout.svelte diff --git a/src/routes/(protected)/bites/+page.server.ts b/src/routes/(main)/(protected)/bites/+page.server.ts similarity index 100% rename from src/routes/(protected)/bites/+page.server.ts rename to src/routes/(main)/(protected)/bites/+page.server.ts diff --git a/src/routes/(protected)/bites/+page.svelte b/src/routes/(main)/(protected)/bites/+page.svelte similarity index 100% rename from src/routes/(protected)/bites/+page.svelte rename to src/routes/(main)/(protected)/bites/+page.svelte diff --git a/src/routes/(protected)/collection/[id]/+page.server.ts b/src/routes/(main)/(protected)/collection/[id]/+page.server.ts similarity index 100% rename from src/routes/(protected)/collection/[id]/+page.server.ts rename to src/routes/(main)/(protected)/collection/[id]/+page.server.ts diff --git a/src/routes/(protected)/collection/[id]/+page.svelte b/src/routes/(main)/(protected)/collection/[id]/+page.svelte similarity index 100% rename from src/routes/(protected)/collection/[id]/+page.svelte rename to src/routes/(main)/(protected)/collection/[id]/+page.svelte diff --git a/src/routes/(protected)/podcasts/[...key]/+server.ts b/src/routes/(main)/(protected)/podcasts/[...key]/+server.ts similarity index 100% rename from src/routes/(protected)/podcasts/[...key]/+server.ts rename to src/routes/(main)/(protected)/podcasts/[...key]/+server.ts diff --git a/src/routes/(protected)/profile/+page.server.ts b/src/routes/(main)/(protected)/profile/+page.server.ts similarity index 100% rename from src/routes/(protected)/profile/+page.server.ts rename to src/routes/(main)/(protected)/profile/+page.server.ts diff --git a/src/routes/(protected)/profile/+page.svelte b/src/routes/(main)/(protected)/profile/+page.svelte similarity index 100% rename from src/routes/(protected)/profile/+page.svelte rename to src/routes/(main)/(protected)/profile/+page.svelte diff --git a/src/routes/(protected)/todos/+page.server.ts b/src/routes/(main)/(protected)/todos/+page.server.ts similarity index 100% rename from src/routes/(protected)/todos/+page.server.ts rename to src/routes/(main)/(protected)/todos/+page.server.ts diff --git a/src/routes/(protected)/todos/+page.svelte b/src/routes/(main)/(protected)/todos/+page.svelte similarity index 100% rename from src/routes/(protected)/todos/+page.svelte rename to src/routes/(main)/(protected)/todos/+page.svelte diff --git a/src/routes/(protected)/unit/[id]/+page.server.ts b/src/routes/(main)/(protected)/unit/[id]/+page.server.ts similarity index 100% rename from src/routes/(protected)/unit/[id]/+page.server.ts rename to src/routes/(main)/(protected)/unit/[id]/+page.server.ts diff --git a/src/routes/(protected)/unit/[id]/+page.svelte b/src/routes/(main)/(protected)/unit/[id]/+page.svelte similarity index 100% rename from src/routes/(protected)/unit/[id]/+page.svelte rename to src/routes/(main)/(protected)/unit/[id]/+page.svelte diff --git a/src/routes/(protected)/unit/[id]/quiz/+page.server.ts b/src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts similarity index 100% rename from src/routes/(protected)/unit/[id]/quiz/+page.server.ts rename to src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts diff --git a/src/routes/(protected)/unit/[id]/quiz/+page.svelte b/src/routes/(main)/(protected)/unit/[id]/quiz/+page.svelte similarity index 100% rename from src/routes/(protected)/unit/[id]/quiz/+page.svelte rename to src/routes/(main)/(protected)/unit/[id]/quiz/+page.svelte diff --git a/src/routes/+error.svelte b/src/routes/(main)/+error.svelte similarity index 100% rename from src/routes/+error.svelte rename to src/routes/(main)/+error.svelte diff --git a/src/routes/+layout.svelte b/src/routes/(main)/+layout.svelte similarity index 97% rename from src/routes/+layout.svelte rename to src/routes/(main)/+layout.svelte index 70bb673b..4786cb9d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/(main)/+layout.svelte @@ -1,5 +1,5 @@ + +
+
+ {title} + +
+ + {#if items.length === 0} + {emptyMessage} + {:else} + {#each items as item, index (index)} +
+
+ {@render children(item, index, itemErrors?.[index])} +
+ + +
+ {/each} + {/if} + +
+ +
+
diff --git a/src/lib/components/AddableField/AddableField.test.ts b/src/lib/components/AddableField/AddableField.test.ts new file mode 100644 index 00000000..7efe0866 --- /dev/null +++ b/src/lib/components/AddableField/AddableField.test.ts @@ -0,0 +1,172 @@ +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { createRawSnippet } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import AddableField from './AddableField.svelte'; + +describe('AddableField', () => { + const children = createRawSnippet< + [{ id: number; name: string }, number, Record | undefined] + >((getItem, getIndex) => { + const item = getItem(); + const index = getIndex(); + return { + render: () => ``, + }; + }); + + it('renders the title', () => { + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'My Items', + items: [], + onadd: vi.fn(), + onremove: vi.fn(), + children, + }, + }); + + expect(screen.getByText('My Items')).toBeInTheDocument(); + }); + + it('renders add button', () => { + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: [], + onadd: vi.fn(), + onremove: vi.fn(), + addButtonText: 'Add New Source', + children, + }, + }); + + expect(screen.getByRole('button', { name: 'Add New Source' })).toBeInTheDocument(); + }); + + it('shows empty message when no items', () => { + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: [], + onadd: vi.fn(), + onremove: vi.fn(), + emptyMessage: 'No sources available', + children, + }, + }); + + expect(screen.getByText('No sources available')).toBeInTheDocument(); + }); + + it('renders items when provided', () => { + const mockItems = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]; + + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: mockItems, + onadd: vi.fn(), + onremove: vi.fn(), + children, + }, + }); + + mockItems.forEach((_, idx) => { + expect(screen.getByTestId(`item-${idx}`)).toBeInTheDocument(); + }); + }); + + it('calls onadd when add button is clicked', async () => { + const onadd = vi.fn(); + + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: [], + onadd, + onremove: vi.fn(), + children, + }, + }); + await userEvent.click(screen.getByRole('button', { name: 'Add Item' })); + + expect(onadd).toHaveBeenCalledTimes(1); + }); + + it('calls onremove with correct index when remove button is clicked', async () => { + const mockItems = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]; + const onremove = vi.fn(); + + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: mockItems, + onadd: vi.fn(), + onremove, + children, + }, + }); + + const removeButton = screen.getByRole('button', { name: 'Remove item 1' }); + await userEvent.click(removeButton); + + expect(onremove).toHaveBeenCalledWith(1); + }); + + it('renders error message', () => { + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: [], + onadd: vi.fn(), + onremove: vi.fn(), + error: 'At least one item is required', + children, + }, + }); + + expect(screen.getByText('At least one item is required')).toBeInTheDocument(); + }); + + it('passes item errors to children snippet', () => { + const mockItems = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]; + + const itemErrors: Record[] = [ + { name: 'Name is required' }, + { url: 'Invalid URL' }, + ]; + + const childrenWithErrors = createRawSnippet< + [{ id: number; name: string }, number, Record | undefined] + >((getItem, getIndex, getErrors) => { + const errors = getErrors(); + return { + render: () => (errors?.name ? `${errors.name}` : 'No errors'), + }; + }); + + render(AddableField<{ id: number; name: string }>, { + props: { + title: 'Items', + items: mockItems, + onadd: vi.fn(), + onremove: vi.fn(), + itemErrors, + children: childrenWithErrors, + }, + }); + + expect(screen.getByText('Name is required')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/AddableField/index.ts b/src/lib/components/AddableField/index.ts new file mode 100644 index 00000000..c7ba421a --- /dev/null +++ b/src/lib/components/AddableField/index.ts @@ -0,0 +1 @@ +export { default as AddableField } from './AddableField.svelte'; diff --git a/src/lib/components/Checkbox/Checkbox.svelte b/src/lib/components/Checkbox/Checkbox.svelte new file mode 100644 index 00000000..b2d2133c --- /dev/null +++ b/src/lib/components/Checkbox/Checkbox.svelte @@ -0,0 +1,40 @@ + + +
+ + + +
diff --git a/src/lib/components/Checkbox/Checkbox.test.ts b/src/lib/components/Checkbox/Checkbox.test.ts new file mode 100644 index 00000000..caa7c8f0 --- /dev/null +++ b/src/lib/components/Checkbox/Checkbox.test.ts @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import Checkbox from './Checkbox.svelte'; + +describe('Checkbox', () => { + it('renders with label', () => { + render(Checkbox, { props: { label: 'Accept terms' } }); + + expect(screen.getByLabelText('Accept terms')).toBeInTheDocument(); + }); + + it('renders default checked prop', () => { + render(Checkbox, { props: { label: 'Accept terms' } }); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it('reflects checked prop', () => { + render(Checkbox, { props: { label: 'Accept terms', checked: true } }); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('forwards additional input attributes', () => { + render(Checkbox, { + props: { + label: 'Accept terms', + disabled: true, + }, + }); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.disabled).toBe(true); + }); +}); diff --git a/src/lib/components/Checkbox/index.ts b/src/lib/components/Checkbox/index.ts new file mode 100644 index 00000000..212bb6ba --- /dev/null +++ b/src/lib/components/Checkbox/index.ts @@ -0,0 +1 @@ +export { default as Checkbox, type Props as CheckboxProps } from './Checkbox.svelte'; diff --git a/src/lib/components/DateInput/DateInput.svelte b/src/lib/components/DateInput/DateInput.svelte new file mode 100644 index 00000000..218013a4 --- /dev/null +++ b/src/lib/components/DateInput/DateInput.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/DateInput/DateInput.test.ts b/src/lib/components/DateInput/DateInput.test.ts new file mode 100644 index 00000000..9c7b5f9e --- /dev/null +++ b/src/lib/components/DateInput/DateInput.test.ts @@ -0,0 +1,33 @@ +import { render } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import DateInput from './DateInput.svelte'; + +describe('DateInput', () => { + it('renders passed value', () => { + const { container } = render(DateInput, { props: { value: '2026-01-12' } }); + + const input = container.querySelector('input[type="date"]') as HTMLInputElement; + expect(input.value).toBe('2026-01-12'); + }); + + it('renders default value', () => { + const { container } = render(DateInput); + + const input = container.querySelector('input[type="date"]') as HTMLInputElement; + expect(input.value).toBe(''); + }); + + it('forwards additional input attributes', () => { + const { container } = render(DateInput, { + props: { + id: 'start-date', + name: 'startDate', + min: '2026-01-01', + }, + }); + + const input = container.querySelector('input[type="date"]') as HTMLInputElement; + expect(input.min).toBe('2026-01-01'); + }); +}); diff --git a/src/lib/components/DateInput/index.ts b/src/lib/components/DateInput/index.ts new file mode 100644 index 00000000..6c333fdd --- /dev/null +++ b/src/lib/components/DateInput/index.ts @@ -0,0 +1 @@ +export { default as DateInput, type Props } from './DateInput.svelte'; diff --git a/src/lib/components/ErrorMessage/ErrorMessage.svelte b/src/lib/components/ErrorMessage/ErrorMessage.svelte new file mode 100644 index 00000000..005e4c91 --- /dev/null +++ b/src/lib/components/ErrorMessage/ErrorMessage.svelte @@ -0,0 +1,14 @@ + + +{#if message} + {message} +{/if} diff --git a/src/lib/components/ErrorMessage/ErrorMessage.test.ts b/src/lib/components/ErrorMessage/ErrorMessage.test.ts new file mode 100644 index 00000000..3c71bcb8 --- /dev/null +++ b/src/lib/components/ErrorMessage/ErrorMessage.test.ts @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import ErrorMessage from './ErrorMessage.svelte'; + +describe('ErrorMessage', () => { + it('renders error message', () => { + render(ErrorMessage, { props: { message: 'This is an error' } }); + + expect(screen.getByText('This is an error')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/ErrorMessage/index.ts b/src/lib/components/ErrorMessage/index.ts new file mode 100644 index 00000000..b4815bd3 --- /dev/null +++ b/src/lib/components/ErrorMessage/index.ts @@ -0,0 +1 @@ +export { default as ErrorMessage, type Props as ErrorMessageProps } from './ErrorMessage.svelte'; diff --git a/src/lib/components/FileInput/FileInput.svelte b/src/lib/components/FileInput/FileInput.svelte new file mode 100644 index 00000000..b1ffdd04 --- /dev/null +++ b/src/lib/components/FileInput/FileInput.svelte @@ -0,0 +1,146 @@ + + +
+ + + {#if !selectedFile} +
+ + Drag and drop file here, or click to browse. + + {accept ? `Accepts: ${accept}` : 'Any file type'} + +
+ {:else} +
+
+ + {selectedFile.name} + {formatFileSize(selectedFile.size)} +
+ + +
+ {/if} +
diff --git a/src/lib/components/FileInput/FileInput.test.ts b/src/lib/components/FileInput/FileInput.test.ts new file mode 100644 index 00000000..a3d0e006 --- /dev/null +++ b/src/lib/components/FileInput/FileInput.test.ts @@ -0,0 +1,133 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import FileInput from './FileInput.svelte'; + +describe('FileInput', () => { + it('renders the drop zone', () => { + render(FileInput); + + expect(screen.getByText('Drag and drop file here, or click to browse.')).toBeInTheDocument(); + }); + + it('shows default accepted file types when there is no accept prop', () => { + render(FileInput); + + expect(screen.getByText('Any file type')).toBeInTheDocument(); + }); + + it('shows accepted file types', () => { + render(FileInput, { props: { accept: 'audio/*' } }); + + expect(screen.getByText('Accepts: audio/*')).toBeInTheDocument(); + }); + + it('displays file name and size when selected', async () => { + const user = userEvent.setup(); + render(FileInput); + + const content = 'x'.repeat(1024 * 1024); + const file = new File([content], 'test-file.mp3', { type: 'audio/mpeg' }); + const input = screen + .getByRole('button') + .querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(screen.getByText('test-file.mp3')).toBeInTheDocument(); + expect(screen.getByText('1.0 MB')).toBeInTheDocument(); + }); + + it('formats file size to MB', async () => { + const user = userEvent.setup(); + render(FileInput); + + const file = new File(['x'.repeat(1024 * 1024)], 'test.txt', { type: 'text/plain' }); + const input = screen + .getByRole('button') + .querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(screen.getByText('1.0 MB')).toBeInTheDocument(); + }); + + it('shows the remove button when file is selected', async () => { + const user = userEvent.setup(); + render(FileInput); + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + const input = screen + .getByRole('button') + .querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(screen.getByRole('button', { name: 'Remove file' })).toBeInTheDocument(); + }); + + it('removes the file when remove button is clicked', async () => { + const user = userEvent.setup(); + render(FileInput); + + await user.upload( + screen.getByRole('button').querySelector('input[type="file"]') as HTMLInputElement, + new File(['test'], 'test.txt', { type: 'text/plain' }), + ); + expect(screen.getByText('test.txt')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Remove file' })); + + expect(screen.getByText('Drag and drop file here, or click to browse.')).toBeInTheDocument(); + }); + + it('opens file dialog when clicking the upload area', async () => { + const user = userEvent.setup(); + render(FileInput); + const uploadArea = screen.getByRole('button'); + const clickSpy = vi.spyOn( + uploadArea.querySelector('input[type="file"]') as HTMLInputElement, + 'click', + ); + + await user.click(uploadArea); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('opens file dialog when pressing Enter', async () => { + const user = userEvent.setup(); + render(FileInput); + const uploadArea = screen.getByRole('button'); + const clickSpy = vi.spyOn( + uploadArea.querySelector('input[type="file"]') as HTMLInputElement, + 'click', + ); + + uploadArea.focus(); + await user.keyboard('{Enter}'); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('highlights border when dragging file over drop zone', async () => { + render(FileInput); + const uploadArea = screen.getByRole('button'); + + await fireEvent.dragOver(uploadArea); + + expect(uploadArea).toHaveClass('border-slate-500', 'bg-slate-100'); + }); + + it('returns border to default when drag leaves drop zone', async () => { + render(FileInput); + + const uploadArea = screen.getByRole('button'); + + await fireEvent.dragOver(uploadArea); + expect(uploadArea).toHaveClass('border-slate-500', 'bg-slate-100'); + + await fireEvent.dragLeave(uploadArea); + expect(uploadArea).toHaveClass('border-slate-300', 'bg-slate-50'); + }); +}); diff --git a/src/lib/components/FileInput/index.ts b/src/lib/components/FileInput/index.ts new file mode 100644 index 00000000..6383a4a0 --- /dev/null +++ b/src/lib/components/FileInput/index.ts @@ -0,0 +1 @@ +export { default as FileInput, type Props as FileInputProps } from './FileInput.svelte'; diff --git a/src/lib/components/FormField/FormField.svelte b/src/lib/components/FormField/FormField.svelte new file mode 100644 index 00000000..317f7b4a --- /dev/null +++ b/src/lib/components/FormField/FormField.svelte @@ -0,0 +1,44 @@ + + +
+ + + {@render children()} + + +
diff --git a/src/lib/components/FormField/FormField.test.ts b/src/lib/components/FormField/FormField.test.ts new file mode 100644 index 00000000..3cb2ef64 --- /dev/null +++ b/src/lib/components/FormField/FormField.test.ts @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import { describe, expect, it } from 'vitest'; + +import FormField from './FormField.svelte'; + +describe('FormField', () => { + const children = createRawSnippet(() => ({ + render: () => ``, + })); + + it('renders label', () => { + render(FormField, { props: { label: 'Email Address', id: 'email', children } }); + + expect(screen.getByText('Email Address')).toBeInTheDocument(); + }); + + it("connects label to input using 'for' attribute", () => { + render(FormField, { props: { label: 'Username', id: 'username', children } }); + + expect(screen.getByText('Username')).toHaveAttribute('for', 'username'); + }); + + it('renders child input element', () => { + render(FormField, { props: { label: 'Name', id: 'name', children } }); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('shows required indicator when field is required', () => { + render(FormField, { + props: { label: 'Required Field', id: 'required', required: true, children }, + }); + + const asterisk = screen.getByText('*'); + expect(asterisk).toBeInTheDocument(); + expect(asterisk).toHaveClass('text-red-500'); + }); + + it('displays error message when provided', () => { + render(FormField, { + props: { label: 'Field', id: 'field', error: 'This field is required', children }, + }); + + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/FormField/index.ts b/src/lib/components/FormField/index.ts new file mode 100644 index 00000000..bb5ad368 --- /dev/null +++ b/src/lib/components/FormField/index.ts @@ -0,0 +1 @@ +export { default as FormField, type Props as FormFieldProps } from './FormField.svelte'; diff --git a/src/lib/components/Paginator/Paginator.svelte b/src/lib/components/Paginator/Paginator.svelte new file mode 100644 index 00000000..07d05370 --- /dev/null +++ b/src/lib/components/Paginator/Paginator.svelte @@ -0,0 +1,96 @@ + + +
+ Showing {lastItem} of {totalCount} + +
+ + + + +
+ (pageInput = e.currentTarget.value)} + onblur={handleBlur} + onkeydown={handleKeyDown} + aria-label="Current page" + /> + of {totalPages} +
+ + + +
+
diff --git a/src/lib/components/Paginator/Paginator.test.ts b/src/lib/components/Paginator/Paginator.test.ts new file mode 100644 index 00000000..8032eae2 --- /dev/null +++ b/src/lib/components/Paginator/Paginator.test.ts @@ -0,0 +1,283 @@ +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import Paginator from './Paginator.svelte'; + +describe('Paginator', () => { + it('shows text with correct total count', () => { + render(Paginator, { + props: { + totalCount: 100, + currentPage: 1, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + expect(screen.getByText('Showing 10 of 100')).toBeInTheDocument(); + }); + + it('displays current page in input', () => { + render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + const input = screen.getByLabelText('Current page') as HTMLInputElement; + + expect(input.value).toBe('5'); + }); + + it('displays total pages correctly', () => { + render(Paginator, { + props: { + totalCount: 100, + currentPage: 1, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + expect(screen.getByText('of 10')).toBeInTheDocument(); + }); + + it('disables previous button on first page', () => { + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 1, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + const buttons = container.querySelectorAll('button'); + const prevButton = buttons[0]; + + expect(prevButton).toBeDisabled(); + }); + + it('enables previous button when not on first page', () => { + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 2, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + const buttons = container.querySelectorAll('button'); + const prevButton = buttons[0]; + expect(prevButton).not.toBeDisabled(); + }); + + it('disables next button on last page', () => { + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 10, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + const buttons = container.querySelectorAll('button'); + const nextButton = buttons[1]; + expect(nextButton).toBeDisabled(); + }); + + it('enables next button when not on last page', () => { + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + const buttons = container.querySelectorAll('button'); + const nextButton = buttons[1]; + expect(nextButton).not.toBeDisabled(); + }); + + it('calls onpagechange when previous button clicked', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const buttons = container.querySelectorAll('button'); + const prevButton = buttons[0]; + await user.click(prevButton); + + expect(handlePageChange).toHaveBeenCalledTimes(1); + expect(handlePageChange).toHaveBeenCalledWith(4); + }); + + it('calls onpagechange when next button clicked', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + const { container } = render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const buttons = container.querySelectorAll('button'); + const nextButton = buttons[1]; + await user.click(nextButton); + + expect(handlePageChange).toHaveBeenCalledTimes(1); + expect(handlePageChange).toHaveBeenCalledWith(6); + }); + + it('calls onpagechange when valid page entered in input and blurred', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + render(Paginator, { + props: { + totalCount: 100, + currentPage: 1, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const input = screen.getByLabelText('Current page'); + await user.clear(input); + await user.type(input, '7'); + await user.tab(); // Trigger blur + + expect(handlePageChange).toHaveBeenCalledTimes(1); + expect(handlePageChange).toHaveBeenCalledWith(7); + }); + + it('calls onpagechange when Enter key pressed in input', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + render(Paginator, { + props: { + totalCount: 100, + currentPage: 1, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const input = screen.getByLabelText('Current page'); + await user.clear(input); + await user.type(input, '3{Enter}'); + + expect(handlePageChange).toHaveBeenCalledTimes(1); + expect(handlePageChange).toHaveBeenCalledWith(3); + }); + + it('does not call onpagechange for invalid page number (too low)', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const input = screen.getByLabelText('Current page'); + await user.clear(input); + await user.type(input, '0'); + await user.tab(); + + expect(handlePageChange).not.toHaveBeenCalled(); + }); + + it('does not call onpagechange for invalid page number (too high)', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const input = screen.getByLabelText('Current page'); + await user.clear(input); + await user.type(input, '11'); + await user.tab(); + + expect(handlePageChange).not.toHaveBeenCalled(); + }); + + it('does not call onpagechange for non-numeric input', async () => { + const user = userEvent.setup(); + const handlePageChange = vi.fn(); + render(Paginator, { + props: { + totalCount: 100, + currentPage: 5, + pageSize: 10, + onpagechange: handlePageChange, + }, + }); + + const input = screen.getByLabelText('Current page'); + await user.clear(input); + await user.type(input, 'abc'); + await user.tab(); + + expect(handlePageChange).not.toHaveBeenCalled(); + }); + + it('calculates total pages correctly with remainder', () => { + render(Paginator, { + props: { + totalCount: 95, + currentPage: 1, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + expect(screen.getByText('of 10')).toBeInTheDocument(); + }); + + it('handles single page correctly', () => { + const { container } = render(Paginator, { + props: { + totalCount: 5, + currentPage: 1, + pageSize: 10, + onpagechange: vi.fn(), + }, + }); + + expect(screen.getByText('Showing 5 of 5')).toBeInTheDocument(); + expect(screen.getByText('of 1')).toBeInTheDocument(); + + const buttons = container.querySelectorAll('button'); + const prevButton = buttons[0]; + const nextButton = buttons[1]; + expect(prevButton).toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); +}); diff --git a/src/lib/components/Paginator/index.ts b/src/lib/components/Paginator/index.ts new file mode 100644 index 00000000..1c009c14 --- /dev/null +++ b/src/lib/components/Paginator/index.ts @@ -0,0 +1 @@ +export { default as Paginator, type Props as PaginatorProps } from './Paginator.svelte'; diff --git a/src/lib/components/Select/Select.svelte b/src/lib/components/Select/Select.svelte new file mode 100644 index 00000000..da61b78b --- /dev/null +++ b/src/lib/components/Select/Select.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/components/Select/Select.test.ts b/src/lib/components/Select/Select.test.ts new file mode 100644 index 00000000..7e8fa772 --- /dev/null +++ b/src/lib/components/Select/Select.test.ts @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import { describe, expect, it } from 'vitest'; + +import Select from './Select.svelte'; + +describe('Select', () => { + const options = createRawSnippet(() => ({ + render: () => + '
', + })); + + it('renders select with options', () => { + render(Select, { props: { children: options } }); + + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + expect(select.tagName).toBe('SELECT'); + + const allOptions = screen.getAllByRole('option'); + console.log(allOptions); + expect(allOptions).toHaveLength(2); + expect(allOptions[0]).toHaveTextContent('Option 1'); + }); + + it('renders with selected value', () => { + const optionsWithSelected = createRawSnippet(() => ({ + render: () => ``, + })); + + render(Select, { props: { children: optionsWithSelected } }); + + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('2'); + }); + + it('supports additional attributes', () => { + render(Select, { + props: { + children: options, + 'data-testid': 'custom-select', + title: 'Select an option', + disabled: true, + }, + }); + + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select).toHaveAttribute('data-testid', 'custom-select'); + expect(select).toHaveAttribute('title', 'Select an option'); + expect(select).toBeDisabled(); + }); +}); diff --git a/src/lib/components/Select/index.ts b/src/lib/components/Select/index.ts new file mode 100644 index 00000000..f3019beb --- /dev/null +++ b/src/lib/components/Select/index.ts @@ -0,0 +1 @@ +export { default as Select } from './Select.svelte'; diff --git a/src/lib/components/Sidebar/Sidebar.svelte b/src/lib/components/Sidebar/Sidebar.svelte new file mode 100644 index 00000000..a5beac55 --- /dev/null +++ b/src/lib/components/Sidebar/Sidebar.svelte @@ -0,0 +1,60 @@ + + + diff --git a/src/lib/components/Sidebar/Sidebar.test.ts b/src/lib/components/Sidebar/Sidebar.test.ts new file mode 100644 index 00000000..5bfd1f7f --- /dev/null +++ b/src/lib/components/Sidebar/Sidebar.test.ts @@ -0,0 +1,124 @@ +import { BookOpen, Home, Settings } from '@lucide/svelte'; +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, test } from 'vitest'; + +import { Sidebar } from './index.js'; + +describe('Sidebar', () => { + const mockNavItems = [ + { href: '/admin', label: 'Dashboard', icon: Home }, + { href: '/admin/settings', label: 'Settings', icon: Settings }, + { href: '/admin/content', label: 'Content', icon: BookOpen }, + ]; + + test('renders sidebar title', () => { + render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + expect(screen.getByText('Test Admin')).toBeInTheDocument(); + }); + + test('renders all navigation items', () => { + render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + test('highlights active navigation item', () => { + const { container } = render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin/settings', + navItems: mockNavItems, + }, + }); + + const links = container.querySelectorAll('a'); + const settingsLink = Array.from(links).find((link) => link.textContent?.includes('Settings')); + + expect(settingsLink).toHaveClass('bg-slate-100'); + }); + + test('does not highlight inactive navigation items', () => { + const { container } = render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin/settings', + navItems: mockNavItems, + }, + }); + + const links = container.querySelectorAll('a'); + const dashboardLink = Array.from(links).find((link) => link.textContent?.includes('Dashboard')); + + expect(dashboardLink).not.toHaveClass('bg-slate-100'); + }); + + test('renders logout link', () => { + render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + test('logout link has correct href', () => { + render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + + const logoutLink = screen.getByText('Logout').closest('a'); + expect(logoutLink).toHaveAttribute('href', '/admin/logout'); + }); + + test('navigation items have correct hrefs', () => { + render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + + const dashboardLink = screen.getByText('Dashboard').closest('a'); + const settingsLink = screen.getByText('Settings').closest('a'); + const contentLink = screen.getByText('Content').closest('a'); + + expect(dashboardLink).toHaveAttribute('href', '/admin'); + expect(settingsLink).toHaveAttribute('href', '/admin/settings'); + expect(contentLink).toHaveAttribute('href', '/admin/content'); + }); + + test('applies hover styles to navigation items', () => { + const { container } = render(Sidebar, { + props: { + title: 'Test Admin', + currentPath: '/admin', + navItems: mockNavItems, + }, + }); + + const links = container.querySelectorAll('nav a'); + links.forEach((link) => { + expect(link).toHaveClass('hover:bg-slate-100'); + }); + }); +}); diff --git a/src/lib/components/Sidebar/index.ts b/src/lib/components/Sidebar/index.ts new file mode 100644 index 00000000..3d4a3c9e --- /dev/null +++ b/src/lib/components/Sidebar/index.ts @@ -0,0 +1 @@ +export { default as Sidebar, type Props as SidebarProps } from './Sidebar.svelte'; diff --git a/src/lib/components/Table/Table.svelte b/src/lib/components/Table/Table.svelte new file mode 100644 index 00000000..b4a59393 --- /dev/null +++ b/src/lib/components/Table/Table.svelte @@ -0,0 +1,48 @@ + + +
+
+ + + + {#each columns as column (column.key)} + + {/each} + + + + + {#each data as item, index (index)} + + {#each columns as column (column.key)} + + {/each} + + {:else} + + + + {/each} + +
+ {column.label} +
+ {item[column.key]} +
+ {emptyMessage} +
+
+
diff --git a/src/lib/components/Table/Table.test.ts b/src/lib/components/Table/Table.test.ts new file mode 100644 index 00000000..0954726b --- /dev/null +++ b/src/lib/components/Table/Table.test.ts @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import Table from './Table.svelte'; + +describe('Table', () => { + it('renders table with data', () => { + const data = [ + { id: 1, name: 'John', age: 30 }, + { id: 2, name: 'Jane', age: 25 }, + ]; + + const columns = [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'age', label: 'Age' }, + ]; + + render(Table, { props: { data, columns } }); + + expect(screen.getByText('ID')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Age')).toBeInTheDocument(); + expect(screen.getByText('John')).toBeInTheDocument(); + expect(screen.getByText('Jane')).toBeInTheDocument(); + }); + + it('renders empty state when no data', () => { + const columns = [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + ]; + + render(Table, { props: { data: [], columns, emptyMessage: 'No items' } }); + + expect(screen.getByText('No items')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/Table/index.ts b/src/lib/components/Table/index.ts new file mode 100644 index 00000000..c3cc7aa2 --- /dev/null +++ b/src/lib/components/Table/index.ts @@ -0,0 +1,2 @@ +export { default as Table, type Props as TableProps } from './Table.svelte'; +export type { Column as TableColumn } from './types'; diff --git a/src/lib/components/Table/types.ts b/src/lib/components/Table/types.ts new file mode 100644 index 00000000..287e7902 --- /dev/null +++ b/src/lib/components/Table/types.ts @@ -0,0 +1,6 @@ +export interface Column { + /** The key in the data object */ + key: string; + /** The label to display in the header */ + label: string; +} diff --git a/src/lib/components/TextArea/TextArea.svelte b/src/lib/components/TextArea/TextArea.svelte new file mode 100644 index 00000000..6dc34449 --- /dev/null +++ b/src/lib/components/TextArea/TextArea.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/lib/components/TextArea/TextArea.test.ts b/src/lib/components/TextArea/TextArea.test.ts new file mode 100644 index 00000000..c91324ef --- /dev/null +++ b/src/lib/components/TextArea/TextArea.test.ts @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import TextArea from './TextArea.svelte'; + +describe('TextArea', () => { + it('renders textbox', () => { + render(TextArea); + + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + + expect(textarea).toBeInTheDocument(); + expect(textarea.tagName).toBe('TEXTAREA'); + }); + + it('shows passed value prop', () => { + render(TextArea, { props: { value: 'Hello World\nMultiline text' } }); + + expect(screen.getByRole('textbox') as HTMLTextAreaElement).toHaveValue( + 'Hello World\nMultiline text', + ); + }); + + it('shows passed rows prop', () => { + render(TextArea, { props: { rows: 3 } }); + + expect(screen.getByRole('textbox') as HTMLTextAreaElement).toHaveAttribute('rows', '3'); + }); +}); diff --git a/src/lib/components/TextArea/index.ts b/src/lib/components/TextArea/index.ts new file mode 100644 index 00000000..e7679b4a --- /dev/null +++ b/src/lib/components/TextArea/index.ts @@ -0,0 +1 @@ +export { default as TextArea, type Props as TextAreaProps } from './TextArea.svelte'; diff --git a/src/lib/components/TextInput/TextInput.svelte b/src/lib/components/TextInput/TextInput.svelte new file mode 100644 index 00000000..9c6933ac --- /dev/null +++ b/src/lib/components/TextInput/TextInput.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/TextInput/TextInput.test.ts b/src/lib/components/TextInput/TextInput.test.ts new file mode 100644 index 00000000..081a263b --- /dev/null +++ b/src/lib/components/TextInput/TextInput.test.ts @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import TextInput from './TextInput.svelte'; + +describe('TextInput', () => { + it('renders with type text', () => { + render(TextInput, { props: { type: 'text' } }); + + const input = screen.getByRole('textbox') as HTMLInputElement; + + expect(input).toBeInTheDocument(); + expect(input.type).toBe('text'); + }); + + it('renders with passed type prop', () => { + render(TextInput, { props: { type: 'url' } }); + + const input = screen.getByRole('textbox') as HTMLInputElement; + + expect(input).toBeInTheDocument(); + expect(input.type).toBe('url'); + }); + + it('shows passed value prop', () => { + render(TextInput, { props: { type: 'text', value: 'Hello World' } }); + + expect(screen.getByRole('textbox') as HTMLInputElement).toHaveValue('Hello World'); + }); +}); diff --git a/src/lib/components/TextInput/index.ts b/src/lib/components/TextInput/index.ts new file mode 100644 index 00000000..c4bb8dd3 --- /dev/null +++ b/src/lib/components/TextInput/index.ts @@ -0,0 +1 @@ +export { default as TextInput, type Props as TextInputProps } from './TextInput.svelte'; From c78bddf14e56f1e44cffb6ba58d3917425018658 Mon Sep 17 00:00:00 2001 From: santosral <74949176+santosral@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:49:39 +0800 Subject: [PATCH 04/11] feat(admin): add authentication (#479) --- .env.example | 3 + src/hooks.server.ts | 59 ++----- src/lib/server/auth/auth.ts | 2 +- src/lib/server/auth/google.ts | 4 + src/lib/server/auth/index.ts | 19 ++- .../(protected)/unit/[id]/+page.server.ts | 6 +- .../unit/[id]/quiz/+page.server.ts | 4 +- src/routes/(main)/api/messages/+server.ts | 4 +- .../(main)/auth/google/callback/+server.ts | 5 +- src/routes/(main)/hooks.server.ts | 57 +++++++ src/routes/(main)/logout/+server.ts | 4 +- src/routes/admin/auth/google/+server.ts | 32 ++++ .../admin/auth/google/callback/+server.ts | 157 ++++++++++++++++++ src/routes/admin/hooks.server.ts | 54 ++++++ src/routes/admin/logout/+server.ts | 24 +++ 15 files changed, 374 insertions(+), 60 deletions(-) create mode 100644 src/routes/(main)/hooks.server.ts create mode 100644 src/routes/admin/auth/google/+server.ts create mode 100644 src/routes/admin/auth/google/callback/+server.ts create mode 100644 src/routes/admin/hooks.server.ts create mode 100644 src/routes/admin/logout/+server.ts diff --git a/.env.example b/.env.example index 560f0cc4..13dc84a6 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,6 @@ OPENAI_BASE_URL=xxx GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=xxx GOOGLE_HOSTED_DOMAIN= + +# Comma-separated list of admin email addresses +ADMIN_EMAILS=admin@example.com diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9e0cadbc..35e9a9bf 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,53 +1,20 @@ -import { type Handle, redirect } from '@sveltejs/kit'; -import { sequence } from '@sveltejs/kit/hooks'; +import type { Handle } from '@sveltejs/kit'; -import { HOME_PATH, nanoid } from '$lib/helpers/index.js'; -import auth from '$lib/server/auth/index.js'; -import { logger } from '$lib/server/logger.js'; +const route_specific_hooks = import.meta.glob('./routes/**/hooks.server.ts') as Record< + string, + () => Promise<{ handle: Handle }> +>; -/** - * A handle that adds a request ID to the response headers and attaches a scoped logger to the - * event. Downstream handles are expected to use the scoped logger for logging. - */ -const requestLoggingHandle: Handle = async ({ event, resolve }) => { - const requestId = nanoid(); +export const handle: Handle = async function handle({ event, resolve }) { + const hookDir = event.url.pathname.startsWith('/admin') ? '/admin' : '/(main)'; + const importer = route_specific_hooks['./routes' + hookDir + '/hooks.server.ts']; - event.setHeaders({ 'X-Request-Id': requestId }); - event.locals.logger = logger.child({ requestId }); - - return await resolve(event); -}; - -/** - * A handle that enforces authentication on protected routes. - * - Requests to `/api/*` are always allowed through - * - Authenticated users visiting `/login` are redirected back to `/` - * - Unauthenticated users are redirected to `/login` - */ -const routeProtectionHandle: Handle = async ({ event, resolve }) => { - if (event.url.pathname.startsWith('/api/')) { - return await resolve(event); - } - - if (event.locals.session.isAuthenticated) { - if (event.url.pathname === '/login') { - return redirect(302, HOME_PATH); + if (importer) { + const module = await importer(); + if (module.handle) { + return module.handle({ event, resolve }); } - - return await resolve(event); - } - - if ( - event.url.pathname === '/login' || - event.url.pathname === '/auth/google' || - event.url.pathname === '/auth/google/callback' || - event.url.pathname === '/terms' || - event.url.pathname === '/privacy' - ) { - return await resolve(event); } - return redirect(303, `/login?return_to=${encodeURIComponent(event.url.pathname)}`); + return resolve(event); }; - -export const handle: Handle = sequence(requestLoggingHandle, auth.handle, routeProtectionHandle); diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 4ea6a1b9..ac3e2223 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -161,7 +161,7 @@ export interface AuthResult { /** * The default session timeout for unauthenticated sessions. */ -const DEFAULT_SESSION_DEFAULT_TIMEOUT = '3h'; +const DEFAULT_SESSION_DEFAULT_TIMEOUT = '5m'; /** * The default session timeout for authenticated sessions. */ diff --git a/src/lib/server/auth/google.ts b/src/lib/server/auth/google.ts index caf7f45d..1cff870d 100644 --- a/src/lib/server/auth/google.ts +++ b/src/lib/server/auth/google.ts @@ -41,6 +41,7 @@ function getOAuth2Client() { * @param options.origin - The origin of the application. It is used to construct the redirect URI. * @param options.state - A random string to prevent CSRF attacks. * @param options.codeVerifier - A cryptographically random string used for PKCE verification. + * @param options.prompt - A space-delimited list of prompts for authentication. * @returns The complete Google OAuth2 authorization URL. * * @example @@ -57,10 +58,12 @@ export function generateAuthURL({ origin, state, codeVerifier, + prompt, }: { origin: string; state: string; codeVerifier: string; + prompt?: string; }): string { const client = getOAuth2Client(); @@ -74,6 +77,7 @@ export function generateAuthURL({ code_challenge: createHash('sha256').update(codeVerifier).digest('base64url'), code_challenge_method: CodeChallengeMethod.S256, hd: env.GOOGLE_HOSTED_DOMAIN, + prompt, }); } diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 52caf32b..99c0e61c 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -4,9 +4,8 @@ import { valkey } from '../valkey.js'; import Auth from './auth.js'; export * from './google.js'; -export type { default as Session } from './session.js'; -export default Auth(valkey, { +export const learnerAuth = Auth(valkey, { cookies: { session: { name: 'learner.session', @@ -18,3 +17,19 @@ export default Auth(valkey, { generateId: () => nanoid(), generateCSRFToken: () => nanoid(), }); + +export const adminAuth = Auth(valkey, { + session: { + authenticatedTimeout: '1d', + }, + cookies: { + session: { + name: 'admin.session', + }, + csrf: { + name: 'admin.csrf', + }, + }, + generateId: () => nanoid(), + generateCSRFToken: () => nanoid(), +}); diff --git a/src/routes/(main)/(protected)/unit/[id]/+page.server.ts b/src/routes/(main)/(protected)/unit/[id]/+page.server.ts index e9819863..093ae9e5 100644 --- a/src/routes/(main)/(protected)/unit/[id]/+page.server.ts +++ b/src/routes/(main)/(protected)/unit/[id]/+page.server.ts @@ -2,7 +2,7 @@ import { error, redirect } from '@sveltejs/kit'; import { validate as uuidValidate } from 'uuid'; import { getLearningUnitStatus } from '$lib/helpers/index.js'; -import auth from '$lib/server/auth'; +import { learnerAuth } from '$lib/server/auth'; import { db, type GetLearningUnitSentimentsAggregateType, @@ -220,7 +220,7 @@ export const actions: Actions = { throw error(400); } - const isValidCSRFToken = await auth.validateCSRFToken(event, csrfToken); + const isValidCSRFToken = await learnerAuth.validateCSRFToken(event, csrfToken); if (!isValidCSRFToken) { logger.warn('CSRF token is invalid'); throw error(400); @@ -293,7 +293,7 @@ export const actions: Actions = { throw error(400); } - const isValidCSRFToken = await auth.validateCSRFToken(event, csrfToken); + const isValidCSRFToken = await learnerAuth.validateCSRFToken(event, csrfToken); if (!isValidCSRFToken) { logger.warn('CSRF token is invalid'); throw error(400); diff --git a/src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts b/src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts index a32309c7..749742a0 100644 --- a/src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts +++ b/src/routes/(main)/(protected)/unit/[id]/quiz/+page.server.ts @@ -1,7 +1,7 @@ import { error, redirect } from '@sveltejs/kit'; import { validate as uuidValidate } from 'uuid'; -import auth from '$lib/server/auth/index.js'; +import { learnerAuth } from '$lib/server/auth/index.js'; import { db, type LearningJourneyUpsertArgs, @@ -93,7 +93,7 @@ export const actions: Actions = { throw error(400); } - const isValidCSRFToken = await auth.validateCSRFToken(event, csrfToken); + const isValidCSRFToken = await learnerAuth.validateCSRFToken(event, csrfToken); if (!isValidCSRFToken) { logger.warn('CSRF token is invalid'); throw error(400); diff --git a/src/routes/(main)/api/messages/+server.ts b/src/routes/(main)/api/messages/+server.ts index 9a1303f8..bcdac31c 100644 --- a/src/routes/(main)/api/messages/+server.ts +++ b/src/routes/(main)/api/messages/+server.ts @@ -1,6 +1,6 @@ import { json, type RequestHandler } from '@sveltejs/kit'; -import auth from '$lib/server/auth'; +import { learnerAuth } from '$lib/server/auth'; import { db, type MessageFindManyArgs, type MessageGetPayload, Role } from '$lib/server/db.js'; import { DEVELOPER_MESSAGE, openAI } from '$lib/server/openai.js'; import { search } from '$lib/server/weaviate'; @@ -54,7 +54,7 @@ export const POST: RequestHandler = async (event) => { return json(null, { status: 400 }); } - const isValidCSRFToken = await auth.validateCSRFToken(event, csrfToken); + const isValidCSRFToken = await learnerAuth.validateCSRFToken(event, csrfToken); if (!isValidCSRFToken) { logger.warn('CSRF token is invalid'); return json(null, { status: 400 }); diff --git a/src/routes/(main)/auth/google/callback/+server.ts b/src/routes/(main)/auth/google/callback/+server.ts index 4c626c4b..2ea0e3c8 100644 --- a/src/routes/(main)/auth/google/callback/+server.ts +++ b/src/routes/(main)/auth/google/callback/+server.ts @@ -1,9 +1,10 @@ import { redirect } from '@sveltejs/kit'; import { HOME_PATH } from '$lib/helpers'; -import auth, { +import { exchangeCodeForIdToken, type GoogleProfile, + learnerAuth, verifyIdToken, } from '$lib/server/auth/index.js'; import { @@ -131,7 +132,7 @@ export const GET: RequestHandler = async (event) => { } try { - await auth.signIn(event, { + await learnerAuth.signIn(event, { id: user.id.toString(), email: user.email, name: user.name, diff --git a/src/routes/(main)/hooks.server.ts b/src/routes/(main)/hooks.server.ts new file mode 100644 index 00000000..9228c00c --- /dev/null +++ b/src/routes/(main)/hooks.server.ts @@ -0,0 +1,57 @@ +import { type Handle, redirect } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; + +import { HOME_PATH, nanoid } from '$lib/helpers/index.js'; +import { learnerAuth } from '$lib/server/auth/index.js'; +import { logger } from '$lib/server/logger.js'; + +/** + * A handle that adds a request ID to the response headers and attaches a scoped logger to the + * event. Downstream handles are expected to use the scoped logger for logging. + */ +const requestLoggingHandle: Handle = async ({ event, resolve }) => { + const requestId = nanoid(); + + event.setHeaders({ 'X-Request-Id': requestId }); + event.locals.logger = logger.child({ requestId }); + + return await resolve(event); +}; + +/** + * A handle that enforces authentication on protected routes. + * - Requests to `/api/*` are always allowed through + * - Authenticated users visiting `/login` are redirected back to `/` + * - Unauthenticated users are redirected to `/login` + */ +const routeProtectionHandle: Handle = async ({ event, resolve }) => { + if (event.url.pathname.startsWith('/api/')) { + return await resolve(event); + } + + if (event.locals.session.isAuthenticated) { + if (event.url.pathname === '/login') { + return redirect(302, HOME_PATH); + } + + return await resolve(event); + } + + if ( + event.url.pathname === '/login' || + event.url.pathname === '/auth/google' || + event.url.pathname === '/auth/google/callback' || + event.url.pathname === '/terms' || + event.url.pathname === '/privacy' + ) { + return await resolve(event); + } + + return redirect(303, `/login?return_to=${encodeURIComponent(event.url.pathname)}`); +}; + +export const handle: Handle = sequence( + requestLoggingHandle, + learnerAuth.handle, + routeProtectionHandle, +); diff --git a/src/routes/(main)/logout/+server.ts b/src/routes/(main)/logout/+server.ts index 1d2e743f..9cd4fd35 100644 --- a/src/routes/(main)/logout/+server.ts +++ b/src/routes/(main)/logout/+server.ts @@ -1,6 +1,6 @@ import { redirect, type RequestHandler } from '@sveltejs/kit'; -import auth from '$lib/server/auth/index.js'; +import { learnerAuth } from '$lib/server/auth/index.js'; export const GET: RequestHandler = async (event) => { const logger = event.locals.logger.child({ handler: 'api_logout' }); @@ -12,7 +12,7 @@ export const GET: RequestHandler = async (event) => { } try { - await auth.signOut(event); + await learnerAuth.signOut(event); } catch (err) { logger.error({ err, email: user.email }, 'Failed to sign out user'); return redirect(302, '/login?error=logout_failed'); diff --git a/src/routes/admin/auth/google/+server.ts b/src/routes/admin/auth/google/+server.ts new file mode 100644 index 00000000..1a247fe9 --- /dev/null +++ b/src/routes/admin/auth/google/+server.ts @@ -0,0 +1,32 @@ +import { redirect } from '@sveltejs/kit'; + +import { nanoid } from '$lib/helpers/index.js'; +import { generateAuthURL } from '$lib/server/auth/index.js'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'google_oauth2_initiate' }); + + const state = Buffer.from( + JSON.stringify({ + csrf_token: event.locals.session.csrfToken(), + return_to: event.url.searchParams.get('return_to'), + }), + ).toString('base64url'); + + const codeVerifier = nanoid(64); + const authURL = generateAuthURL({ + origin: `${event.url.origin}/admin`, + state, + codeVerifier, + prompt: 'select_account', + }); + + event.locals.session.set('adminCodeVerifier', codeVerifier); + event.locals.session.set('adminAuthURL', authURL); + + logger.info('Redirecting to Google OAuth2 authorization URL'); + + return redirect(307, authURL); +}; diff --git a/src/routes/admin/auth/google/callback/+server.ts b/src/routes/admin/auth/google/callback/+server.ts new file mode 100644 index 00000000..e660ed5c --- /dev/null +++ b/src/routes/admin/auth/google/callback/+server.ts @@ -0,0 +1,157 @@ +import { redirect } from '@sveltejs/kit'; + +import { env } from '$env/dynamic/private'; +import { + adminAuth, + exchangeCodeForIdToken, + type GoogleProfile, + verifyIdToken, +} from '$lib/server/auth/index.js'; +import { + db, + PrismaClientKnownRequestError, + type UserFindUniqueArgs, + type UserGetPayload, +} from '$lib/server/db.js'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'google_oauth2_callback' }); + + const code = event.url.searchParams.get('code'); + const state = event.url.searchParams.get('state'); + if (!code || !state) { + logger.error( + { + hasCode: !!code, + hasState: !!state, + }, + 'Missing required OAuth2 parameters from URL', + ); + return redirect(302, '/admin'); + } + + const codeVerifier = event.locals.session.get('adminCodeVerifier'); + const authURL = event.locals.session.get('adminAuthURL'); + if (!codeVerifier || !authURL) { + logger.error( + { + hasCodeVerifier: !!codeVerifier, + hasAuthURL: !!authURL, + }, + 'Missing required values from session', + ); + return redirect(302, '/admin'); + } + + const originalState = new URL(authURL).searchParams.get('state'); + if (!originalState || originalState !== state) { + logger.error('State mismatch between original and callback'); + return redirect(302, '/admin'); + } + + let idToken: string | null = null; + try { + idToken = await exchangeCodeForIdToken({ + origin: `${event.url.origin}/admin`, + code, + codeVerifier, + }); + + if (!idToken) { + logger.error('Missing ID token'); + return redirect(302, '/admin'); + } + } catch (err) { + logger.error(err, 'Failed to exchange code for ID token'); + return redirect(302, '/admin'); + } + + let profile: GoogleProfile | null = null; + try { + profile = await verifyIdToken(idToken); + + if (!profile) { + logger.error('Invalid Google profile'); + return redirect(302, '/admin'); + } + } catch (err) { + logger.error(err, 'Failed to verify ID token'); + return redirect(302, '/admin'); + } + + const adminEmails = env.ADMIN_EMAILS ? env.ADMIN_EMAILS.split(',') : []; + if (!adminEmails.includes(profile.email)) { + logger.warn({ email: profile.email }, 'Unauthorized email was used to sign in'); + return redirect(302, '/admin'); + } + + const userArgs = { + select: { + id: true, + email: true, + name: true, + avatarURL: true, + }, + where: { + email: profile.email, + }, + } satisfies UserFindUniqueArgs; + + let user: UserGetPayload | null = null; + try { + user = await db.user.findUnique(userArgs); + } catch (err) { + logger.error({ err, email: profile.email }, 'Failed to find user'); + return redirect(302, '/admin'); + } + + if (!user) { + try { + user = await db.user.create({ + data: { + name: profile.name, + email: profile.email, + googleProviderId: profile.id, + avatarURL: profile.picture, + }, + select: { + id: true, + email: true, + name: true, + avatarURL: true, + }, + }); + } catch (err) { + if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') { + logger.error( + { err, email: profile.email }, + 'Failed to create user due to duplicate constraint', + ); + return redirect(302, '/admin'); + } + + logger.error({ err, email: profile.email }, 'Failed to create user'); + return redirect(302, '/admin'); + } + } + + try { + await adminAuth.signIn(event, { + id: user.id.toString(), + email: user.email, + name: user.name, + }); + } catch (err) { + logger.error({ err, email: user.email }, 'Failed to sign in user'); + return redirect(302, '/admin'); + } + + const rawState = JSON.parse(Buffer.from(state, 'base64url').toString('utf-8')); + const returnTo = rawState['return_to'] || '/admin'; + + logger.info({ email: user.email }, 'Successfully signed in user'); + + return redirect(302, returnTo); +}; diff --git a/src/routes/admin/hooks.server.ts b/src/routes/admin/hooks.server.ts new file mode 100644 index 00000000..1be58d54 --- /dev/null +++ b/src/routes/admin/hooks.server.ts @@ -0,0 +1,54 @@ +import { type Handle, redirect } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; + +import { nanoid } from '$lib/helpers/index.js'; +import { adminAuth } from '$lib/server/auth/index.js'; +import { logger } from '$lib/server/logger.js'; + +/** + * A handle that adds a request ID to the response headers and attaches a scoped logger to the + * event. Downstream handles are expected to use the scoped logger for logging. + */ +const requestLoggingHandle: Handle = async ({ event, resolve }) => { + const requestId = nanoid(); + + event.setHeaders({ 'X-Request-Id': requestId }); + event.locals.logger = logger.child({ requestId, module: 'admin' }); + + return await resolve(event); +}; + +/** + * A handle that enforces authentication on admin routes. + * - Requests for google auth (`/admin/auth/google/*`) are always allowed through. + * - Unauthenticated users are redirected to `/admin/auth/google?return_to=%2Fadmin`. + * - Authenticated users who are not 'admin' are redirected to `/admin/auth/google?return_to=%2Fadmin`. + */ +const routeProtectionHandle: Handle = async ({ event, resolve }) => { + if (!event.url.pathname.startsWith('/admin')) { + return redirect(303, '/admin/auth/google?return_to=%2Fadmin'); + } + + if ( + event.url.pathname === '/admin/auth/google' || + event.url.pathname === '/admin/auth/google/callback' + ) { + return await resolve(event); + } + + if (!event.locals.session.isAuthenticated) { + return redirect(303, '/admin/auth/google?return_to=%2Fadmin'); + } + + if (!event.locals.session.user) { + return redirect(303, '/admin/auth/google?return_to=%2Fadmin'); + } + + return await resolve(event); +}; + +export const handle: Handle = sequence( + requestLoggingHandle, + adminAuth.handle, + routeProtectionHandle, +); diff --git a/src/routes/admin/logout/+server.ts b/src/routes/admin/logout/+server.ts new file mode 100644 index 00000000..534a3e57 --- /dev/null +++ b/src/routes/admin/logout/+server.ts @@ -0,0 +1,24 @@ +import { redirect, type RequestHandler } from '@sveltejs/kit'; + +import { adminAuth } from '$lib/server/auth/index.js'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_logout' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return redirect(303, '/admin'); + } + + try { + await adminAuth.signOut(event); + } catch (err) { + logger.error({ err, email: user.email }, 'Failed to sign out user'); + return redirect(302, '/admin'); + } + + logger.info({ email: user.email }, 'Successfully signed out user'); + + return redirect(302, '/admin'); +}; From cff84367249b5e9c19021d0c0ef6f267159d702c Mon Sep 17 00:00:00 2001 From: santosral <74949176+santosral@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:13:05 +0800 Subject: [PATCH 05/11] feat(admin): add admin dashboard (#482) --- src/routes/admin/+layout.svelte | 20 ++++++++ src/routes/admin/+page.server.ts | 55 +++++++++++++++++++++ src/routes/admin/+page.svelte | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/routes/admin/+layout.svelte create mode 100644 src/routes/admin/+page.server.ts create mode 100644 src/routes/admin/+page.svelte diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 00000000..737cd8bc --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,20 @@ + + +
+ + +
+ {@render children()} +
+
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 00000000..c87586b1 --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -0,0 +1,55 @@ +import { error } from '@sveltejs/kit'; + +import { db, type LearningUnitFindManyArgs, type LearningUnitGetPayload } from '$lib/server/db.js'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.session.user) { + return error(401, 'Unauthorized'); + } + + const logger = event.locals.logger.child({ + userID: event.locals.session.user.id, + handler: 'page_load_admin', + }); + + const page = Number(event.url.searchParams.get('page')) || 1; + const pageSize = Number(event.url.searchParams.get('pageSize')) || 10; + const skip = (page - 1) * pageSize; + + const learningUnitArgs = { + select: { + id: true, + title: true, + createdBy: true, + createdAt: true, + isRecommended: true, + isRequired: true, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: pageSize, + } satisfies LearningUnitFindManyArgs; + + let learningUnits: LearningUnitGetPayload[]; + let totalCount: number; + try { + [learningUnits, totalCount] = await Promise.all([ + db.learningUnit.findMany(learningUnitArgs), + db.learningUnit.count(), + ]); + } catch (err) { + logger.error({ err }, 'Failed to fetch learning units'); + throw error(500, 'Internal Server Error'); + } + + return { + learningUnits, + totalCount, + currentPage: page, + pageSize, + }; +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 00000000..003296cd --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,83 @@ + + +
+
+
+ Learning Units + Manage all learning units +
+ + + Create New Learning Unit + +
+ +
+ + + {#if data.totalCount > data.pageSize} + + {/if} + + From 52a8c7d51ae8a9533eff02160aa364e45e4941d5 Mon Sep 17 00:00:00 2001 From: santosral <74949176+santosral@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:33:28 +0800 Subject: [PATCH 06/11] feat(admin): add learning unit create form page (#483) --- .env.example | 7 +- src/lib/server/s3.ts | 29 ++ src/lib/server/unit/form.ts | 275 ++++++++++++++++ src/lib/server/unit/index.ts | 1 + .../(protected)/podcasts/[...key]/+server.ts | 2 +- src/routes/admin/unit/new/+page.server.ts | 162 ++++++++++ src/routes/admin/unit/new/+page.svelte | 295 ++++++++++++++++++ 7 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/unit/form.ts create mode 100644 src/lib/server/unit/index.ts create mode 100644 src/routes/admin/unit/new/+page.server.ts create mode 100644 src/routes/admin/unit/new/+page.svelte diff --git a/.env.example b/.env.example index 13dc84a6..586d7eec 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Used for Learning Unit podcast creation. +APP_URL=http://localhost:5173 + # The connection string to Valkey. VALKEY_URL=xxx @@ -29,5 +32,5 @@ GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=xxx GOOGLE_HOSTED_DOMAIN= -# Comma-separated list of admin email addresses -ADMIN_EMAILS=admin@example.com +# The admin user emails (comma-separated). +ADMIN_EMAILS=xxx diff --git a/src/lib/server/s3.ts b/src/lib/server/s3.ts index 01271035..51547f54 100644 --- a/src/lib/server/s3.ts +++ b/src/lib/server/s3.ts @@ -1,9 +1,12 @@ +import { extname } from 'node:path'; import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; import { GetObjectCommand, NoSuchKey, NotFound, + PutObjectCommand, S3Client, S3ServiceException, } from '@aws-sdk/client-s3'; @@ -87,6 +90,32 @@ export async function getPodcastObject( } } +/** + * Uploads a podcast file to S3. + * + * @param file - The file to upload. + * @param key - The key to store the file under. + * @returns The URL of the uploaded file. + */ +export async function uploadPodcastObject(file: File, key: string): Promise { + const fileExt = extname(file.name).slice(1); + const path = `podcasts/${key}.${fileExt}`; + + const nodeStream = Readable.fromWeb(file.stream() as ReadableStream); + + await client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: path, + Body: nodeStream, + ContentType: file.type, + ContentLength: file.size, + }), + ); + + return `${env.APP_URL || 'http://localhost:5173'}/${path}`; +} + /** * The base class for all S3 errors. */ diff --git a/src/lib/server/unit/form.ts b/src/lib/server/unit/form.ts new file mode 100644 index 00000000..fec8277b --- /dev/null +++ b/src/lib/server/unit/form.ts @@ -0,0 +1,275 @@ +import type { ContentType } from '$lib/server/db.js'; + +export const ERROR_MESSAGES = { + FIELD_REQUIRED: 'This field is required.', + INVALID_OPTION: 'Invalid option selected. Please select a valid option.', + DATE_PAST: 'Due date must be tomorrow or later.', + INVALID_DATA: (data = 'data') => `Invalid ${data} format.`, + FILE_UPLOAD_REQUIRED: (fileType: string) => `Please upload a ${fileType} file`, + FILE_TYPE_INVALID: (fileType: string) => `File must be a valid ${fileType} file`, + ARRAY_MIN: (field: string, min: number) => + `At least ${min} ${field.toLowerCase()} ${min === 1 ? 'is' : 'are'} required`, +}; + +export interface LearningUnitFormData { + title: string; + contentType: ContentType; + podcastFile: File; + summary: string; + objectives: string; + createdBy: string; + collectionId: string; + isRecommended: boolean; + isRequired: boolean; + dueDate: Date | null; + tagIds: string[]; + sources: { title: string; sourceURL: string; tagId: string }[]; + questionAnswers: { + question: string; + options: string[]; + answer: number; + explanation: string; + }[]; +} + +export type FormValidationError = Record< + string, + { message: string; items?: Record[] } +>; + +/** + * Parse and validate learning unit form data from a FormData object + * @param data - FormData from the request + * @returns Validated learning unit data or validation errors + */ +export function validateLearningUnit(data: FormData): + | { success: true; data: LearningUnitFormData } + | { + success: false; + errors: FormValidationError; + } { + const errors: FormValidationError = {}; + + const title = data.get('title'); + if (!title || typeof title !== 'string' || title.trim().length === 0) { + errors.title = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } + + const contentType = data.get('contentType'); + if (!contentType || typeof contentType !== 'string') { + errors.contentType = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } else if (contentType !== 'PODCAST') { + errors.contentType = { message: ERROR_MESSAGES.INVALID_OPTION }; + } + + const podcastFile = data.get('podcastFile'); + if (!podcastFile || !(podcastFile instanceof File) || podcastFile.size === 0) { + errors.podcastFile = { message: ERROR_MESSAGES.FILE_UPLOAD_REQUIRED('podcast') }; + } else if (!podcastFile.type.startsWith('audio/')) { + errors.podcastFile = { message: ERROR_MESSAGES.FILE_TYPE_INVALID('audio') }; + } + + const summary = data.get('summary'); + if (!summary || typeof summary !== 'string' || summary.trim().length === 0) { + errors.summary = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } + + const objectives = data.get('objectives'); + if (!objectives || typeof objectives !== 'string' || objectives.trim().length === 0) { + errors.objectives = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } + + const createdBy = data.get('createdBy'); + if (!createdBy || typeof createdBy !== 'string' || createdBy.trim().length === 0) { + errors.createdBy = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } + + const collectionId = data.get('collectionId'); + if (!collectionId || typeof collectionId !== 'string' || collectionId.trim().length === 0) { + errors.collectionId = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } + + const isRecommended = data.get('isRecommended') === 'on'; + const isRequired = data.get('isRequired') === 'on'; + + let dueDate: Date | null = null; + const dueDateRaw = data.get('dueDate'); + + if (isRequired) { + if (!dueDateRaw || typeof dueDateRaw !== 'string' || dueDateRaw.trim().length === 0) { + errors.dueDate = { message: ERROR_MESSAGES.FIELD_REQUIRED }; + } else { + const dueDateObj = new Date(dueDateRaw); + + if (isNaN(dueDateObj.getTime())) { + errors.dueDate = { message: ERROR_MESSAGES.INVALID_DATA('date') }; + } else { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (dueDateObj <= today) { + errors.dueDate = { message: ERROR_MESSAGES.DATE_PAST }; + } else { + dueDate = dueDateObj; + } + } + } + } else { + dueDate = null; + } + + const tagIds = data + .getAll('tags') + .filter((id): id is string => typeof id === 'string' && id.length > 0); + if (tagIds.length === 0) { + errors.tags = { message: ERROR_MESSAGES.ARRAY_MIN('Tag', 1) }; + } + + const sourcesJson = data.get('sources'); + let sources: LearningUnitFormData['sources'] = []; + if (sourcesJson && typeof sourcesJson === 'string') { + try { + sources = JSON.parse(sourcesJson); + } catch { + errors.sources = { message: ERROR_MESSAGES.INVALID_DATA(), items: [] }; + } + + if (!Array.isArray(sources)) { + errors.sources = { message: ERROR_MESSAGES.INVALID_DATA(), items: [] }; + } else { + if (sources.length > 0) { + const sourcesItemErrors: Record[] = []; + + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const itemError: Record = {}; + + if ( + !source.title || + typeof source.title !== 'string' || + source.title.trim().length === 0 + ) { + itemError.title = ERROR_MESSAGES.FIELD_REQUIRED; + } + + if ( + !source.sourceURL || + typeof source.sourceURL !== 'string' || + source.sourceURL.trim().length === 0 + ) { + itemError.sourceURL = ERROR_MESSAGES.FIELD_REQUIRED; + } else { + try { + new URL(source.sourceURL); + } catch { + itemError.sourceURL = ERROR_MESSAGES.INVALID_DATA('URL'); + } + } + + if (typeof source.tagId !== 'string') { + source.tagId = ''; + } + + if (Object.keys(itemError).length > 0) { + sourcesItemErrors[i] = itemError; + } + + if (sourcesItemErrors.length > 0) { + errors.sources = { message: '', items: sourcesItemErrors }; + } + } + } + } + } else { + errors.sources = { message: ERROR_MESSAGES.ARRAY_MIN('Source', 1), items: [] }; + } + + const questionAnswersJson = data.get('questionAnswers'); + let questionAnswers: LearningUnitFormData['questionAnswers'] = []; + if (questionAnswersJson && typeof questionAnswersJson === 'string') { + try { + questionAnswers = JSON.parse(questionAnswersJson); + } catch { + errors.questionAnswers = { message: ERROR_MESSAGES.INVALID_DATA(), items: [] }; + } + + if (!Array.isArray(questionAnswers)) { + errors.questionAnswers = { message: ERROR_MESSAGES.INVALID_DATA(), items: [] }; + } else { + if (questionAnswers.length === 0) { + errors.questionAnswers = { message: ERROR_MESSAGES.ARRAY_MIN('question', 1), items: [] }; + } else { + const questionAnswerItemErrors: Record[] = []; + for (let i = 0; i < questionAnswers.length; i++) { + const questionAnswer = questionAnswers[i]; + const itemError: Record = {}; + + if ( + !questionAnswer.question || + typeof questionAnswer.question !== 'string' || + questionAnswer.question.trim().length === 0 + ) { + itemError.question = ERROR_MESSAGES.FIELD_REQUIRED; + } + + if (!Array.isArray(questionAnswer.options) || questionAnswer.options.length < 2) { + itemError.options = ERROR_MESSAGES.ARRAY_MIN('Option', 2); + } else { + questionAnswer.options.filter((opt: string) => !opt || opt.trim().length === 0); + } + + const answerIndex = + typeof questionAnswer.answer === 'string' + ? Number(questionAnswer.answer) + : questionAnswer.answer; + if (typeof answerIndex !== 'number' || isNaN(answerIndex)) { + itemError.answer = ERROR_MESSAGES.FIELD_REQUIRED; + } else { + questionAnswer.answer = answerIndex; + } + + if ( + !questionAnswer.explanation || + typeof questionAnswer.explanation !== 'string' || + questionAnswer.explanation.trim().length === 0 + ) { + itemError.explanation = ERROR_MESSAGES.FIELD_REQUIRED; + } + + if (Object.keys(itemError).length > 0) { + questionAnswerItemErrors[i] = itemError; + } + } + + if (questionAnswerItemErrors.length > 0) { + errors.questionAnswers = { message: '', items: questionAnswerItemErrors }; + } + } + } + } else { + errors.questionAnswers = { message: ERROR_MESSAGES.ARRAY_MIN('Question', 1), items: [] }; + } + + if (Object.keys(errors).length > 0) { + return { success: false, errors }; + } + + return { + success: true, + data: { + title: (title as string).trim(), + contentType: contentType as ContentType, + podcastFile: podcastFile as File, + summary: (summary as string).trim(), + objectives: (objectives as string).trim(), + createdBy: (createdBy as string).trim(), + collectionId: (collectionId as string).trim(), + isRecommended, + isRequired, + dueDate, + tagIds, + sources, + questionAnswers, + }, + }; +} diff --git a/src/lib/server/unit/index.ts b/src/lib/server/unit/index.ts new file mode 100644 index 00000000..573e0dc6 --- /dev/null +++ b/src/lib/server/unit/index.ts @@ -0,0 +1 @@ +export * from './form.js'; diff --git a/src/routes/(main)/(protected)/podcasts/[...key]/+server.ts b/src/routes/(main)/(protected)/podcasts/[...key]/+server.ts index fe61d5ea..e3e7d643 100644 --- a/src/routes/(main)/(protected)/podcasts/[...key]/+server.ts +++ b/src/routes/(main)/(protected)/podcasts/[...key]/+server.ts @@ -38,7 +38,7 @@ export const GET: RequestHandler = async (event) => { headers.set(key, value); } - return new Response(podcast.stream, { + return new Response(podcast.stream as BodyInit, { status: range ? 206 : 200, headers, }); diff --git a/src/routes/admin/unit/new/+page.server.ts b/src/routes/admin/unit/new/+page.server.ts new file mode 100644 index 00000000..9d735780 --- /dev/null +++ b/src/routes/admin/unit/new/+page.server.ts @@ -0,0 +1,162 @@ +import { error, fail, redirect } from '@sveltejs/kit'; + +import { nanoid } from '$lib/helpers/index.js'; +import { + type CollectionFindManyArgs, + type CollectionGetPayload, + db, + type LearningUnitCreateArgs, + type LearningUnitGetPayload, + type TagFindManyArgs, + type TagGetPayload, +} from '$lib/server/db.js'; +import { uploadPodcastObject } from '$lib/server/s3.js'; +import { validateLearningUnit } from '$lib/server/unit/form.js'; + +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.session.user) { + redirect(303, '/admin'); + } + + const logger = event.locals.logger.child({ + userID: event.locals.session.user.id, + handler: 'page_load_unit_new', + }); + + const collectionArgs = { + select: { + id: true, + title: true, + type: true, + }, + orderBy: { + title: 'asc', + }, + } satisfies CollectionFindManyArgs; + + const tagBaseArgs = { + select: { + id: true, + code: true, + label: true, + }, + orderBy: { + label: 'asc', + }, + } satisfies TagFindManyArgs; + + const contentTagArgs = { + ...tagBaseArgs, + where: { code: { notIn: ['PDF', 'LINK'] } }, + } satisfies TagFindManyArgs; + + const sourceTagArgs = { + ...tagBaseArgs, + where: { code: { in: ['PDF', 'LINK'] } }, + } satisfies TagFindManyArgs; + + let collections: CollectionGetPayload[]; + let contentTags: TagGetPayload[]; + let sourceTags: TagGetPayload[]; + try { + [collections, contentTags, sourceTags] = await Promise.all([ + db.collection.findMany(collectionArgs), + db.tag.findMany(contentTagArgs), + db.tag.findMany(sourceTagArgs), + ]); + } catch (err) { + logger.error({ err }, 'Failed to fetch collections and tags'); + throw error(500); + } + + return { + collections, + contentTags, + sourceTags, + }; +}; + +export const actions = { + default: async (event) => { + if (!event.locals.session.user) { + redirect(303, '/admin'); + } + + const logger = event.locals.logger.child({ + userID: event.locals.session.user.id, + handler: 'action_create_learning_unit', + }); + + const formData = await event.request.formData(); + const result = validateLearningUnit(formData); + if (!result.success) { + return fail(400, { errors: result.errors }); + } + + let contentURL: string; + try { + contentURL = await uploadPodcastObject(result.data.podcastFile, nanoid()); + } catch (err) { + logger.error({ err }, 'Podcast upload failed'); + throw error(500); + } + + const learningUnitCreateArgs = { + data: { + title: result.data.title, + contentType: result.data.contentType, + contentURL, + summary: result.data.summary, + objectives: result.data.objectives, + createdBy: result.data.createdBy, + collection: { + connect: { id: result.data.collectionId }, + }, + isRecommended: result.data.isRecommended, + isRequired: result.data.isRequired, + dueDate: result.data.dueDate, + tags: { + create: result.data.tagIds.map((tagId) => ({ + tagId, + })), + }, + sources: { + create: result.data.sources.map((source) => ({ + title: source.title, + sourceURL: source.sourceURL, + tags: source.tagId + ? { + create: { + tagId: source.tagId, + }, + } + : undefined, + })), + }, + questionAnswers: { + create: result.data.questionAnswers.map((q, i) => ({ + question: q.question, + options: q.options, + answer: q.answer, + explanation: q.explanation, + order: i + 1, + })), + }, + }, + select: { id: true }, + } satisfies LearningUnitCreateArgs; + + let learningUnit: LearningUnitGetPayload; + try { + learningUnit = await db.learningUnit.create(learningUnitCreateArgs); + } catch (err) { + logger.error({ err }, 'Failed to create learning unit'); + throw error(500); + } + logger.info({ learningUnitId: learningUnit.id }, 'Learning unit created successfully'); + + redirect(303, '/admin'); + }, +} satisfies Actions; diff --git a/src/routes/admin/unit/new/+page.svelte b/src/routes/admin/unit/new/+page.svelte new file mode 100644 index 00000000..cc372c63 --- /dev/null +++ b/src/routes/admin/unit/new/+page.svelte @@ -0,0 +1,295 @@ + + +
+
+ Create Learning Unit + Add a new learning unit to the system +
+ +
+
+
+ + + + + + + + + + + + + + + + + + +