diff options
Diffstat (limited to 'src/lib')
| -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 |
11 files changed, 388 insertions, 0 deletions
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; +} |
