diff --git a/src/components/FeedDirectory.astro b/src/components/FeedDirectory.astro index f9b88fb6..25af5943 100644 --- a/src/components/FeedDirectory.astro +++ b/src/components/FeedDirectory.astro @@ -1,142 +1,186 @@ --- import { configs } from "../data/loadConfigs"; -import { Icon, LinkButton } from "@astrojs/starlight/components"; - -// Simple helper functions -function getFeedUrl( - config: { - domain: string; - name: string; - url_parameters?: Record; - }, - instanceUrl: string, - params: Record = {}, -) { - const baseUrl = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`; - let url = `${baseUrl}${config.domain}/${config.name}.rss`; - - const queryParams = new URLSearchParams(); - Object.keys(config.url_parameters || {}).forEach((key) => { - if (params[key]) queryParams.append(key, params[key]); - }); - - const queryString = queryParams.toString(); - if (queryString) url += `?${queryString}`; - return url; +import { Icon } from "@astrojs/starlight/components"; + +const feedCount = configs.length; + +function formatDefaultParameters(defaultParameters: Record = {}) { + return Object.entries(defaultParameters) + .filter(([, value]) => value) + .map(([key, value]) => `${key}=${value}`) + .join(", "); } -// Don't generate static URLs to avoid exposing instance URL in build const staticFeedUrls = configs.map((config) => ({ ...config, - staticFeedUrl: "#", // Placeholder that will be updated by JavaScript + staticFeedUrl: "#", + defaultSummary: formatDefaultParameters(config.default_parameters), + sourceSummary: config.channel?.url + ?.replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/\/$/, ""), })); --- -
- -
- - - - - +
+
+
+ +
+ + +
+

+ Search across + {feedCount} + ready-to-use feeds +

+
+ +
+
+ Using instance: + 1.h2r.workers.dev + +
+ +
+
+ + + +
+

+ Feed links update when you apply a valid instance URL. +

