- {/* Page header */}
-
-
- {t('sources.title')}
-
-
- {t('sources.subtitle')}
-
-
-
- {/* Section 1: Add a source */}
-
-
-
- {t('sources.addTitle')}
-
-
-
- {(msg) => (
- {msg()}
- )}
-
-
-
-
- {/* Section 2: CSV Import / Export */}
-
-
-
- {t('sources.csvSection')}
-
-
- {t('sources.csvDescription')}
-
-
-
-
-
-
- {(msg) => (
- {msg()}
- )}
-
-
-
-
- {/* Section 3: Bulk Import */}
-
-
-
- {t('sources.bulkSection')}
-
-
- {t('sources.bulkDescription')}{' '}
- {t('sources.bulkFormat')}
-
-
-
-
-
- {/* Section 4: Source list */}
-
-
- 0}
- fallback={
- -
-
{t('sources.empty')}
- {t('sources.emptyHint')}
-
- }
- >
-
- {(source) => (
- -
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
- );
-};
-
-export default Sources;
diff --git a/frontend/src/pages/ThemeManager.tsx b/frontend/src/pages/ThemeManager.tsx
index 601ccce..44caefe 100644
--- a/frontend/src/pages/ThemeManager.tsx
+++ b/frontend/src/pages/ThemeManager.tsx
@@ -20,7 +20,7 @@ import Button from '~/components/ui/Button';
import { themesApi } from '~/api/themes';
import type { ThemeResponse, CreateThemeRequest, UpdateThemeRequest } from '~/api/themes';
import { sourcesApi } from '~/api/sources';
-import { normalizeUrl, isValidUrl } from '~/pages/Sources';
+import { normalizeUrl, isValidUrl } from '~/utils/url';
import { useI18n } from '~/i18n';
import { isApiError } from '~/types';
import type { Source } from '~/types';
diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts
new file mode 100644
index 0000000..5481c30
--- /dev/null
+++ b/frontend/src/utils/url.ts
@@ -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;
+ }
+}