summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar amrkmn 2025-12-26 22:39:23 +0800
committerGravatar amrkmn 2025-12-26 22:39:23 +0800
commit32ca410f4edbff578d71781d943c41573912f476 (patch)
tree49f7e1e5602657d23945082fe273fc4802959a40 /src
Initial commitmain
Diffstat (limited to '')
-rw-r--r--src/app.css563
-rw-r--r--src/app.d.ts13
-rw-r--r--src/app.html13
-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
-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
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:&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;
+}
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>