+
+
+
+ + -
+
{ staticFeedUrls.map((config, index) => ( -
-
-
-
- <> - {config.domain} - / - {config.name} - +
+
+

{config.sourceSummary || `${config.domain}/${config.name}`}

+ + +
+ +
+ {config.defaultSummary ? ( +

Defaults: {config.defaultSummary}

+ ) : ( +
- {!config.valid_channel_url && Object.keys(config.url_parameters || {}).length > 0 && ( + {Object.keys(config.url_parameters || {}).length > 0 && ( ))} +
-
)} -
+ )) }
-
+
- diff --git a/src/components/feed-directory.js b/src/components/feed-directory.js index d7d42f07..d9e24f45 100644 --- a/src/components/feed-directory.js +++ b/src/components/feed-directory.js @@ -1,7 +1,5 @@ // Feed Directory JavaScript functionality -// Simple, focused functions for maintainability -// Simple debounce helper function debounce(func, wait) { let timeout; return function executedFunction(...args) { @@ -14,7 +12,78 @@ function debounce(func, wait) { }; } -// Simple fuzzy search +function getDefaultInstanceUrl() { + return atob('aHR0cHM6Ly8xLmgyci53b3JrZXJzLmRldi8='); +} + +function getStorageKey() { + return 'html2rss.feedDirectory.instanceUrl'; +} + +function getHashParams() { + const hash = window.location.hash || ''; + if (!hash.startsWith('#!')) return new URLSearchParams(); + return new URLSearchParams(hash.slice(2)); +} + +function normalizeParsedInstanceUrl(parsedUrl) { + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return null; + } + + parsedUrl.search = ''; + parsedUrl.hash = ''; + return parsedUrl.toString(); +} + +function readInstanceUrlFromHash(defaultInstanceUrl) { + const candidate = getHashParams().get('url'); + if (!candidate) return defaultInstanceUrl; + + try { + return normalizeParsedInstanceUrl(new URL(candidate)) || defaultInstanceUrl; + } catch { + return defaultInstanceUrl; + } +} + +function readInstanceUrlFromStorage(defaultInstanceUrl) { + try { + const candidate = window.localStorage.getItem(getStorageKey()); + if (!candidate) return defaultInstanceUrl; + return normalizeInstanceUrl(candidate) || defaultInstanceUrl; + } catch { + return defaultInstanceUrl; + } +} + +function writeInstanceUrl(instanceUrl, defaultInstanceUrl) { + try { + if (instanceUrl && instanceUrl !== defaultInstanceUrl) { + window.localStorage.setItem(getStorageKey(), instanceUrl); + } else { + window.localStorage.removeItem(getStorageKey()); + } + } catch { + // Ignore storage failures and keep the current page usable. + } + + if (window.location.hash.startsWith('#!')) { + const nextUrl = `${window.location.pathname}${window.location.search}`; + window.history.replaceState({}, '', nextUrl); + } +} + +function readInitialInstanceUrl(defaultInstanceUrl) { + const hashInstanceUrl = readInstanceUrlFromHash(defaultInstanceUrl); + if (hashInstanceUrl !== defaultInstanceUrl) { + writeInstanceUrl(hashInstanceUrl, defaultInstanceUrl); + return hashInstanceUrl; + } + + return readInstanceUrlFromStorage(defaultInstanceUrl); +} + function fuzzyMatch(text, query) { if (!query) return true; const lowerText = text.toLowerCase(); @@ -28,150 +97,288 @@ function fuzzyMatch(text, query) { return queryIndex === lowerQuery.length; } -// Search functionality +function formatInstanceLabel(instanceUrl) { + try { + const parsedUrl = new URL(instanceUrl); + return parsedUrl.host + parsedUrl.pathname.replace(/\/$/, ''); + } catch { + return instanceUrl; + } +} + +function normalizeInstanceUrl(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + return normalizeParsedInstanceUrl(new URL(trimmed)); + } catch { + return null; + } +} + +function updateInstanceSummary(instanceUrl) { + const host = document.querySelector('[data-instance-host]'); + if (host) { + host.textContent = formatInstanceLabel(instanceUrl); + } +} + +function setInstanceFeedback(message, state) { + const feedback = document.querySelector('[data-instance-feedback]'); + if (!feedback) return; + feedback.textContent = message; + feedback.dataset.state = state; +} + +function createUpdateFeedUrlsFunction() { + return function updateFeedUrls(instanceUrl) { + document.querySelectorAll('[data-feed-url]').forEach((link) => { + const item = link.closest('[data-domain]'); + if (!item) return; + + const domain = item.dataset.domain; + const name = item.dataset.name; + if (!domain || !name) return; + + const params = {}; + item.querySelectorAll('[data-param-key]').forEach((input) => { + if (input.value) params[input.dataset.paramKey] = input.value; + }); + + let url = instanceUrl.endsWith('/') ? instanceUrl : `${instanceUrl}/`; + url += `${domain}/${name}.rss`; + + const queryParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => queryParams.append(key, value)); + const queryString = queryParams.toString(); + if (queryString) url += `?${queryString}`; + + link.href = url; + }); + }; +} + +function updateSearchState(feedItems, query) { + let visibleCount = 0; + feedItems.forEach((item) => { + const searchableText = item.dataset.searchable?.toLowerCase() || ''; + const matches = fuzzyMatch(searchableText, query); + item.hidden = !matches; + if (matches) visibleCount++; + }); + + const resultCount = document.querySelector('[data-result-count]'); + const resultLabel = document.querySelector('[data-result-label]'); + const emptyState = document.querySelector('[data-empty-state]'); + const emptyCopy = document.querySelector('[data-empty-copy]'); + const feedList = document.querySelector('[data-feed-list]'); + + if (resultCount) { + resultCount.textContent = String(visibleCount); + } + + if (resultLabel) { + const hasQuery = Boolean(query.trim()); + if (hasQuery) { + resultLabel.textContent = visibleCount === 1 ? 'matching feed' : 'matching feeds'; + } else { + resultLabel.textContent = visibleCount === 1 ? 'ready-to-use feed' : 'ready-to-use feeds'; + } + } + + if (emptyState && emptyCopy && feedList) { + const hasNoResults = visibleCount === 0; + emptyState.hidden = !hasNoResults; + feedList.hidden = hasNoResults; + if (hasNoResults) { + emptyCopy.textContent = query + ? `No configurations found for "${query}". Try another domain or feed name, check the community wiki, or contribute a new configuration.` + : 'Try a different domain or feed name, or contribute a new configuration.'; + } + } +} + function setupSearch(searchInput, feedItems) { if (!searchInput) return; - searchInput.addEventListener( - 'input', - debounce((e) => { - const query = e.target.value.toLowerCase(); - feedItems.forEach((item) => { - const searchableText = item.dataset.searchable?.toLowerCase() || ''; - item.style.display = fuzzyMatch(searchableText, query) ? 'flex' : 'none'; - }); - }, 150) - ); + const applySearch = debounce((query) => { + updateSearchState(feedItems, query.toLowerCase()); + }, 120); + + searchInput.addEventListener('input', (event) => { + applySearch(event.target.value); + }); + + updateSearchState(feedItems, searchInput.value.toLowerCase()); } -// Instance URL updates -function setupInstanceUrlUpdates(instanceInput, defaultInstanceUrl, updateFeedUrls) { - if (!instanceInput) return; +function setupInstanceEditor( + defaultInstanceUrl, + getCurrentInstanceUrl, + setCurrentInstanceUrl, + updateFeedUrls +) { + const toggle = document.querySelector('[data-toggle-instance]'); + const editor = document.getElementById('instance-editor'); + const input = document.getElementById('instance-url-input'); + const apply = document.querySelector('[data-apply-instance]'); + if (!toggle || !editor || !input || !apply) return; - instanceInput.addEventListener( - 'input', - debounce((e) => { - const instanceUrl = e.target.value || defaultInstanceUrl; - updateFeedUrls(instanceUrl); - }, 300) - ); + const directory = document.querySelector('[data-feed-directory]'); + if (directory) { + directory.dataset.enhanced = 'true'; + } + + const setExpanded = (expanded) => { + editor.hidden = !expanded; + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.textContent = expanded ? 'Close' : 'Change'; + }; + + toggle.addEventListener('click', () => { + const nextExpanded = editor.hidden; + setExpanded(nextExpanded); + if (nextExpanded) { + input.value = getCurrentInstanceUrl(); + setInstanceFeedback('Feed links update when you apply a valid instance URL.', 'idle'); + input.focus(); + input.select(); + } + }); + + const applyInstance = () => { + const normalized = normalizeInstanceUrl(input.value); + if (!normalized) { + setInstanceFeedback('Enter a valid URL.', 'error'); + return; + } + + setCurrentInstanceUrl(normalized); + input.value = normalized; + updateInstanceSummary(normalized); + writeInstanceUrl(normalized, defaultInstanceUrl); + updateFeedUrls(normalized); + setExpanded(false); + setInstanceFeedback('Using your custom instance.', 'success'); + }; + + setExpanded(false); + + apply.addEventListener('click', applyInstance); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + applyInstance(); + } + }); } -// Parameter forms toggle -function setupParameterForms(updateFeedUrls) { - document.querySelectorAll('.configure-button').forEach((button) => { - button.addEventListener('click', (e) => { - const targetId = e.target.closest('button')?.dataset.target; - const form = document.getElementById(targetId); +function setupParameterForms(updateFeedUrls, getCurrentInstanceUrl) { + document.querySelectorAll('[data-target]').forEach((button) => { + button.addEventListener('click', (event) => { + const sourceButton = event.currentTarget; + const form = document.getElementById(sourceButton.dataset.target); if (!form) return; const isExpanded = !form.hidden; form.hidden = isExpanded; - const button = e.target.closest('button'); - if (button) { - button.setAttribute('aria-expanded', !isExpanded); - const span = button.querySelector('span'); - if (span) { - span.textContent = isExpanded ? 'Configure' : 'Hide'; - } + sourceButton.setAttribute('aria-expanded', String(!isExpanded)); + const label = sourceButton.querySelector('span'); + if (label) { + label.textContent = isExpanded ? 'Customize' : 'Close'; } - if (!isExpanded) updateFeedUrls(); + if (!isExpanded) { + updateFeedUrls(getCurrentInstanceUrl()); + } }); }); } -// Close forms function setupCloseForms() { document.querySelectorAll('[data-close-form]').forEach((button) => { - button.addEventListener('click', (e) => { - const form = e.target.closest('.parameter-form'); + button.addEventListener('click', (event) => { + const form = event.currentTarget.closest('.parameter-form'); const toggle = document.querySelector(`[data-target="${form?.id}"]`); if (!form || !toggle) return; form.hidden = true; toggle.setAttribute('aria-expanded', 'false'); - const span = toggle.querySelector('span'); - if (span) { - span.textContent = 'Configure'; + const label = toggle.querySelector('span'); + if (label) { + label.textContent = 'Customize'; } }); }); } -// Parameter input updates -function setupParameterInputs(updateFeedUrls) { +function setupParameterInputs(updateFeedUrls, getCurrentInstanceUrl) { document.querySelectorAll('.form-input').forEach((input) => { input.addEventListener( 'input', - debounce(() => updateFeedUrls(), 200) + debounce(() => updateFeedUrls(getCurrentInstanceUrl()), 180) ); }); } -// Update feed URLs -function createUpdateFeedUrlsFunction() { - return function updateFeedUrls(instanceUrl) { - document.querySelectorAll('[data-feed-url]').forEach((link) => { - const item = link.closest('[data-domain]'); - if (!item) return; - - const domain = item.dataset.domain; - const name = item.dataset.name; - if (!domain || !name) return; - - // Get parameters - const params = {}; - item.querySelectorAll('[data-param-key]').forEach((input) => { - if (input.value) params[input.dataset.paramKey] = input.value; - }); - - // Build URL - let url = instanceUrl.endsWith('/') ? instanceUrl : `${instanceUrl}/`; - url += `${domain}/${name}.rss`; +function setupCopyButtons() { + document.querySelectorAll('[data-copy-feed]').forEach((button) => { + button.addEventListener('click', async () => { + const feedLink = button.closest('[data-domain]')?.querySelector('[data-feed-url]'); + if (!feedLink?.href) return; - const queryParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => queryParams.append(key, value)); - const queryString = queryParams.toString(); - if (queryString) url += `?${queryString}`; - - link.href = url; + try { + await navigator.clipboard.writeText(feedLink.href); + const label = button.querySelector('span'); + button.dataset.copied = 'true'; + if (label) label.textContent = 'Copied'; + window.setTimeout(() => { + button.dataset.copied = 'false'; + if (label) label.textContent = 'Copy link'; + }, 1400); + } catch { + const label = button.querySelector('span'); + if (label) label.textContent = 'Copy failed'; + window.setTimeout(() => { + if (label) label.textContent = 'Copy link'; + }, 1400); + } }); - }; + }); } -// Main initialization function initializeFeedDirectory() { - const defaultInstanceUrl = atob('aHR0cHM6Ly8xLmgyci53b3JrZXJzLmRldi8='); - let instanceUrl = defaultInstanceUrl; + const defaultInstanceUrl = getDefaultInstanceUrl(); + let currentInstanceUrl = readInitialInstanceUrl(defaultInstanceUrl); const searchInput = document.getElementById('search-input'); - const feedItems = document.querySelectorAll('[data-domain]'); + const feedItems = Array.from(document.querySelectorAll('[data-domain]')); const instanceInput = document.getElementById('instance-url-input'); + const updateFeedUrls = createUpdateFeedUrlsFunction(); + + updateInstanceSummary(currentInstanceUrl); - // Initialize instance URL field if (instanceInput) { - instanceInput.value = defaultInstanceUrl; - instanceUrl = defaultInstanceUrl; + instanceInput.value = currentInstanceUrl; } - // Create update function with current instance URL - const updateFeedUrls = createUpdateFeedUrlsFunction(); - - // Setup all functionality setupSearch(searchInput, feedItems); - setupInstanceUrlUpdates(instanceInput, defaultInstanceUrl, (newUrl) => { - instanceUrl = newUrl; - updateFeedUrls(instanceUrl); - }); - setupParameterForms(() => updateFeedUrls(instanceUrl)); + setupInstanceEditor( + defaultInstanceUrl, + () => currentInstanceUrl, + (nextUrl) => { + currentInstanceUrl = nextUrl; + }, + updateFeedUrls + ); + setupParameterForms(updateFeedUrls, () => currentInstanceUrl); setupCloseForms(); - setupParameterInputs(() => updateFeedUrls(instanceUrl)); + setupParameterInputs(updateFeedUrls, () => currentInstanceUrl); + setupCopyButtons(); - // Initialize feed URLs on page load - updateFeedUrls(instanceUrl); + updateFeedUrls(currentInstanceUrl); } -// Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeFeedDirectory); } else { diff --git a/src/content/docs/feed-directory/index.mdx b/src/content/docs/feed-directory/index.mdx index e0a3bf94..4b75c0c1 100644 --- a/src/content/docs/feed-directory/index.mdx +++ b/src/content/docs/feed-directory/index.mdx @@ -8,21 +8,15 @@ head: content: noindex --- -This directory contains a list of pre-built configurations to create RSS feeds for various websites. - -## Instance URL - -An Instance URL is the address of a running `html2rss-web` application. You can use a public instance, but we encourage you to host your own. - -[🚀 Host Your Own Instance (and share it!)](/web-application/how-to/deployment) +import FeedDirectory from "../../../components/FeedDirectory.astro"; -Find more public instances on the [community-run wiki](https://github.com/html2rss/html2rss-web/wiki/Instances). + --- -import FeedDirectory from "../../../components/FeedDirectory.astro"; +Need a different instance? You can use the built-in default, self-host your own, or find more options on the [community-run wiki](https://github.com/html2rss/html2rss-web/wiki/Instances). - +[🚀 Host Your Own Instance (and share it!)](/web-application/how-to/deployment) --- @@ -30,4 +24,4 @@ import FeedDirectory from "../../../components/FeedDirectory.astro"; The feed configurations in this directory are community-driven. If you've created a new feed configuration, we encourage you to share it with the community. -[Contribute on GitHub](https://github.com/html2rss/html2rss-configs) +[Contribute on GitHub](https://github.com/html2rss/html2rss-configs/tree/master/lib/html2rss/configs)