diff options
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/+layout.svelte | 28 | ||||
| -rw-r--r-- | src/routes/+layout.ts | 10 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 21 | ||||
| -rw-r--r-- | src/routes/search/+page.svelte | 322 |
4 files changed, 381 insertions, 0 deletions
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..cf931d8 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,28 @@ +<script lang="ts"> + import '../app.css'; + + import { selectedDomain } from '$lib/stores/mirror'; + import type { Snippet } from 'svelte'; + import type { LayoutData } from './$types'; + + import Footer from '$lib/components/Footer.svelte'; + + interface Props { + children: Snippet; + data: LayoutData; + } + + let { children, data }: Props = $props(); + + let { source, commitLink, latestCommitHash, domains } = $derived(data); + + $effect(() => { + if (domains && domains.length > 0) { + selectedDomain.update((d) => d || domains[0]); + } + }); +</script> + +{@render children()} + +<Footer {source} {commitLink} {latestCommitHash} /> diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..fc5ca3f --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,10 @@ +import type { AppData } from '$lib/types'; + +export const prerender = true; + +export const load = async ({ fetch }) => { + const response = await fetch('/data.json'); + const data = (await response.json()) as AppData; + + return { ...data }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..5365d99 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import ExtensionCategory from '$lib/components/ExtensionCategory.svelte'; + import MirrorSelector from '$lib/components/MirrorSelector.svelte'; + import { selectedDomain } from '$lib/stores/mirror'; + + let { data } = $props(); + let { extensions, domains } = $derived(data); +</script> + +<div class="container"> + <div class="page-header"> + <h1>Mihon & Aniyomi Extensions</h1> + <a href="/search" class="btn btn-secondary header-btn"> Search </a> + </div> + + <MirrorSelector {domains} /> + + {#each Object.entries(extensions) as [category, repos]} + <ExtensionCategory {category} {repos} selectedDomain={$selectedDomain} /> + {/each} +</div> diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..2df902d --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,322 @@ +<script lang="ts"> + import { browser } from '$app/environment'; + import { goto } from '$app/navigation'; + import { page } from '$app/state'; + import { onMount } from 'svelte'; + + import ExtensionRow from '$lib/components/ExtensionRow.svelte'; + import { debounce } from '$lib/search/debounce.js'; + import { + getFilterOptions, + initMeilisearch, + searchExtensions, + transformMeilisearchHit + } from '$lib/search/meilisearch.js'; + import type { EnrichedExtension } from '$lib/search/types.js'; + import { findSourceByFormattedName, formatSourceName } from '$lib/search/utils.js'; + + // Component state (must be declared before derived state that uses them) + let loading = $state(true); + let error = $state<string | null>(null); + let results = $state<EnrichedExtension[]>([]); + let sources = $state<string[]>(['all']); + let categories = $state<string[]>(['all']); + let languages = $state<string[]>(['all']); + let currentPage = $state(1); + let totalPages = $state(1); + let totalHits = $state(0); + let resultsPerPage = $state(10); + let hasSearched = $state(false); + + // Derived state from URL parameters + let query = $derived(browser ? (page.url.searchParams.get('q') ?? '') : ''); + let selectedSource = $derived( + browser + ? findSourceByFormattedName(page.url.searchParams.get('source') ?? 'all', sources) + : 'all' + ); + let selectedCategory = $derived( + browser ? (page.url.searchParams.get('category') ?? 'all') : 'all' + ); + let selectedLanguage = $derived(browser ? (page.url.searchParams.get('lang') ?? 'all') : 'all'); + let showNSFW = $derived(browser ? page.url.searchParams.get('nsfw') !== '0' : true); + let pageParam = $derived(browser ? parseInt(page.url.searchParams.get('page') ?? '1') : 1); + + // URL parameter management + function updateParams(updates: Record<string, string | null>) { + const params = new URLSearchParams(page.url.searchParams); + + // Reset to page 1 if any search filter changed (not page parameter) + const filterChanges = Object.keys(updates).filter((key) => key !== 'page'); + if (filterChanges.length > 0) { + params.set('page', '1'); + } + + for (const [key, value] of Object.entries(updates)) { + if (value === null) params.delete(key); + else params.set(key, value); + } + goto(`?${params.toString()}`, { replaceState: true, keepFocus: true, noScroll: true }); + } + + // Initialize Meilisearch + onMount(async () => { + try { + const meiliConfig = { + host: import.meta.env.VITE_MEILISEARCH_HOST || '', + apiKey: import.meta.env.VITE_MEILISEARCH_DEFAULT_SEARCH_KEY + }; + + if (!meiliConfig.host) { + error = 'Meilisearch is not configured.'; + return; + } + + initMeilisearch(meiliConfig); + } catch (e) { + console.error(e); + error = 'Failed to initialize Meilisearch.'; + } finally { + loading = false; + } + }); + + // Debounced search with 300ms delay + const debouncedSearch = debounce( + ( + query: string, + source: string, + category: string, + lang: string, + nsfw: boolean, + page: number + ) => { + searchExtensions({ + query: query || undefined, + source: source !== 'all' ? formatSourceName(source) : undefined, + category: category !== 'all' ? category : undefined, + lang: lang !== 'all' ? lang : undefined, + nsfw: nsfw, + page, + limit: resultsPerPage + }) + .then((searchResults) => { + results = searchResults.hits.map(transformMeilisearchHit); + totalHits = searchResults.estimatedTotalHits || searchResults.hits.length; + totalPages = Math.ceil(totalHits / resultsPerPage); + currentPage = page; + hasSearched = true; + }) + .catch((err) => { + console.error('Meilisearch error:', err); + error = 'Search failed. Please try again.'; + hasSearched = true; + }) + .finally(() => { + loading = false; + }); + }, + 300 + ); + + // Reactive search effect + $effect(() => { + if (!browser) return; + + currentPage = pageParam; + + // Set loading immediately, then execute debounced search + loading = true; + debouncedSearch( + query, + selectedSource, + selectedCategory, + selectedLanguage, + showNSFW, + pageParam + ); + }); + + // Load filter options from Meilisearch + $effect(() => { + if (!browser) return; + getFilterOptions() + .then((options) => { + sources = [...new Set(['all', ...options.sources.sort()])]; + categories = [...new Set(['all', ...options.categories.sort()])]; + languages = [...new Set(['all', ...options.languages.sort()])]; + }) + .catch((err) => { + console.error('Failed to load filter options:', err); + }); + }); +</script> + +<div class="container"> + <div class="page-header"> + <h1>Search Extensions</h1> + <a href="/" class="btn btn-secondary header-btn"> Home </a> + </div> + <div class="search-container"> + <input + type="text" + class="search-input" + placeholder="Search by name or package..." + value={query} + oninput={(e) => updateParams({ q: e.currentTarget.value || null })} + /> + </div> + <div class="filter-bar"> + <div class="filter-group"> + <label for="category-filter">Category:</label> + <select + id="category-filter" + value={selectedCategory} + onchange={(e) => + updateParams({ + category: e.currentTarget.value === 'all' ? null : e.currentTarget.value + })} + > + {#each categories as category (category)} + <option value={category}> + {category} + </option> + {/each} + </select> + </div> + <div class="filter-group"> + <label for="source-filter">Source:</label> + <select + id="source-filter" + value={selectedSource} + onchange={(e) => { + const val = formatSourceName(e.currentTarget.value); + updateParams({ source: val === 'all' ? null : val }); + }} + > + {#each sources as source (source)} + <option value={source}> + {source} + </option> + {/each} + </select> + </div> + <div class="filter-group"> + <label for="language-filter">Language:</label> + <select + id="language-filter" + value={selectedLanguage} + onchange={(e) => + updateParams({ + lang: e.currentTarget.value === 'all' ? null : e.currentTarget.value + })} + > + {#each languages as lang (lang)} + <option value={lang}> + {lang} + </option> + {/each} + </select> + </div> + <div class="filter-group filter-checkbox"> + <label> + <input + type="checkbox" + checked={showNSFW} + onchange={(e) => updateParams({ nsfw: e.currentTarget.checked ? null : '0' })} + /> + <span>Show NSFW</span> + </label> + </div> + </div> + <div class="table-container"> + <table class="extensions-table"> + <thead> + <tr> + <th style="width: 60px;">Icon</th> + <th>Name / Package</th> + <th>Version / Lang</th> + <th style="width: 100px;">Action</th> + </tr> + </thead> + <tbody> + {#each results as ext (ext.formattedSourceName + ';' + ext.pkg)} + <ExtensionRow extension={ext} repoUrl={ext.repoUrl} /> + {/each} + </tbody> + </table> + </div> + {#if totalPages > 1} + {@const startPage = Math.max(1, currentPage - 2)} + {@const endPage = Math.min(totalPages, startPage + 4)} + <div class="pagination-container"> + <div class="pagination-info"> + Showing {Math.min((currentPage - 1) * resultsPerPage + 1, totalHits)} to {Math.min( + currentPage * resultsPerPage, + totalHits + )} of {totalHits} results + </div> + <div class="pagination-controls"> + <button + class="btn btn-secondary btn-sm" + disabled={currentPage === 1} + onclick={() => updateParams({ page: '1' })} + title="First page" + > + First + </button> + + <button + class="btn btn-secondary btn-sm" + disabled={currentPage === 1} + onclick={() => + updateParams({ + page: currentPage === 1 ? null : (currentPage - 1).toString() + })} + > + Previous + </button> + + <div class="page-numbers"> + {#each Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i) as pageNum} + <button + class="btn btn-sm {pageNum === currentPage + ? 'btn-primary' + : 'btn-secondary'}" + onclick={() => + updateParams({ page: pageNum === 1 ? null : pageNum.toString() })} + > + {pageNum} + </button> + {/each} + </div> + + <button + class="btn btn-secondary btn-sm" + disabled={currentPage === totalPages} + onclick={() => updateParams({ page: (currentPage + 1).toString() })} + > + Next + </button> + + <button + class="btn btn-secondary btn-sm" + disabled={currentPage === totalPages} + onclick={() => updateParams({ page: totalPages.toString() })} + title="Last page" + > + Last + </button> + </div> + </div> + {/if} + + {#if loading} + <div style="text-align: center; padding: 20px;">Loading extensions...</div> + {:else if results.length === 0 && hasSearched} + <div style="text-align: center; padding: 20px;">No results found.</div> + {/if} + {#if error} + <div style="text-align: center; margin-top: 50px; color: red;">{error}</div> + {/if} +</div> |
