summaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/components/ExtensionCard.svelte47
-rw-r--r--src/lib/components/ExtensionCategory.svelte24
-rw-r--r--src/lib/components/ExtensionRow.svelte47
-rw-r--r--src/lib/components/Footer.svelte16
-rw-r--r--src/lib/components/MirrorSelector.svelte26
-rw-r--r--src/lib/search/debounce.ts30
-rw-r--r--src/lib/search/meilisearch.ts125
-rw-r--r--src/lib/search/types.ts27
-rw-r--r--src/lib/search/utils.ts17
-rw-r--r--src/lib/stores/mirror.ts3
-rw-r--r--src/lib/types.ts26
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:&nbsp;</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;
+}