diff options
| author | 2025-12-26 22:39:23 +0800 | |
|---|---|---|
| committer | 2025-12-26 22:39:23 +0800 | |
| commit | 32ca410f4edbff578d71781d943c41573912f476 (patch) | |
| tree | 49f7e1e5602657d23945082fe273fc4802959a40 /src | |
Initial commitmain
Diffstat (limited to '')
| -rw-r--r-- | src/app.css | 563 | ||||
| -rw-r--r-- | src/app.d.ts | 13 | ||||
| -rw-r--r-- | src/app.html | 13 | ||||
| -rw-r--r-- | src/lib/components/ExtensionCard.svelte | 47 | ||||
| -rw-r--r-- | src/lib/components/ExtensionCategory.svelte | 24 | ||||
| -rw-r--r-- | src/lib/components/ExtensionRow.svelte | 47 | ||||
| -rw-r--r-- | src/lib/components/Footer.svelte | 16 | ||||
| -rw-r--r-- | src/lib/components/MirrorSelector.svelte | 26 | ||||
| -rw-r--r-- | src/lib/search/debounce.ts | 30 | ||||
| -rw-r--r-- | src/lib/search/meilisearch.ts | 125 | ||||
| -rw-r--r-- | src/lib/search/types.ts | 27 | ||||
| -rw-r--r-- | src/lib/search/utils.ts | 17 | ||||
| -rw-r--r-- | src/lib/stores/mirror.ts | 3 | ||||
| -rw-r--r-- | src/lib/types.ts | 26 | ||||
| -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 |
18 files changed, 1358 insertions, 0 deletions
diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..6bdad32 --- /dev/null +++ b/src/app.css @@ -0,0 +1,563 @@ +html, +body { + height: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + background-color: #121212; + font-family: Arial, sans-serif; + color: #a0d1ff; +} + +body { + box-sizing: border-box; +} + +.container { + flex-grow: 1; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 20px; + box-sizing: border-box; +} + +.controls { + text-align: center; + margin: 0 auto 20px; + padding: 8px; + background-color: #1c2a39; + border: 1px solid #66b2ff; + display: table; +} + +select { + padding: 4px; + background-color: #121212; + color: #a0d1ff; + border: 1px solid #66b2ff; + font-size: 14px; +} + +h1, +h2 { + text-align: center; + color: #66b2ff; +} + +.mihon, +.aniyomi { + margin-top: 20px; +} + +.grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; +} + +.card { + background-color: #1c2a39; + border: 1px solid #2c3e50; + padding: 20px; + display: flex; + flex-direction: column; + transition: + transform 0.2s, + box-shadow 0.2s; + flex: 1 1 300px; + max-width: 350px; +} + +.card:hover { + border-color: #66b2ff; +} + +.card-header { + margin-bottom: 15px; +} + +.card-title { + font-size: 1.2em; + font-weight: bold; + color: #a0d1ff; + text-decoration: none; + display: block; + margin-bottom: 8px; +} + +.card-title:hover { + color: #fff; +} + +.card-meta { + font-size: 0.85em; + color: #6c8b9f; + font-family: monospace; +} + +.extension-card { + flex-direction: row; + align-items: center; + max-width: 100%; + text-align: left; +} + +.extension-icon { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; + background-color: #1c2a39; + border: 1px solid #2c3e50; +} + +.extension-details { + flex-grow: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.extension-details .card-header { + margin-bottom: 5px; +} + +.extension-details .pkg-name { + font-size: 0.8em; + color: #555; + margin-bottom: 10px; + word-break: break-all; +} + +.extension-details .card-actions { + justify-content: flex-start; +} + +.extension-details .btn { + flex: 0 0 auto; + padding: 6px 12px; + font-size: 0.85em; +} + +.table-container { + overflow-x: auto; + background-color: #1c2a39; + border: 1px solid #2c3e50; +} + +.extensions-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.extensions-table th, +.extensions-table td { + padding: 6px 10px; + border-bottom: 1px solid #2c3e50; +} + +.extensions-table th { + background-color: #15202b; + color: #66b2ff; + font-weight: bold; + text-transform: uppercase; + font-size: 0.85em; +} + +.extensions-table tr:last-child td { + border-bottom: none; +} + +.extensions-table tr:hover { + background-color: #253646; +} + +.info-cell { + max-width: 200px; +} + +.extension-icon-small { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + background-color: #2c3e50; + display: block; +} + +.extension-name { + font-weight: bold; + color: #66b2ff; + font-size: 16px; + margin-bottom: 2px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.nsfw-badge { + display: inline-block; + background-color: #e74c3c; + color: #fff; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.7em; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.extension-pkg { + font-size: 0.75em; + color: #6c8b9f; + font-family: monospace; + margin-top: 2px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.extension-source { + font-size: 0.7em; + color: #8a9ba8; + margin-top: 3px; + font-style: italic; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.meta-cell { + font-size: 0.9em; + color: #ccc; + white-space: nowrap; +} + +.meta-cell .version { + display: inline-block; + margin-right: 8px; + color: #a0d1ff; +} + +.meta-cell .lang { + display: inline-block; + background-color: #2c3e50; + padding: 1px 5px; + border-radius: 3px; + font-size: 0.8em; + color: #a0d1ff; +} + +.search-container { + margin-bottom: 20px; + text-align: center; +} + +.search-input { + width: 100%; + max-width: 600px; + padding: 12px 15px; + background-color: #1c2a39; + border: 1px solid #2c3e50; + color: #a0d1ff; + font-size: 16px; + box-sizing: border-box; + border-radius: 4px; + transition: border-color 0.2s; +} + +.search-input:focus { + outline: none; + border-color: #66b2ff; +} + +.filter-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 15px; + margin-bottom: 20px; + padding: 15px; + background-color: #1c2a39; + border: 1px solid #2c3e50; + border-radius: 4px; + align-items: center; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; +} + +.filter-group label { + color: #a0d1ff; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.filter-group select { + width: 100%; + padding: 8px 10px; + background-color: #121212; + color: #a0d1ff; + border: 1px solid #66b2ff; + border-radius: 3px; + font-size: 14px; + cursor: pointer; +} + +.filter-group select:focus { + outline: none; + border-color: #89cfff; + box-shadow: 0 0 0 2px rgba(102, 178, 255, 0.1); +} + +.filter-checkbox { + display: flex; + align-items: flex-start; + justify-content: flex-start; +} + +.filter-checkbox label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.filter-checkbox input[type='checkbox'] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #66b2ff; +} + +.filter-checkbox span { + user-select: none; +} + +.page-header { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; + position: relative; +} + +.page-header h1 { + margin: 0; +} + +.header-btn { + position: absolute; + right: 0; +} + +.commit-link { + color: inherit; + text-decoration: none; + transition: all 0.2s; + border-bottom: 1px dotted transparent; +} + +.commit-link:hover { + color: #66b2ff; + border-bottom-color: #66b2ff; + text-shadow: 0 0 8px rgba(102, 178, 255, 0.4); +} + +.card-actions { + margin-top: auto; + display: flex; + gap: 10px; +} + +.btn { + flex: 1; + padding: 10px; + text-align: center; + text-decoration: none; + font-weight: bold; + transition: all 0.2s; + font-size: 0.9em; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.85em; +} + +.btn-primary { + background-color: #196ec8; + color: #fff; + border: 1px solid #196ec8; +} + +.btn-primary:hover { + background-color: #155a9c; + border-color: #155a9c; +} + +.btn-secondary { + background-color: transparent; + border: 1px solid #66b2ff; + color: #66b2ff; +} + +.btn-secondary:hover { + background-color: rgba(102, 178, 255, 0.1); +} + +footer { + text-align: center; + padding: 10px 0; + background-color: #1c2a39; + border-top: 2px solid #66b2ff; + color: #a0d1ff; + width: 100%; + margin-top: auto; +} + +footer a { + color: #66b2ff; + text-decoration: none; +} + +footer a:hover { + color: #89cfff; +} + +@media (max-width: 600px) { + .container { + padding: 10px; + } + + .page-header { + flex-direction: column; + position: static; + gap: 10px; + } + + .header-btn, + .page-header .btn { + position: static; + width: auto; + min-width: 120px; + } + + .page-header h1 { + font-size: 1.5em; + margin-bottom: 5px; + } + + .extensions-table th, + .extensions-table td { + padding: 12px 10px; + text-align: left; + border-bottom: 1px solid #2c3e50; + vertical-align: middle; + } + + .extension-pkg { + display: none; + } + + .meta-cell .version { + display: block; + margin-right: 0; + margin-bottom: 2px; + } + + .meta-cell .lang { + display: inline-block; + } + + .card { + flex: 1 1 100%; + max-width: 100%; + } + + .filter-bar { + grid-template-columns: 1fr; + gap: 12px; + padding: 12px; + } + + .filter-group { + width: 100%; + } + + .filter-checkbox { + justify-content: flex-start; + width: 100%; + } +} + +.pagination-container { + margin-top: 30px; + padding: 20px; + background-color: #1c2a39; + border: 1px solid #2c3e50; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; +} + +.pagination-info { + color: #a0d1ff; + font-size: 14px; + text-align: center; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.page-numbers { + display: flex; + gap: 5px; + align-items: center; +} + +.pagination-controls .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-controls .btn:disabled:hover { + background-color: transparent; +} + +@media (max-width: 600px) { + .pagination-container { + padding: 15px; + margin-top: 20px; + } + + .pagination-controls { + gap: 6px; + } + + .page-numbers .btn { + min-width: 35px; + padding: 6px 10px; + font-size: 13px; + } + + .pagination-info { + font-size: 13px; + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..d76242a --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..cfcdea3 --- /dev/null +++ b/src/app.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Mihon & Aniyomi Extensions</title> + <link rel="shortcut icon" href="%sveltekit.assets%/favicon.ico" type="image/x-icon" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/src/lib/components/ExtensionCard.svelte b/src/lib/components/ExtensionCard.svelte new file mode 100644 index 0000000..90769e9 --- /dev/null +++ b/src/lib/components/ExtensionCard.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + interface Props { + repo: { + source: string; + name: string; + path: string; + commit?: string; + }; + protocol: string; + selectedDomain: string; + } + + let { repo, protocol, selectedDomain }: Props = $props(); +</script> + +<div class="card"> + <div class="card-header"> + <a href={repo.source} target="_blank" class="card-title"> + {repo.name} + </a> + <div class="card-meta"> + {#if repo.commit} + Commit:{' '} + <a + href={`${repo.source}/commit/${repo.commit}`} + target="_blank" + class="commit-link" + > + {repo.commit.substring(0, 7)} + </a> + {:else} + Commit: N/A + {/if} + </div> + </div> + <div class="card-actions"> + <a + href={`${protocol}://add-repo?url=${selectedDomain}${repo.path}`} + class="btn btn-primary" + > + Add Repo + </a> + <a href={`${selectedDomain}${repo.path}`} target="_blank" class="btn btn-secondary"> + JSON + </a> + </div> +</div> diff --git a/src/lib/components/ExtensionCategory.svelte b/src/lib/components/ExtensionCategory.svelte new file mode 100644 index 0000000..0acf92f --- /dev/null +++ b/src/lib/components/ExtensionCategory.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import ExtensionCard from './ExtensionCard.svelte'; + import type { ExtensionRepo } from '$lib/types'; + + interface Props { + category: string; + repos: ExtensionRepo[]; + selectedDomain: string; + } + + let { category, repos, selectedDomain }: Props = $props(); + + let protocol = $derived(category.toLowerCase() === 'mihon' ? 'tachiyomi' : 'aniyomi'); + let title = $derived(category.charAt(0).toUpperCase() + category.slice(1)); +</script> + +<div class={category}> + <h2>{title} Extensions</h2> + <div class="grid"> + {#each repos as repo} + <ExtensionCard {repo} {protocol} {selectedDomain} /> + {/each} + </div> +</div> diff --git a/src/lib/components/ExtensionRow.svelte b/src/lib/components/ExtensionRow.svelte new file mode 100644 index 0000000..d2a4102 --- /dev/null +++ b/src/lib/components/ExtensionRow.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import type { Extension } from '$lib/types'; + + interface Props { + extension: Extension; + repoUrl: string; + } + + let { extension, repoUrl }: Props = $props(); + + function handleImageError(e: Event) { + const target = e.target as HTMLImageElement; + target.src = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij48cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIGZpbGw9IiMyYzNlNTAiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzZjOGI5ZiIgZm9udC1zaXplPSIxMiI+TjwvdGV4dD48L3N2Zz4='; + } +</script> + +<tr class="extension-row"> + <td class="icon-cell"> + <img + src={`${repoUrl}/icon/${extension.pkg}.png`} + alt={extension.name} + class="extension-icon-small" + loading="lazy" + onerror={handleImageError} + /> + </td> + <td class="info-cell"> + <div class="extension-name"> + {extension.name} + {#if extension.nsfw === 1} + <span class="nsfw-badge">NSFW</span> + {/if} + </div> + <div class="extension-pkg">{extension.pkg}</div> + {#if extension.sourceName} + <div class="extension-source">Source: {extension.sourceName}</div> + {/if} + </td> + <td class="meta-cell"> + <span class="version">v{extension.version}</span> + <span class="lang">{extension.lang}</span> + </td> + <td class="action-cell"> + <a href={`${repoUrl}/apk/${extension.apk}`} class="btn btn-primary btn-sm"> Download </a> + </td> +</tr> diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte new file mode 100644 index 0000000..3a228ca --- /dev/null +++ b/src/lib/components/Footer.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + interface Props { + source: string; + commitLink: string; + latestCommitHash: string; + } + + let { source, commitLink, latestCommitHash }: Props = $props(); +</script> + +<footer> + Source Code: <a href={source} target="_blank">{source}</a> + <div> + Commit: <a href={commitLink} target="_blank">{latestCommitHash}</a> + </div> +</footer> diff --git a/src/lib/components/MirrorSelector.svelte b/src/lib/components/MirrorSelector.svelte new file mode 100644 index 0000000..7db4885 --- /dev/null +++ b/src/lib/components/MirrorSelector.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { selectedDomain } from '$lib/stores/mirror'; + + interface Props { + domains: string[]; + } + + let { domains }: Props = $props(); + + function getHostname(url: string) { + try { + return new URL(url).hostname; + } catch { + return url; + } + } +</script> + +<div class="controls"> + <label for="mirror-select">Select Mirror: </label> + <select id="mirror-select" bind:value={$selectedDomain}> + {#each domains as domain} + <option value={domain}>{getHostname(domain)}</option> + {/each} + </select> +</div> diff --git a/src/lib/search/debounce.ts b/src/lib/search/debounce.ts new file mode 100644 index 0000000..4b685e7 --- /dev/null +++ b/src/lib/search/debounce.ts @@ -0,0 +1,30 @@ +/** + * Creates a debounced version of a function that delays execution until after + * the specified wait time has elapsed since the last invocation. + */ +export function debounce<T extends (...args: any[]) => any>( + func: T, + wait: number +): (...args: Parameters<T>) => void { + let timeoutId: ReturnType<typeof setTimeout> | null = null; + + return function (...args: Parameters<T>) { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func(...args); + timeoutId = null; + }, wait); + }; +} + +/** + * Clears a timeout if it exists + */ +export function clearDebounce(timeoutId: ReturnType<typeof setTimeout> | null) { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/search/meilisearch.ts b/src/lib/search/meilisearch.ts new file mode 100644 index 0000000..9b4c0d1 --- /dev/null +++ b/src/lib/search/meilisearch.ts @@ -0,0 +1,125 @@ +export interface MeilisearchConfig { + host: string; + apiKey?: string; +} + +export interface SearchFilters { + query?: string; + source?: string; + category?: string; + lang?: string; + nsfw?: boolean; + page?: number; + limit?: number; +} + +interface MeilisearchClient { + host: string; + apiKey: string; +} + +let client: MeilisearchClient | null = null; + +export function initMeilisearch(config: MeilisearchConfig) { + if (!config.host) { + console.warn('Meilisearch not configured'); + return null; + } + client = { host: config.host, apiKey: config.apiKey ?? '' }; + return client; +} + +export function isMeilisearchEnabled(): boolean { + return client !== null; +} + +/** + * Transforms a Meilisearch hit to EnrichedExtension format + */ +export function transformMeilisearchHit(hit: any) { + return { + name: hit.name, + pkg: hit.pkg, + apk: hit.apk, + lang: hit.lang, + code: hit.code, + version: hit.version, + nsfw: hit.nsfw, + repoUrl: hit.repoUrl, + sourceName: hit.sourceName, + formattedSourceName: hit.formattedSourceName + }; +} + +export async function searchExtensions(filters: SearchFilters) { + if (!client) { + throw new Error('Meilisearch client not initialized'); + } + + const filterConditions: string[] = []; + + if (filters.source && filters.source !== 'all') + filterConditions.push(`formattedSourceName = "${filters.source}"`); + if (filters.category && filters.category !== 'all') + filterConditions.push(`category = "${filters.category}"`); + if (filters.lang && filters.lang !== 'all') filterConditions.push(`lang = "${filters.lang}"`); + if (filters.nsfw === false) filterConditions.push('nsfw = 0'); + + const page = filters.page || 1; + const limit = filters.limit || 50; + const offset = (page - 1) * limit; + + const body: Record<string, any> = { + q: filters.query || '', + limit, + offset + }; + + if (filterConditions.length > 0) body.filter = filterConditions; + + const response = await fetch(`${client.host}/indexes/extensions/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${client.apiKey}` + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Meilisearch error: ${response.status} ${response.statusText}`); + } + + return await response.json(); +} + +export async function getFilterOptions() { + if (!client) { + throw new Error('Meilisearch client not initialized'); + } + + const response = await fetch(`${client.host}/indexes/extensions/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${client.apiKey}` + }, + body: JSON.stringify({ + q: '', + limit: 0, + facets: ['formattedSourceName', 'category', 'lang'] + }) + }); + + if (!response.ok) { + throw new Error(`Meilisearch error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + return { + sources: Object.keys(result.facetDistribution?.formattedSourceName || {}), + categories: Object.keys(result.facetDistribution?.category || {}), + languages: Object.keys(result.facetDistribution?.lang || {}) + }; +} diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts new file mode 100644 index 0000000..cc30ae5 --- /dev/null +++ b/src/lib/search/types.ts @@ -0,0 +1,27 @@ +import type { Extension } from '$lib/types'; + +/** + * Extension enriched with repository and source information + */ +export interface EnrichedExtension extends Extension { + repoUrl: string; + sourceName: string; + formattedSourceName: string; + category: string; +} + +/** + * Repository information from data.json + */ +export interface RepoInfo { + name: string; + path: string; + commit: string; +} + +/** + * Repository data grouped by category + */ +export interface RepoData { + [category: string]: RepoInfo[]; +} diff --git a/src/lib/search/utils.ts b/src/lib/search/utils.ts new file mode 100644 index 0000000..d6b6aa8 --- /dev/null +++ b/src/lib/search/utils.ts @@ -0,0 +1,17 @@ +/** + * Formats a source name to lowercase with dots instead of spaces + */ +export function formatSourceName(sourceName: string): string { + return sourceName.toLowerCase().replace(/\s+/g, '.'); +} + +/** + * Finds a source by its formatted name from available sources + */ +export function findSourceByFormattedName( + formattedName: string, + availableSources: string[] +): string { + if (formattedName === 'all') return 'all'; + return availableSources.find((source) => formatSourceName(source) === formattedName) ?? 'all'; +} diff --git a/src/lib/stores/mirror.ts b/src/lib/stores/mirror.ts new file mode 100644 index 0000000..d80a105 --- /dev/null +++ b/src/lib/stores/mirror.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const selectedDomain = writable<string>(''); diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..79783b3 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,26 @@ +export interface Extension { + pkg: string; + name: string; + version: string; + lang: string; + apk: string; + nsfw: number; + sourceName?: string; +} + +export interface ExtensionRepo { + source: string; + name: string; + path: string; + commit: string; +} + +export interface AppData { + extensions: { + [category: string]: ExtensionRepo[]; + }; + domains: string[]; + source: string; + commitLink: string; + latestCommitHash: string; +} 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> |
