summaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/+layout.svelte28
-rw-r--r--src/routes/+layout.ts10
-rw-r--r--src/routes/+page.svelte21
-rw-r--r--src/routes/search/+page.svelte322
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>