refactor: delete dead Sources.tsx, move URL utils to utils/url.ts
normalizeUrl and isValidUrl are now in ~/utils/url. ThemeManager and sources-utils tests import from the new location. The /sources route redirect to /themes is preserved in App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
c51a99051f
commit
381e11a5e4
@ -1,240 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@solidjs/testing-library';
|
||||
import { renderWithProviders } from '../test-utils';
|
||||
import type { Source, BulkImportResponse } from '~/types';
|
||||
import { MOCK_SOURCES } from '../fixtures';
|
||||
|
||||
// Mock the sources API module
|
||||
vi.mock('~/api/sources', () => ({
|
||||
sourcesApi: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
bulkImport: vi.fn(),
|
||||
importCsv: vi.fn(),
|
||||
exportCsv: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Also mock syntheses since sources imports from it (fetchFile, triggerDownload)
|
||||
vi.mock('~/api/syntheses', () => ({
|
||||
synthesesApi: {
|
||||
list: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
generate: vi.fn(),
|
||||
progressUrl: vi.fn(),
|
||||
},
|
||||
fetchFile: vi.fn(),
|
||||
triggerDownload: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sourcesApi } from '~/api/sources';
|
||||
import Sources from '~/pages/Sources';
|
||||
|
||||
const mockedList = vi.mocked(sourcesApi.list);
|
||||
const mockedCreate = vi.mocked(sourcesApi.create);
|
||||
const mockedRemove = vi.mocked(sourcesApi.remove);
|
||||
const mockedBulkImport = vi.mocked(sourcesApi.bulkImport);
|
||||
const mockedExportCsv = vi.mocked(sourcesApi.exportCsv);
|
||||
|
||||
// Sources page tests use specific titles that the UI renders, so we define
|
||||
// page-specific sources based on the shared fixture shape.
|
||||
const pageSources: Source[] = [
|
||||
{ ...MOCK_SOURCES[0], id: 'src1', title: 'OpenAI Blog', url: 'https://openai.com/blog', created_at: '2026-03-01T10:00:00Z' },
|
||||
{ ...MOCK_SOURCES[1], id: 'src2', title: 'Google AI Blog', url: 'https://ai.googleblog.com', created_at: '2026-03-02T10:00:00Z' },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Sources Page', () => {
|
||||
it('should render source list from API data', async () => {
|
||||
mockedList.mockResolvedValue(pageSources);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('OpenAI Blog')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Google AI Blog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no sources', async () => {
|
||||
mockedList.mockResolvedValue([]);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Aucune source personnalisee pour le moment.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate empty title on add form submit', async () => {
|
||||
mockedList.mockResolvedValue([]);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ajouter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill URL but not title
|
||||
const urlInput = screen.getByPlaceholderText('https://...');
|
||||
fireEvent.input(urlInput, { target: { value: 'https://example.com' } });
|
||||
|
||||
const addButton = screen.getByText('Ajouter');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Le titre est requis.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate empty URL on add form submit', async () => {
|
||||
mockedList.mockResolvedValue([]);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ajouter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(
|
||||
'Nom de la source (ex: Blog de Yann LeCun)',
|
||||
);
|
||||
fireEvent.input(titleInput, { target: { value: 'My Blog' } });
|
||||
|
||||
const addButton = screen.getByText('Ajouter');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("L'URL est requise.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API and refresh list when add form is submitted', async () => {
|
||||
const newSource: Source = {
|
||||
id: 'src-new',
|
||||
user_id: 'u1',
|
||||
title: 'New Blog',
|
||||
url: 'https://newblog.com',
|
||||
is_preferred: false,
|
||||
created_at: '2026-03-20T10:00:00Z',
|
||||
};
|
||||
mockedCreate.mockResolvedValue(newSource);
|
||||
// First call returns empty, second call after create returns the new source
|
||||
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce([newSource]);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ajouter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(
|
||||
'Nom de la source (ex: Blog de Yann LeCun)',
|
||||
);
|
||||
const urlInput = screen.getByPlaceholderText('https://...');
|
||||
|
||||
fireEvent.input(titleInput, { target: { value: 'New Blog' } });
|
||||
fireEvent.input(urlInput, { target: { value: 'https://newblog.com' } });
|
||||
|
||||
const addButton = screen.getByText('Ajouter');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCreate).toHaveBeenCalledWith({
|
||||
title: 'New Blog',
|
||||
url: 'https://newblog.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle delete with confirmation flow', async () => {
|
||||
mockedRemove.mockResolvedValue(undefined);
|
||||
// First call returns all sources, second call after delete returns one
|
||||
mockedList
|
||||
.mockResolvedValueOnce(pageSources)
|
||||
.mockResolvedValueOnce([pageSources[1]]);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('OpenAI Blog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// First click on delete for first source
|
||||
const deleteButtons = screen.getAllByTitle('Supprimer');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Confirmer ?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second click to confirm
|
||||
const confirmBtn = screen.getByText('Confirmer ?');
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedRemove).toHaveBeenCalledWith('src1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger bulk import API call', async () => {
|
||||
const bulkResponse: BulkImportResponse = {
|
||||
imported: 2,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
mockedBulkImport.mockResolvedValue(bulkResponse);
|
||||
mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce(pageSources);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Importer les sources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The textarea has id="bulk-import"
|
||||
const textarea = document.getElementById('bulk-import') as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
fireEvent.input(textarea, {
|
||||
target: {
|
||||
value: 'OpenAI Blog;https://openai.com/blog\nGoogle AI Blog;https://ai.googleblog.com',
|
||||
},
|
||||
});
|
||||
|
||||
const importButton = screen.getByText('Importer les sources');
|
||||
fireEvent.click(importButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedBulkImport).toHaveBeenCalledWith({
|
||||
sources: [
|
||||
{ title: 'OpenAI Blog', url: 'https://openai.com/blog' },
|
||||
{ title: 'Google AI Blog', url: 'https://ai.googleblog.com' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger CSV export download', async () => {
|
||||
mockedList.mockResolvedValue(pageSources);
|
||||
mockedExportCsv.mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(() => <Sources />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Exporter en CSV')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const exportButton = screen.getByText('Exporter en CSV');
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedExportCsv).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,481 +0,0 @@
|
||||
import {
|
||||
type Component,
|
||||
createSignal,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Show,
|
||||
For,
|
||||
} from 'solid-js';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Link as LinkIcon,
|
||||
Download,
|
||||
Upload,
|
||||
} from 'lucide-solid';
|
||||
import Button from '~/components/ui/Button';
|
||||
import { sourcesApi } from '~/api/sources';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { isApiError } from '~/types';
|
||||
import type { Source } from '~/types';
|
||||
import LoadingSpinner from '~/components/ui/LoadingSpinner';
|
||||
|
||||
/**
|
||||
* Prepend https:// if the URL has no scheme.
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
// Strip smart quotes, zero-width chars, and other invisible formatting
|
||||
// that browsers or rich-text editors inject when copy-pasting URLs
|
||||
const cleaned = url
|
||||
.trim()
|
||||
.replace(/[\u200B-\u200D\uFEFF\u00A0]/g, '') // zero-width & non-breaking spaces
|
||||
.replace(/^[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+/, '') // leading quotes
|
||||
.replace(/[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+$/, '') // trailing quotes
|
||||
.trim();
|
||||
if (!cleaned) return cleaned;
|
||||
if (
|
||||
!cleaned.startsWith('http://') &&
|
||||
!cleaned.startsWith('https://')
|
||||
) {
|
||||
return 'https://' + cleaned;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic URL validation: must start with http(s) and have a dot in the host.
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
parsed.hostname.includes('.')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources management page for adding, deleting, and bulk-importing custom URLs.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - **Bulk import parsing**: The textarea accepts one source per line in
|
||||
* `title;url` format. Lines are split on the first semicolon; the URL
|
||||
* portion is normalized via {@link normalizeUrl}.
|
||||
* - **CSV flow**: Export downloads a CSV via the backend; import uploads a
|
||||
* `.csv` file as `multipart/form-data`. The file input is reset after
|
||||
* each import so the same file can be re-selected.
|
||||
* - **URL normalization**: {@link normalizeUrl} prepends `https://` when no
|
||||
* scheme is present. {@link isValidUrl} then validates the result.
|
||||
*/
|
||||
const Sources: Component = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// ---- State ----
|
||||
const [sources, setSources] = createSignal<Source[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [newTitle, setNewTitle] = createSignal('');
|
||||
const [newUrl, setNewUrl] = createSignal('');
|
||||
const [adding, setAdding] = createSignal(false);
|
||||
const [addError, setAddError] = createSignal<string | null>(null);
|
||||
const [bulkText, setBulkText] = createSignal('');
|
||||
const [importing, setImporting] = createSignal(false);
|
||||
const [importError, setImportError] = createSignal<string | null>(null);
|
||||
const [csvError, setCsvError] = createSignal<string | null>(null);
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = createSignal<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
let deleteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let fileInputRef: HTMLInputElement | undefined;
|
||||
|
||||
onCleanup(() => {
|
||||
if (deleteTimer) clearTimeout(deleteTimer);
|
||||
});
|
||||
|
||||
// ---- Data loading ----
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
const data = await sourcesApi.list();
|
||||
setSources(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sources:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(fetchSources);
|
||||
|
||||
// ---- Add a single source ----
|
||||
const handleAddSource = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
setAddError(null);
|
||||
|
||||
const title = newTitle().trim();
|
||||
const rawUrl = newUrl().trim();
|
||||
|
||||
if (!title) {
|
||||
setAddError(t('sources.titleRequired'));
|
||||
return;
|
||||
}
|
||||
if (!rawUrl) {
|
||||
setAddError(t('sources.urlRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = normalizeUrl(rawUrl);
|
||||
if (!isValidUrl(url)) {
|
||||
setAddError(t('sources.urlInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
await sourcesApi.create({ title, url });
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
await fetchSources();
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setAddError(err.message);
|
||||
} else {
|
||||
setAddError(t('sources.addError'));
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Delete with confirmation ----
|
||||
const handleDeleteClick = (id: string) => {
|
||||
if (confirmingDeleteId() === id) {
|
||||
// Second click: delete
|
||||
performDelete(id);
|
||||
} else {
|
||||
// First click: enter confirm state
|
||||
setConfirmingDeleteId(id);
|
||||
if (deleteTimer) clearTimeout(deleteTimer);
|
||||
deleteTimer = setTimeout(() => {
|
||||
setConfirmingDeleteId(null);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const performDelete = async (id: string) => {
|
||||
if (deleteTimer) clearTimeout(deleteTimer);
|
||||
setConfirmingDeleteId(null);
|
||||
|
||||
try {
|
||||
await sourcesApi.remove(id);
|
||||
await fetchSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete source:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- CSV Export ----
|
||||
const handleExportCsv = async () => {
|
||||
setCsvError(null);
|
||||
try {
|
||||
await sourcesApi.exportCsv();
|
||||
} catch (err) {
|
||||
setCsvError(t('sources.exportError'));
|
||||
}
|
||||
};
|
||||
|
||||
// ---- CSV Import ----
|
||||
const handleImportCsv = async (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
setCsvError(null);
|
||||
|
||||
try {
|
||||
await sourcesApi.importCsv(file);
|
||||
await fetchSources();
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setCsvError(err.message);
|
||||
} else {
|
||||
setCsvError(t('sources.csvImportError'));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
// Reset the file input so the same file can be re-selected
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Bulk Import ----
|
||||
const handleBulkImport = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!bulkText().trim()) return;
|
||||
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
const lines = bulkText()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
const validSources: { title: string; url: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(';');
|
||||
if (parts.length >= 2) {
|
||||
const title = parts[0].trim();
|
||||
const url = normalizeUrl(parts.slice(1).join(';').trim());
|
||||
if (title && url) {
|
||||
validSources.push({ title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validSources.length === 0) {
|
||||
setImportError(t('sources.bulkImportError'));
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sourcesApi.bulkImport({ sources: validSources });
|
||||
setBulkText('');
|
||||
await fetchSources();
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setImportError(err.message);
|
||||
} else {
|
||||
setImportError(t('sources.bulkImportError'));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Render ----
|
||||
return (
|
||||
<Show when={!loading()} fallback={<LoadingSpinner />}>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Page header */}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
{t('sources.title')}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
{t('sources.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Add a source */}
|
||||
<div class="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
{t('sources.addTitle')}
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={handleAddSource}
|
||||
class="space-y-4 sm:flex sm:space-y-0 sm:space-x-4"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label for="source-title" class="sr-only">
|
||||
{t('sources.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="source-title"
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder={t('sources.titlePlaceholder')}
|
||||
value={newTitle()}
|
||||
onInput={(e) => setNewTitle(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label for="source-url" class="sr-only">
|
||||
{t('sources.urlLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="source-url"
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder={t('sources.urlPlaceholder')}
|
||||
value={newUrl()}
|
||||
onInput={(e) => setNewUrl(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={adding()}
|
||||
icon={Plus}
|
||||
>
|
||||
{t('sources.add')}
|
||||
</Button>
|
||||
</form>
|
||||
<Show when={addError()}>
|
||||
{(msg) => (
|
||||
<p class="mt-2 text-sm text-red-600">{msg()}</p>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: CSV Import / Export */}
|
||||
<div class="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
{t('sources.csvSection')}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{t('sources.csvDescription')}
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
{t('sources.exportCsv')}
|
||||
</button>
|
||||
<label class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer">
|
||||
<Upload class="h-4 w-4 mr-2" />
|
||||
{t('sources.importCsv')}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".csv"
|
||||
onChange={handleImportCsv}
|
||||
disabled={importing()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Show when={csvError()}>
|
||||
{(msg) => (
|
||||
<p class="mt-2 text-sm text-red-600">{msg()}</p>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Bulk Import */}
|
||||
<div class="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
{t('sources.bulkSection')}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{t('sources.bulkDescription')}{' '}
|
||||
<strong>{t('sources.bulkFormat')}</strong>
|
||||
</p>
|
||||
<form onSubmit={handleBulkImport} class="space-y-4">
|
||||
<div>
|
||||
<label for="bulk-import" class="sr-only">
|
||||
{t('sources.bulkSection')}
|
||||
</label>
|
||||
<textarea
|
||||
id="bulk-import"
|
||||
rows={5}
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder={t('sources.bulkPlaceholder')}
|
||||
value={bulkText()}
|
||||
onInput={(e) => setBulkText(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={importError()}>
|
||||
{(msg) => (
|
||||
<p class="text-sm text-red-600">{msg()}</p>
|
||||
)}
|
||||
</Show>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={importing() || !bulkText().trim()}
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{importing()
|
||||
? t('sources.importing')
|
||||
: t('sources.bulkImport')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Source list */}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
<Show
|
||||
when={sources().length > 0}
|
||||
fallback={
|
||||
<li class="px-4 py-8 text-center text-gray-500">
|
||||
<p>{t('sources.empty')}</p>
|
||||
<p class="mt-1 text-xs">{t('sources.emptyHint')}</p>
|
||||
</li>
|
||||
}
|
||||
>
|
||||
<For each={sources()}>
|
||||
{(source) => (
|
||||
<li>
|
||||
<div class="px-4 py-4 flex items-center sm:px-6">
|
||||
<div class="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div class="truncate">
|
||||
<div class="flex text-sm">
|
||||
<p class="font-medium text-indigo-600 truncate">
|
||||
{source.title}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 flex">
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<LinkIcon class="flex-shrink-0 mr-1.5 h-4 w-4 text-gray-400" />
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate hover:underline"
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDeleteClick(source.id)}
|
||||
class={`p-2 transition-colors ${
|
||||
confirmingDeleteId() === source.id
|
||||
? 'text-red-600 bg-red-50 rounded-md'
|
||||
: 'text-gray-400 hover:text-red-600'
|
||||
}`}
|
||||
title={
|
||||
confirmingDeleteId() === source.id
|
||||
? t('sources.confirmDelete')
|
||||
: t('sources.deleteTitle')
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={confirmingDeleteId() === source.id}
|
||||
fallback={<Trash2 class="h-5 w-5" />}
|
||||
>
|
||||
<span class="text-xs font-medium">
|
||||
{t('sources.confirmDelete')}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sources;
|
||||
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Prepend https:// if the URL has no scheme.
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
// Strip smart quotes, zero-width chars, and other invisible formatting
|
||||
// that browsers or rich-text editors inject when copy-pasting URLs
|
||||
const cleaned = url
|
||||
.trim()
|
||||
.replace(/[\u200B-\u200D\uFEFF\u00A0]/g, '') // zero-width & non-breaking spaces
|
||||
.replace(/^[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+/, '') // leading quotes
|
||||
.replace(/[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+$/, '') // trailing quotes
|
||||
.trim();
|
||||
if (!cleaned) return cleaned;
|
||||
if (
|
||||
!cleaned.startsWith('http://') &&
|
||||
!cleaned.startsWith('https://')
|
||||
) {
|
||||
return 'https://' + cleaned;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic URL validation: must start with http(s) and have a dot in the host.
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
parsed.hostname.includes('.')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue