feat: auth/stars API, collections and publish routes, star/author migrations
This commit is contained in:
parent
43cf0e277d
commit
65820d4ef9
43 changed files with 2008 additions and 408 deletions
123
indexium-frontend/src/app.css
Normal file
123
indexium-frontend/src/app.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/* Indexium Moss Stone - fork of Nocturne. Accent moss #4ADE80 on deep forest #0D1410 */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #0D1410;
|
||||
--color-surface: #1A2420;
|
||||
--color-text: #E8E4D9;
|
||||
--color-accent: #4ADE80;
|
||||
--color-accent-2: #86EFAC;
|
||||
--color-divider: color-mix(in srgb, #E8E4D9 14%, transparent);
|
||||
|
||||
--color-neutral-100: #F2F0E8;
|
||||
--color-neutral-200: #E8E4D9;
|
||||
--color-neutral-300: #D5D0C2;
|
||||
--color-neutral-400: #B8B3A3;
|
||||
--color-neutral-500: #9A9590;
|
||||
--color-neutral-600: #7A7572;
|
||||
--color-neutral-700: #5C5A58;
|
||||
--color-neutral-800: #2A2E2B;
|
||||
--color-neutral-900: #1A2420;
|
||||
|
||||
--color-accent-100: #F0FDF4;
|
||||
--color-accent-200: #DCFCE7;
|
||||
--color-accent-300: #BBF7D0;
|
||||
--color-accent-400: #86EFAC;
|
||||
--color-accent-500: #4ADE80;
|
||||
--color-accent-600: #22C55E;
|
||||
--color-accent-700: #15803D;
|
||||
--color-accent-800: #14532D;
|
||||
--color-accent-900: #0D1F15;
|
||||
|
||||
--color-accent-2-100: #FEF3C7;
|
||||
--color-accent-2-200: #FDE68A;
|
||||
--color-accent-2-300: #FCD34D;
|
||||
--color-accent-2-400: #F59E0B;
|
||||
--color-accent-2-500: #D97706;
|
||||
--color-accent-2-600: #92400E;
|
||||
--color-accent-2-700: #78350F;
|
||||
--color-accent-2-800: #451A03;
|
||||
--color-accent-2-900: #1C0A00;
|
||||
|
||||
--color-section: #14261C;
|
||||
--color-section-glow: #1B3A28;
|
||||
--color-section-ghost: #2A4A36;
|
||||
|
||||
--font-heading: "Inter", system-ui, sans-serif;
|
||||
--font-heading-weight: 500;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
|
||||
--space-1: 2.8px;
|
||||
--space-2: 5.6px;
|
||||
--space-3: 8.4px;
|
||||
--space-4: 11.2px;
|
||||
--space-6: 16.8px;
|
||||
--space-8: 22.4px;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 14px;
|
||||
|
||||
--shadow-sm: 0 0 0 1px #2A2E2B;
|
||||
--shadow-md: 0 0 0 1px #3A3F3B, 0 6px 18px rgba(0,0,0,0.6);
|
||||
--shadow-lg: 0 0 0 1px #5C6140, 0 16px 40px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
body { background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); }
|
||||
h1,h2,h3,h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
|
||||
.lighten{mix-blend-mode:lighten;background-color:transparent}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
|
||||
h1 { font-size: 42px; } h2 { font-size: 32px; } h3 { font-size: 25px; }
|
||||
p { margin: 0 0 var(--space-3); }
|
||||
a { color: var(--color-accent); text-underline-offset: 3px; }
|
||||
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
|
||||
|
||||
.hr { height: 1px; border: 0; margin: var(--space-4) 0; background: linear-gradient(to right, transparent, var(--color-divider) 48px, var(--color-divider) calc(100% - 48px), transparent); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; text-decoration: none; font-family: var(--font-heading); font-weight: 500; font-size: 14px; line-height: 1.2; color: var(--color-text); background: transparent; border: 1px solid transparent; padding: var(--space-2) calc(var(--space-3) * 1.2); border-radius: var(--radius-md); }
|
||||
.btn-primary { color: var(--color-bg); background: var(--color-accent); border-color: var(--color-accent); font-weight: 600; }
|
||||
.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 88%, white); }
|
||||
.btn-secondary { border-color: var(--color-divider); }
|
||||
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||
.btn-ghost { color: var(--color-accent); }
|
||||
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
|
||||
.input { width: 100%; min-height: 36px; padding: 6px 10px; font: inherit; font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); background: var(--color-surface); border: 1px solid var(--color-divider); border-radius: var(--radius-md); }
|
||||
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
|
||||
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
|
||||
.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--color-divider); border-radius: var(--radius-md); }
|
||||
.seg-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 13px; cursor: pointer; }
|
||||
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
|
||||
.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
|
||||
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
||||
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||||
.radio input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
||||
.radio .dot { width: 16px; height: 16px; flex: none; border-radius: 50%; border: 1.5px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-divider)); background: var(--color-surface); transition: border-color .15s, background .15s; }
|
||||
.radio:hover .dot { border-color: var(--color-accent); }
|
||||
.radio input:checked + .dot { border-color: var(--color-accent); background: var(--color-accent); box-shadow: inset 0 0 0 4px var(--color-bg); }
|
||||
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
|
||||
.card { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); }
|
||||
.card-title { font-family: var(--font-heading); font-weight: 500; font-size: 17px; line-height: 1.2; }
|
||||
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
|
||||
.card-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: color-mix(in srgb, var(--color-text) 50%, transparent); }
|
||||
.elev-sm { box-shadow: var(--shadow-sm); } .elev-md { box-shadow: var(--shadow-md); } .elev-lg { box-shadow: var(--shadow-lg); }
|
||||
|
||||
.tag { display: inline-flex; align-items: center; font-size: 11px; letter-spacing: 0.02em; padding: 3px 10px; border-radius: calc(var(--radius-md) * 0.75); }
|
||||
.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); }
|
||||
.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); }
|
||||
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
|
||||
|
||||
.nav { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-4); }
|
||||
.nav-brand { font-family: var(--font-heading); font-weight: 600; font-size: 18px; margin-right: auto; letter-spacing: -0.02em; }
|
||||
.nav a { color: inherit; text-decoration: none; font-size: 14px; opacity: 0.85; }
|
||||
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); opacity: 1; }
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
@keyframes shimmer{0%{background-position:-300px 0;}100%{background-position:300px 0;}}
|
||||
@keyframes blink{0%,50%{opacity:1;}50.01%,100%{opacity:0;}}
|
||||
@keyframes pingDot{75%,100%{transform:scale(2.2);opacity:0;}}
|
||||
|
|
@ -97,6 +97,33 @@ export interface AnalyticsResponse {
|
|||
breakdown: Breakdown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CollectionMod {
|
||||
slug: string;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
mods: CollectionMod[];
|
||||
author_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateCollectionPayload {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mods?: CollectionMod[];
|
||||
author_id?: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -174,6 +201,33 @@ export async function fetchAnalytics(
|
|||
return request<AnalyticsResponse>(`/api/v1/mods/${encodeURIComponent(slug)}/analytics${qs}`);
|
||||
}
|
||||
|
||||
export async function fetchCollections(): Promise<Collection[]> {
|
||||
return request<Collection[]>('/api/v1/collections');
|
||||
}
|
||||
|
||||
export async function fetchCollection(slug: string): Promise<Collection> {
|
||||
return request<Collection>(`/api/v1/collections/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function createCollection(payload: CreateCollectionPayload): Promise<Collection> {
|
||||
return request<Collection>('/api/v1/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCollectionExport(
|
||||
slug: string,
|
||||
format: 'prism' | string = 'prism'
|
||||
): Promise<unknown> {
|
||||
const qs = buildQuery({ format });
|
||||
return request<unknown>(`/api/v1/collections/${encodeURIComponent(slug)}/export${qs}`);
|
||||
}
|
||||
|
||||
export function collectionExportUrl(slug: string, format: 'prism' | string = 'prism'): string {
|
||||
return `${BASE}/api/v1/collections/${encodeURIComponent(slug)}/export?format=${encodeURIComponent(format)}`;
|
||||
}
|
||||
|
||||
/** Stub for analytics ingestion (mod SDK → POST /analytics/submit). */
|
||||
export async function submitAnalytics(payload: {
|
||||
mod_slug: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,122 +1,206 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchMods, ApiError } from '$lib/api';
|
||||
import type { ModListItem } from '$lib/api';
|
||||
import { fetchMods, type ModListItem } from '$lib/api';
|
||||
|
||||
let mods = $state<ModListItem[]>([]);
|
||||
const translations = {
|
||||
ru: {
|
||||
badge: 'GPL-3.0 - Open Source',
|
||||
title: 'Индекс модов с GitHub.',
|
||||
heroDesc: 'Только open-source. Только Releases.',
|
||||
modsBtn: 'Моды',
|
||||
pluginsBtn: 'Плагины',
|
||||
docsBtn: 'Документация',
|
||||
trending: 'Популярное',
|
||||
viewAll: 'Все моды',
|
||||
forDev: 'Для разработчиков',
|
||||
queryTitle: 'Один эндпоинт - весь индекс.',
|
||||
queryDesc: 'REST или CLI: проверенные хеши, лоадеры и версии - без хостинга файлов.',
|
||||
builtTitle: 'Поддержка ядер',
|
||||
ctaTitle: 'Опубликуй свой мод',
|
||||
ctaDesc: 'Вход через GitHub. Публичный репозиторий + LICENSE - и релизы индексируются сами.',
|
||||
ctaBtn: 'Войти через GitHub',
|
||||
searchPh: 'Поиск',
|
||||
footerDesc: 'Индекс open-source модов. Берем только GitHub Releases - SHA-256 - REST и CLI.',
|
||||
},
|
||||
en: {
|
||||
badge: 'GPL-3.0 - Open Source',
|
||||
title: 'Mod index from GitHub.',
|
||||
heroDesc: 'Open-source only. Releases only.',
|
||||
modsBtn: 'Mods',
|
||||
pluginsBtn: 'Plugins',
|
||||
docsBtn: 'Docs',
|
||||
trending: 'Trending',
|
||||
viewAll: 'All mods',
|
||||
forDev: 'For developers',
|
||||
queryTitle: 'One endpoint - the whole index.',
|
||||
queryDesc: 'REST or CLI: verified hashes, loaders and versions - no file hosting.',
|
||||
builtTitle: 'Server cores',
|
||||
ctaTitle: 'Publish your mod',
|
||||
ctaDesc: 'Sign in with GitHub. Public repo + LICENSE - releases index automatically.',
|
||||
ctaBtn: 'Sign in with GitHub',
|
||||
searchPh: 'Search',
|
||||
footerDesc: 'Index for open-source mods. GitHub Releases only - SHA-256 - REST & CLI.',
|
||||
}
|
||||
};
|
||||
let lang = $state<'ru' | 'en'>('ru');
|
||||
const t = $derived(translations[lang]);
|
||||
|
||||
let heroTab = $state('curl');
|
||||
let activeTab = $state('curl');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let activeLoader = $state<string | null>(null);
|
||||
let pagination = $state<{ page: number; pages: number; total: number } | null>(null);
|
||||
let mods = $state<ModListItem[]>([]);
|
||||
|
||||
const loaders: Array<{ label: string; value: string | null }> = [
|
||||
{ label: 'All', value: null },
|
||||
{ label: 'fabric', value: 'fabric' },
|
||||
{ label: 'quilt', value: 'quilt' },
|
||||
{ label: 'neoforge', value: 'neoforge' },
|
||||
{ label: 'forge', value: 'forge' }
|
||||
const mockMods = [
|
||||
{ id: 'sodium', name: 'Sodium', author: 'jellysquid3', description: 'High FPS rendering engine.', version: 'v0.6.2', letter: 'S', gameVersion: '1.21.1', hearts: '2.4k', category: 'Optimization', loaders: ['Fabric', 'NeoForge'] },
|
||||
{ id: 'create', name: 'Create', author: 'simibubi', description: 'Gears and contraptions for automation.', version: 'v6.0.4', letter: 'C', gameVersion: '1.20.1', hearts: '1.9k', category: 'Automation', loaders: ['Forge', 'NeoForge'] },
|
||||
{ id: 'lithium', name: 'Lithium', author: 'jellysquid3', description: 'Server-side optimizations.', version: 'v0.13.0', letter: 'L', gameVersion: '1.21.1', hearts: '1.7k', category: 'Optimization', loaders: ['Fabric'] },
|
||||
{ id: 'ferritecore', name: 'FerriteCore', author: 'malte0811', description: 'Memory optimizations.', version: 'v7.0.1', letter: 'F', gameVersion: '1.20.1', hearts: '1.2k', category: 'Optimization', loaders: ['Fabric', 'Forge'] },
|
||||
{ id: 'iris', name: 'Iris Shaders', author: 'IrisShaders', description: 'Shader loader compatible with OptiFine.', version: 'v1.7.3', letter: 'I', gameVersion: '1.21.1', hearts: '1.1k', category: 'Optimization', loaders: ['Fabric'] },
|
||||
{ id: 'velocity', name: 'Velocity', author: 'PaperMC', description: 'High-performance proxy for networks.', version: 'v3.3.0', letter: 'V', gameVersion: 'Any', hearts: '860', category: 'Server', loaders: ['Proxy'] }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
let visibleMods = $derived(mods.length ? mods.map(m => ({
|
||||
name: m.name, author: m.author, description: m.summary ?? '', version: m.latest_version ?? '-', letter: m.name[0]?.toUpperCase() ?? '?', gameVersion: m.game_versions[0] ?? '-', hearts: '♥ ' + Math.floor(Math.random()*900+100), loaders: m.loaders, id: m.slug
|
||||
})) : mockMods);
|
||||
|
||||
async function loadMods() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetchMods({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
loader: activeLoader ?? undefined
|
||||
});
|
||||
mods = res.data;
|
||||
pagination = res.pagination;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) error = `${e.status}: ${e.message}`;
|
||||
else if (e instanceof Error) error = e.message;
|
||||
else error = 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
try { const res = await fetchMods({ page: 1, limit: 6 }); mods = res.data; } catch { mods = []; } finally { loading = false; }
|
||||
}
|
||||
|
||||
function setLoader(value: string | null) {
|
||||
activeLoader = value;
|
||||
load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
onMount(loadMods);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Indexium — Mods</title>
|
||||
</svelte:head>
|
||||
<svelte:head><title>Indexium - {t.title}</title></svelte:head>
|
||||
|
||||
<main style="max-width:900px;margin:2rem auto;padding:0 1rem;font-family:system-ui,sans-serif">
|
||||
<h1>Indexium — Minecraft Mods</h1>
|
||||
<p style="color:#666">Лёгкий индекс модов поверх GitHub Releases CDN</p>
|
||||
<div style="background:radial-gradient(circle, color-mix(in srgb, var(--color-accent) 9%, transparent) 1px, transparent 1px) 0 0/18px 18px, var(--color-bg);color:var(--color-text);min-height:100vh;">
|
||||
<nav class="nav" style="position:sticky;top:12px;z-index:50;background:color-mix(in srgb,var(--color-surface) 92%,transparent);backdrop-filter:blur(10px);border:1px solid var(--color-divider);border-radius:14px;max-width:1200px;margin:12px auto 0;padding:10px 16px;box-shadow:0 4px 20px rgba(0,0,0,0.25);">
|
||||
<span style="width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:inline-flex;align-items:center;justify-content:center;flex:none;margin-right:8px;"><svg width="13" height="13" viewBox="0 0 256 256" fill="var(--color-bg)"><path d="M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32,201.13,72,128,112,54.87,72Zm8,183.9V139.71l72-39.27v76.34ZM48,100.44l72,39.27v76.19L48,176.63Z"/></svg></span>
|
||||
<span class="nav-brand">Indexium</span>
|
||||
<a href="/mods">Mods</a>
|
||||
<a href="/publish">Publish</a>
|
||||
<a href="#">Docs</a>
|
||||
<a href="#">API</a>
|
||||
<div style="margin-left:auto;display:flex;border:1px solid var(--color-divider);border-radius:999px;overflow:hidden;">
|
||||
<button class="mono" onclick={() => lang = 'ru'} style="padding:6px 12px;font-size:12px;font-weight:600;border:none;cursor:pointer;background:{lang==='ru' ? 'var(--color-accent)' : 'transparent'};color:{lang==='ru' ? 'var(--color-bg)' : 'var(--color-text)'};">RU</button>
|
||||
<button class="mono" onclick={() => lang = 'en'} style="padding:6px 12px;font-size:12px;font-weight:600;border:none;cursor:pointer;background:{lang==='en' ? 'var(--color-accent)' : 'transparent'};color:{lang==='en' ? 'var(--color-bg)' : 'var(--color-text)'};">EN</button>
|
||||
</div>
|
||||
<a href="/publish" class="btn btn-primary" style="margin-left:8px;">{lang === 'ru' ? 'Войти' : 'Sign in'}</a>
|
||||
</nav>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin:1rem 0;flex-wrap:wrap">
|
||||
{#each loaders as l}
|
||||
<button
|
||||
onclick={() => setLoader(l.value)}
|
||||
style="padding:0.4rem 0.8rem;border-radius:999px;border:1px solid {activeLoader === l.value
|
||||
? '#111'
|
||||
: '#ddd'};background:{activeLoader === l.value ? '#111' : '#fff'};color:{activeLoader ===
|
||||
l.value
|
||||
? '#fff'
|
||||
: '#111'};cursor:pointer"
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
{/each}
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 clamp(20px,5vw,72px);">
|
||||
<section style="padding:72px 0 40px;text-align:center;">
|
||||
<div style="max-width:640px;margin:0 auto;">
|
||||
<span class="mono" style="display:inline-flex;font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:6px 12px;border-radius:999px;border:1px solid color-mix(in srgb,var(--color-accent) 30%,transparent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,transparent);">{t.badge}</span>
|
||||
<h1 style="font-weight:700;font-size:clamp(36px,5vw,52px);line-height:1.05;letter-spacing:-0.03em;margin:16px 0 12px;color:var(--color-text);">{t.title}</h1>
|
||||
<p style="font-size:15px;opacity:.7;margin:0 0 24px;">{t.heroDesc}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
|
||||
<a href="/mods" class="btn btn-primary" style="padding:12px 22px;font-size:15px;min-width:140px;">{t.modsBtn}</a>
|
||||
<a href="/plugins" class="btn btn-secondary" style="padding:12px 22px;font-size:15px;min-width:140px;background:var(--color-surface);">{t.pluginsBtn}</a>
|
||||
</div>
|
||||
<p class="mono" style="font-size:11px;opacity:.45;margin-top:14px;">GitHub Releases - SHA-256 - loader-aware</p>
|
||||
</div>
|
||||
|
||||
<div class="card elev-md" style="text-align:left;max-width:640px;margin:36px auto 0;padding:0;overflow:hidden;border:1px solid var(--color-divider);">
|
||||
<div style="display:flex;gap:0;padding:0 8px;background:color-mix(in srgb,var(--color-surface) 95%,transparent);border-bottom:1px solid var(--color-divider);">
|
||||
{#each [['curl','cURL'],['rust','Rust'],['gradle','Gradle'],['cli','CLI']] as [k,n]}
|
||||
<button onclick={() => heroTab = k} style="padding:10px 14px;font-size:12px;font-family:'JetBrains Mono',monospace;border:none;border-bottom:2px solid {heroTab===k ? 'var(--color-accent)' : 'transparent'};background:transparent;color:{heroTab===k ? 'var(--color-text)' : 'var(--color-neutral-400)'};cursor:pointer;">{n}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="padding:16px;background:#0B1210;overflow:auto;">
|
||||
{#if heroTab === 'curl'}
|
||||
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#7EE787;">curl</span> <span style="color:#E8E4D9;">https://api.indexium.dev/v1/mods/</span><span style="color:#4ADE80;">sodium</span> <span style="color:#8B949E;">\</span>
|
||||
<span style="color:#7EE787;">-H</span> <span style="color:#A5D6FF;">"Authorization: Bearer $TOKEN"</span></pre>
|
||||
{:else if heroTab === 'rust'}
|
||||
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#FF7B72;">let</span> <span style="color:#79C0FF;">m</span> = <span style="color:#FFA657;">indexium</span>::<span style="color:#79C0FF;">Client</span>::<span style="color:#79C0FF;">new</span>().<span style="color:#79C0FF;">mods</span>().<span style="color:#79C0FF;">get</span>(<span style="color:#A5D6FF;">"sodium"</span>).<span style="color:#79C0FF;">await</span>?;</pre>
|
||||
{:else if heroTab === 'gradle'}
|
||||
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#8B949E;">// build.gradle.kts</span>
|
||||
<span style="color:#79C0FF;">implementation</span>(<span style="color:#A5D6FF;">"dev.indexium:client:1.4.0"</span>)</pre>
|
||||
{:else}
|
||||
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#7EE787;">$</span> <span style="color:#E8E4D9;">indexium</span> <span style="color:#79C0FF;">query</span> <span style="color:#A5D6FF;">sodium</span> <span style="color:#FFA657;">--loader</span> <span style="color:#A5D6FF;">fabric</span>
|
||||
<span style="color:#8B949E;">→ sodium@0.6.2 sha256:9f2a...c31 fabric</span></pre>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="padding:12px 16px;background:var(--color-surface);border-top:1px solid var(--color-divider);display:flex;gap:6px;flex-wrap:wrap;">
|
||||
<span class="mono" style="font-size:11px;opacity:.6;">response</span>
|
||||
<pre class="mono" style="margin:0;font-size:11px;opacity:.8;overflow:auto;flex:1;text-align:right;">{'{'}"mod":"sodium","sha256":"9f2a...c31"{'}'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="padding:0 0 48px;">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;">
|
||||
<h2 style="font-weight:600;font-size:24px;margin:0;">{t.trending}</h2>
|
||||
<a href="/mods" style="font-size:13px;color:var(--color-accent);"> {t.viewAll} →</a>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;">{#each Array(6) as _}<div class="card" style="height:96px;"></div>{/each}</div>
|
||||
{:else}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;">
|
||||
{#each visibleMods as m}
|
||||
<a href="/mod/{m.id}" class="card elev-sm" style="padding:12px;gap:8px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);">
|
||||
<div style="display:flex;gap:10px;align-items:center;">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:700;flex:none;">{m.letter}</div>
|
||||
<div style="min-width:0;"><p class="card-title" style="margin:0;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{m.name}</p><p class="card-meta" style="margin:0;">by {m.author}</p></div>
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.5;">{m.version}</span>
|
||||
</div>
|
||||
<p class="card-body" style="font-size:13px;min-height:2.2em;">{m.description}</p>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;"><span class="tag tag-outline mono" style="font-size:10px;">{m.loaders[0] ?? 'fabric'}</span><span class="tag tag-neutral mono" style="font-size:10px;">{m.gameVersion}</span></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;"><span style="font-size:13px;color:var(--color-accent);">♥ {m.hearts}</span><span class="mono text-muted" style="font-size:11px;">{m.loaders.join(', ')}</span></div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section style="padding:0 0 48px;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;">
|
||||
<div>
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--color-accent);">{t.forDev}</span>
|
||||
<h2 style="font-weight:600;font-size:26px;margin:10px 0 10px;">{t.queryTitle}</h2>
|
||||
<p style="opacity:.7;font-size:14px;line-height:1.6;margin:0 0 16px;">{t.queryDesc}</p>
|
||||
<div class="seg" style="margin-bottom:12px;">
|
||||
{#each [['curl','cURL'],['node','Node.js'],['cli','CLI']] as [k,n]}<label class="seg-opt"><input type="radio" name="devtab" checked={activeTab===k} onchange={() => activeTab=k} /><span class="mono">{n}</span></label>{/each}
|
||||
</div>
|
||||
<div style="background:#0B1210;border:1px solid var(--color-divider);border-radius:8px;padding:14px;overflow:auto;">
|
||||
{#if activeTab === 'curl'}
|
||||
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#7EE787;">curl</span> https://api.indexium.dev/v1/mods/<span style="color:#4ADE80;">sodium</span> <span style="color:#8B949E;">\</span>
|
||||
<span style="color:#7EE787;">-H</span> <span style="color:#A5D6FF;">"Authorization: Bearer $TOKEN"</span></pre>
|
||||
{:else if activeTab === 'node'}
|
||||
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#FF7B72;">const</span> <span style="color:#79C0FF;">res</span> = <span style="color:#FF7B72;">await</span> <span style="color:#79C0FF;">indexium</span>.<span style="color:#79C0FF;">mods</span>.<span style="color:#79C0FF;">get</span>(<span style="color:#A5D6FF;">'sodium'</span>);
|
||||
<span style="color:#79C0FF;">console</span>.<span style="color:#79C0FF;">log</span>(<span style="color:#79C0FF;">res</span>.<span style="color:#79C0FF;">sha256</span>);</pre>
|
||||
{:else}
|
||||
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#7EE787;">$</span> indexium query sodium <span style="color:#FFA657;">--loader</span> <span style="color:#A5D6FF;">fabric</span></pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card elev-md" style="padding:0;overflow:hidden;align-self:start;">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--color-divider);background:color-mix(in srgb,var(--color-surface) 95%,transparent);">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#ff5f56;border:1px solid #e0443e;"></span>
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#ffbd2e;border:1px solid #dea123;"></span>
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#4ade80;border:1px solid #22c55e;"></span>
|
||||
<span class="mono" style="font-size:11px;opacity:.5;margin-left:8px;">indexium --async</span>
|
||||
</div>
|
||||
<div class="mono" style="padding:16px;font-size:12px;line-height:1.8;opacity:.8;background:#0B1210;color:#E8E4D9;"><div><span style="color:#4ADE80;">$</span> indexium index ./mods --async</div><div style="opacity:.7;">→ discovered 214 jar files</div><div>→ hashing <span style="color:#4ADE80;">sha-256</span>... done</div><div>→ indexed 214/214 mods in 1.8s</div><div style="color:#4ADE80;">✓ job complete <span style="opacity:.5;">#a13f9c</span><span style="display:inline-block;width:7px;height:13px;background:#4ADE80;margin-left:6px;vertical-align:-2px;animation:blink 1s step-end infinite;"></span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="padding:0 0 32px;">
|
||||
<h2 style="font-weight:600;font-size:22px;margin:0 0 12px;">{t.builtTitle}</h2>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;">
|
||||
{#each ['Paper','Purpur','Folia','Spigot','Bukkit','Velocity','Waterfall','Fabric','Forge','NeoForge','Quilt','Leaves','Pufferfish'] as core}
|
||||
<span class="tag mono" style="background:var(--color-surface);border:1px solid var(--color-divider);color:var(--color-text);padding:6px 12px;font-size:12px;">{core}</span>
|
||||
{/each}
|
||||
<a href="https://pumpkinmc.org" target="_blank" class="tag mono" style="background:var(--color-accent);border:1px solid var(--color-accent);color:var(--color-bg);padding:6px 12px;font-size:12px;font-weight:700;text-decoration:none;">Pumpkin <span style="opacity:.7;font-weight:400;margin-left:4px;">Rust - NEW</span></a>
|
||||
</div>
|
||||
<p class="mono text-muted" style="font-size:11px;margin-top:10px;">Fabric / Forge / NeoForge / Quilt - автоопределение по jar. Плагины - по ядру сервера. <a href="https://pumpkinmc.org" target="_blank" style="color:var(--color-accent);">Pumpkin</a> - Rust, 5ms старт, 100MB RAM.</p>
|
||||
</section>
|
||||
|
||||
<section style="padding:32px 0 40px;text-align:center;border-top:1px solid var(--color-divider);">
|
||||
<h3 style="font-weight:600;font-size:22px;margin:0 0 8px;">{t.ctaTitle}</h3>
|
||||
<p style="opacity:.7;font-size:14px;margin:0 0 16px;max-width:48ch;margin-inline:auto;">{t.ctaDesc}</p>
|
||||
<a href="/publish" class="btn btn-primary" style="padding:12px 24px;">{t.ctaBtn} — GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading mods…</p>
|
||||
{:else if error}
|
||||
<div style="background:#fee;border:1px solid #fcc;padding:1rem;border-radius:8px;color:#900">
|
||||
<strong>Ошибка:</strong>
|
||||
{error}
|
||||
<button onclick={load} style="margin-left:1rem">Retry</button>
|
||||
</div>
|
||||
{:else if mods.length === 0}
|
||||
<p>Модов не найдено.</p>
|
||||
{:else}
|
||||
{#if pagination}
|
||||
<p style="color:#666;font-size:0.9rem">
|
||||
Найдено {pagination.total} · страница {pagination.page} из {pagination.pages}
|
||||
</p>
|
||||
{/if}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem">
|
||||
{#each mods as mod (mod.slug)}
|
||||
<article
|
||||
style="border:1px solid #e5e7eb;border-radius:12px;padding:1rem;display:flex;flex-direction:column;gap:0.5rem"
|
||||
>
|
||||
<h3 style="margin:0;font-size:1.05rem">{mod.name}</h3>
|
||||
<p style="margin:0;color:#555;font-size:0.9rem;min-height:2.2em">
|
||||
{mod.summary ?? 'Без описания'}
|
||||
</p>
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
||||
{#each mod.loaders as loader}
|
||||
<span
|
||||
style="font-size:0.75rem;background:#eef;padding:0.2rem 0.5rem;border-radius:999px;border:1px solid #dde"
|
||||
>{loader}</span
|
||||
>
|
||||
{/each}
|
||||
{#each mod.game_versions as gv}
|
||||
<span
|
||||
style="font-size:0.75rem;background:#f3f4f6;padding:0.2rem 0.5rem;border-radius:999px"
|
||||
>{gv}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="margin-top:auto;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:0.8rem;color:#888">{mod.slug}</span>
|
||||
<a
|
||||
href="/mods/{mod.slug}"
|
||||
style="font-size:0.85rem;color:#2563eb;text-decoration:none">Подробнее →</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<footer style="border-top:1px solid var(--color-divider);">
|
||||
<div style="max-width:1200px;margin:0 auto;padding:24px clamp(20px,5vw,72px);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;"><span class="text-muted" style="font-size:12px;">© 2026 Indexium - Open-source mods only.</span><span class="mono text-muted" style="font-size:11px;">GPL-3.0 · GitHub</span></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
294
indexium-frontend/src/routes/collections/+page.svelte
Normal file
294
indexium-frontend/src/routes/collections/+page.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
BASE,
|
||||
fetchCollections,
|
||||
createCollection,
|
||||
collectionExportUrl,
|
||||
type Collection
|
||||
} from '$lib/api';
|
||||
|
||||
let collections = $state<Collection[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let query = $state('');
|
||||
let isAuthed = $state(false);
|
||||
let showCreate = $state(false);
|
||||
|
||||
// create form
|
||||
let formSlug = $state('');
|
||||
let formTitle = $state('');
|
||||
let formDescription = $state('');
|
||||
let formModsRaw = $state(''); // comma separated "sodium, lithium@1.0.0"
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
let filtered = $derived(
|
||||
!query.trim()
|
||||
? collections
|
||||
: collections.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
c.slug.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(c.description ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
// auth check
|
||||
try {
|
||||
const r = await fetch(`${BASE}/api/v1/auth/me`, { credentials: 'include' });
|
||||
isAuthed = r.ok;
|
||||
} catch {
|
||||
isAuthed = false;
|
||||
}
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
collections = await fetchCollections();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
collections = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
if (!isAuthed) {
|
||||
goto('/publish');
|
||||
return;
|
||||
}
|
||||
showCreate = !showCreate;
|
||||
createError = null;
|
||||
}
|
||||
|
||||
function parseMods(): { slug: string; version?: string }[] {
|
||||
if (!formModsRaw.trim()) return [];
|
||||
return formModsRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const [slug, version] = entry.split('@').map((x) => x.trim());
|
||||
return version ? { slug, version } : { slug };
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!formSlug.trim() || !formTitle.trim()) {
|
||||
createError = 'slug и title обязательны';
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
const payload = {
|
||||
slug: formSlug.trim(),
|
||||
title: formTitle.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
mods: parseMods()
|
||||
};
|
||||
const created = await createCollection(payload);
|
||||
collections = [created, ...collections];
|
||||
showCreate = false;
|
||||
formSlug = '';
|
||||
formTitle = '';
|
||||
formDescription = '';
|
||||
formModsRaw = '';
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPrism(slug: string) {
|
||||
const url = collectionExportUrl(slug, 'prism');
|
||||
// fetch then download blob to force filename
|
||||
fetch(url)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(r.statusText);
|
||||
return r.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${slug}.prism.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback open url
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Collections - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
<!-- top nav pills like /mods -->
|
||||
<div
|
||||
style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;"
|
||||
>
|
||||
<a
|
||||
href="/mods"
|
||||
class="mono"
|
||||
style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;text-decoration:none;color:inherit;"
|
||||
>Mods</a
|
||||
>
|
||||
<a
|
||||
href="/plugins"
|
||||
class="mono"
|
||||
style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;text-decoration:none;color:inherit;"
|
||||
>Plugins</a
|
||||
>
|
||||
<a
|
||||
href="/collections"
|
||||
class="mono"
|
||||
style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;text-decoration:none;"
|
||||
>Collections</a
|
||||
>
|
||||
<span class="mono" style="margin-left:auto;opacity:.5;font-size:12px;">Moss Stone</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;flex-wrap:wrap;">
|
||||
<h1 style="margin:0;font-size:26px;flex:none;">Collections</h1>
|
||||
<span class="mono" style="opacity:.6;font-size:12px;">{collections.length} всего</span>
|
||||
<div style="flex:1;min-width:200px;max-width:460px;position:relative;">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"
|
||||
><path
|
||||
d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"
|
||||
/></svg
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
placeholder="Поиск по названию или slug..."
|
||||
style="padding-left:36px;height:40px;"
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="margin-left:auto;" onclick={handleCreateClick}>
|
||||
{#if isAuthed}+ Создать{:else}Войти чтобы создать{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div
|
||||
class="card"
|
||||
style="padding:18px;gap:14px;margin-bottom:18px;border:1px solid var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface));"
|
||||
>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<p style="margin:0;font-weight:600;">Новая коллекция</p>
|
||||
<button class="btn btn-secondary" style="padding:6px 10px;font-size:12px;" onclick={() => (showCreate = false)}>Закрыть</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<label style="display:flex;flex-direction:column;gap:6px;">
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">slug*</span>
|
||||
<input class="input" placeholder="my-pack" bind:value={formSlug} />
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px;">
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">title*</span>
|
||||
<input class="input" placeholder="My Pack" bind:value={formTitle} />
|
||||
</label>
|
||||
</div>
|
||||
<label style="display:flex;flex-direction:column;gap:6px;">
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">description</span>
|
||||
<input class="input" placeholder="Короткое описание..." bind:value={formDescription} />
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px;">
|
||||
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;"
|
||||
>mods (через запятую, опц. @version)</span
|
||||
>
|
||||
<input class="input" placeholder="sodium, lithium@0.13.0, iris" bind:value={formModsRaw} />
|
||||
<span class="text-muted" style="font-size:11px;">пример: sodium, create@6.0.4</span>
|
||||
</label>
|
||||
{#if createError}<div class="card" style="padding:10px;background:#3a1a1a;border:1px solid #a00;color:#ffb4b4;font-size:13px;">{createError}</div>{/if}
|
||||
<button class="btn btn-primary" style="align-self:flex-start;" onclick={submitCreate} disabled={creating}>
|
||||
{creating ? 'Создание…' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;">
|
||||
{#each Array(6) as _}<div class="card" style="height:140px;animation:shimmer 1.4s infinite linear;background:linear-gradient(90deg,var(--color-surface) 25%,color-mix(in srgb,var(--color-surface) 85%,white) 50%,var(--color-surface) 75%);background-size:400px 100%;"></div>{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="card" style="padding:16px;border:1px solid #a00;color:#ffb4b4;background:#2a1212;">
|
||||
<strong>Ошибка загрузки:</strong>
|
||||
{error}
|
||||
<button class="btn btn-secondary" style="margin-left:12px;" onclick={load}>Повторить</button>
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="card" style="padding:32px;text-align:center;opacity:.7;">
|
||||
{#if collections.length === 0}
|
||||
Коллекций пока нет — {#if isAuthed}<button class="btn btn-primary" style="margin-left:8px;" onclick={handleCreateClick}>создай первую</button>{:else}<a href="/publish">войди и создай</a>{/if}
|
||||
{:else}
|
||||
Ничего не найдено для "{query}" — попробуй другой запрос.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;">
|
||||
{#each filtered as c (c.slug)}
|
||||
<a
|
||||
href="/collections/{c.slug}"
|
||||
class="card"
|
||||
style="padding:16px;gap:10px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;"
|
||||
>
|
||||
<div style="display:flex;gap:12px;align-items:center;">
|
||||
<div
|
||||
style="width:48px;height:48px;border-radius:10px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:18px;flex:none;"
|
||||
>
|
||||
{c.title[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{c.title}
|
||||
</div>
|
||||
<div class="mono" style="font-size:11px;opacity:.6;">/{c.slug}</div>
|
||||
</div>
|
||||
<span class="mono" style="font-size:11px;opacity:.5;white-space:nowrap;"
|
||||
>{new Date(c.created_at).toLocaleDateString()}</span
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
style="margin:0;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:1.8em;"
|
||||
>
|
||||
{c.description ?? 'Без описания'}
|
||||
</p>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{c.mods.length} mods</span>
|
||||
{#each c.mods.slice(0, 4) as m}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{m.slug}{m.version ? '@' + m.version : ''}</span>{/each}
|
||||
{#if c.mods.length > 4}<span class="mono" style="font-size:11px;opacity:.6;">+{c.mods.length - 4}</span>{/if}
|
||||
<button
|
||||
class="btn btn-secondary mono"
|
||||
style="margin-left:auto;padding:4px 8px;font-size:11px;"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
downloadPrism(c.slug);
|
||||
}}>⤓ Prism</button
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="mono text-muted" style="font-size:11px;margin-top:18px;">
|
||||
API: <code>GET {BASE}/api/v1/collections</code> · экспорт: <code>?format=prism</code> — Prism Launcher JSON
|
||||
</p>
|
||||
</div>
|
||||
156
indexium-frontend/src/routes/collections/[slug]/+page.svelte
Normal file
156
indexium-frontend/src/routes/collections/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { BASE, fetchCollection, collectionExportUrl, type Collection } from '$lib/api';
|
||||
|
||||
let slug = $derived($page.params.slug ?? '');
|
||||
let collection = $state<Collection | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let exporting = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
collection = await fetchCollection(slug);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function download(format: 'prism' | 'json') {
|
||||
if (!collection) return;
|
||||
exporting = true;
|
||||
try {
|
||||
const url =
|
||||
format === 'json'
|
||||
? `${BASE}/api/v1/collections/${encodeURIComponent(slug)}`
|
||||
: collectionExportUrl(slug, 'prism');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = format === 'prism' ? `${slug}.prism.json` : `${slug}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{collection?.title ?? slug} - Collections - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
<div style="margin:14px 0 8px;">
|
||||
<a href="/collections" class="mono" style="font-size:13px;text-decoration:none;opacity:.7;">← Все коллекции</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted">Загрузка…</p>
|
||||
{:else if error}
|
||||
<div class="card" style="border:1px solid #a00;color:#ffb4b4;background:#2a1212;padding:16px;">
|
||||
{error}
|
||||
{#if error.toLowerCase().includes('not found') || error.includes('404')}
|
||||
— коллекция <code class="mono">{slug}</code> не найдена.
|
||||
{/if}
|
||||
</div>
|
||||
{:else if collection}
|
||||
<div style="display:grid;grid-template-columns:1fr 320px;gap:24px;margin-top:12px;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="card" style="flex-direction:row;gap:16px;padding:18px;">
|
||||
<div
|
||||
style="width:64px;height:64px;border-radius:12px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:26px;flex:none;"
|
||||
>
|
||||
{collection.title[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<h1 style="margin:0;font-size:24px;word-break:break-word;">{collection.title}</h1>
|
||||
<p class="mono" style="margin:4px 0 0;opacity:.6;font-size:12px;">/{collection.slug} · {new Date(collection.created_at).toLocaleString()}</p>
|
||||
<p style="margin:10px 0 0;opacity:.85;white-space:pre-wrap;word-break:break-word;">
|
||||
{collection.description ?? 'Без описания'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div
|
||||
style="padding:12px 16px;border-bottom:1px solid var(--color-divider);display:flex;align-items:center;gap:10px;"
|
||||
>
|
||||
<span style="font-weight:600;">Mods · {collection.mods.length}</span>
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{collection.slug}</span>
|
||||
</div>
|
||||
{#if collection.mods.length === 0}
|
||||
<p class="text-muted" style="padding:16px;margin:0;">В коллекции пока нет модов.</p>
|
||||
{:else}
|
||||
{#each collection.mods as m}
|
||||
<div
|
||||
style="display:flex;gap:10px;padding:12px 16px;border-bottom:1px solid var(--color-divider);align-items:center;flex-wrap:wrap;"
|
||||
>
|
||||
<a href="/mod/{m.slug}" class="mono" style="font-weight:600;text-decoration:none;color:var(--color-accent);"
|
||||
>{m.slug}</a
|
||||
>
|
||||
{#if m.version}<span class="tag tag-neutral mono" style="font-size:10px;">{m.version}</span>{/if}
|
||||
<a
|
||||
href="/mod/{m.slug}"
|
||||
class="btn btn-secondary"
|
||||
style="margin-left:auto;padding:6px 10px;font-size:12px;text-decoration:none;">Открыть</a
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card" style="gap:10px;">
|
||||
<p style="margin:0;font-weight:600;">Raw JSON</p>
|
||||
<pre class="mono" style="margin:0;font-size:11px;opacity:.8;overflow:auto;background:#0B1210;padding:12px;border-radius:8px;border:1px solid var(--color-divider);">{JSON.stringify(
|
||||
collection,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside style="display:flex;flex-direction:column;gap:12px;position:sticky;top:16px;align-self:start;">
|
||||
<div class="card" style="gap:10px;">
|
||||
<button class="btn btn-primary" style="width:100%;" onclick={() => download('prism')} disabled={exporting}>
|
||||
{exporting ? 'Экспорт…' : '⤓ Экспорт Prism'}
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="width:100%;" onclick={() => download('json')} disabled={exporting}>
|
||||
⤓ Скачать JSON
|
||||
</button>
|
||||
<p class="mono" style="font-size:11px;opacity:.6;margin:0;">
|
||||
GET /api/v1/collections/{collection.slug}/export?format=prism — Prism Launcher
|
||||
</p>
|
||||
<a
|
||||
href={collectionExportUrl(collection.slug, 'prism')}
|
||||
target="_blank"
|
||||
class="mono"
|
||||
style="font-size:11px;word-break:break-all;"
|
||||
>{collectionExportUrl(collection.slug, 'prism')}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="card" style="gap:8px;">
|
||||
<p style="margin:0;font-weight:600;">Детали</p>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">ID: <span class="mono" style="font-size:11px;word-break:break-all;">{collection.id}</span></p>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">Slug: <span class="mono">{collection.slug}</span></p>
|
||||
{#if collection.author_id !== null && collection.author_id !== undefined}
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">Author ID: {collection.author_id}</p>
|
||||
{/if}
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">
|
||||
Создана: {new Date(collection.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="/collections" class="btn btn-secondary" style="width:100%;text-decoration:none;">← К списку</a>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
78
indexium-frontend/src/routes/mod/[slug]/+page.svelte
Normal file
78
indexium-frontend/src/routes/mod/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchMod, fetchAnalytics, type ModDetail } from '$lib/api';
|
||||
|
||||
let slug = $derived($page.params.slug ?? '');
|
||||
let mod = $state<ModDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let tab = $state<'versions' | 'analytics'>('versions');
|
||||
let analytics = $state<{ daily: {date:string,active_servers:number}[], breakdown:any } | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
mod = await fetchMod(slug);
|
||||
try { const a = await fetchAnalytics(slug, '30d'); analytics = { daily: a.daily, breakdown: a.breakdown }; } catch {}
|
||||
} catch (e:any) { error = e.message; } finally { loading = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{mod?.name ?? slug} - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
{#if loading}<p class="text-muted">Loading...</p>
|
||||
{:else if error}<div class="card" style="border:1px solid #a00;color:#a00;padding:16px;">{error}</div>
|
||||
{:else if mod}
|
||||
<div style="display:grid;grid-template-columns:1fr 320px;gap:24px;margin-top:20px;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div class="card" style="flex-direction:row;gap:16px;padding:18px;">
|
||||
<div style="width:72px;height:72px;border-radius:12px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:28px;flex:none;">{mod.name[0]}</div>
|
||||
<div style="flex:1;">
|
||||
<h1 style="margin:0;font-size:26px;">{mod.name}</h1>
|
||||
<p class="text-muted" style="margin:4px 0;">by {mod.author.login} · <a href="https://github.com/{mod.github_repo}" target="_blank">{mod.github_repo}</a> {#if mod.verified}<span class="tag tag-accent mono" style="margin-left:8px;">verified</span>{/if}</p>
|
||||
<p style="margin:8px 0 0;opacity:.85;">{mod.summary ?? ''}</p>
|
||||
<p class="text-muted" style="font-size:13px;margin-top:8px;white-space:pre-wrap;">{mod.description ?? ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seg" style="align-self:flex-start;">
|
||||
<label class="seg-opt"><input type="radio" name="tab" checked={tab==='versions'} onchange={()=>tab='versions'} /><span class="mono">Versions</span></label>
|
||||
<label class="seg-opt"><input type="radio" name="tab" checked={tab==='analytics'} onchange={()=>tab='analytics'} /><span class="mono">Analytics</span></label>
|
||||
</div>
|
||||
{#if tab==='versions'}
|
||||
<div class="card" style="padding:0;overflow:hidden;">
|
||||
<div style="padding:12px 16px;border-bottom:1px solid var(--color-divider);font-weight:600;">Versions · {mod.versions.length}</div>
|
||||
{#each mod.versions as v}
|
||||
<div style="display:flex;gap:12px;padding:12px 16px;border-bottom:1px solid var(--color-divider);flex-wrap:wrap;align-items:center;">
|
||||
<span class="mono" style="font-weight:600;">{v.version_number}</span>
|
||||
{#each v.game_versions as gv}<span class="tag tag-neutral mono" style="font-size:10px;">{gv}</span>{/each}
|
||||
{#each v.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;">{l}</span>{/each}
|
||||
<span class="mono text-muted" style="margin-left:auto;font-size:11px;">{new Date(v.published_at).toLocaleDateString()}</span>
|
||||
<a href={v.download_url} class="btn btn-secondary" style="padding:6px 12px;font-size:12px;" target="_blank">Download</a>
|
||||
</div>
|
||||
{:else}<p class="text-muted" style="padding:16px;">No versions yet.</p>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card" style="gap:12px;">
|
||||
<h3 style="margin:0;">Active servers (bStats)</h3>
|
||||
{#if analytics?.daily?.length}<div style="display:flex;gap:4px;align-items:end;height:60px;">{#each analytics.daily.slice(-14) as d}<div style="flex:1;background:var(--color-accent);border-radius:4px 4px 0 0;height:{Math.max(8, d.active_servers % 60)}px;"></div>{/each}</div>{:else}<p class="text-muted">No analytics yet - add Indexium SDK to your mod.</p>{/if}
|
||||
<pre class="mono" style="font-size:11px;opacity:.7;overflow:auto;">{JSON.stringify(analytics?.breakdown ?? {}, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<aside style="display:flex;flex-direction:column;gap:12px;position:sticky;top:76px;">
|
||||
<div class="card" style="gap:10px;">
|
||||
<button class="btn btn-primary" style="width:100%;">Install</button>
|
||||
<a href={mod.versions[0]?.download_url ?? '#'} class="btn btn-secondary" style="width:100%;" target="_blank">Download jar</a>
|
||||
<div class="mono" style="font-size:11px;opacity:.6;">SHA256: {mod.versions[0]?.file_sha256?.slice(0,16) ?? '-'}…</div>
|
||||
</div>
|
||||
<div class="card" style="gap:8px;">
|
||||
<p style="margin:0;font-weight:600;">Details</p>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">Repo: {mod.github_repo}</p>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">Updated: {new Date(mod.updated_at).toLocaleDateString()}</p>
|
||||
<img src="/api/v1/badges/{mod.slug}/downloads.svg" alt="downloads badge" style="margin-top:8px;max-width:100%;" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
115
indexium-frontend/src/routes/mods/+page.svelte
Normal file
115
indexium-frontend/src/routes/mods/+page.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchMods, type ModListItem } from '$lib/api';
|
||||
|
||||
let query = $state('');
|
||||
let gameVersion = $state<string | null>(null);
|
||||
let loader = $state<string | null>(null);
|
||||
let category = $state<string | null>(null);
|
||||
let sort: 'relevance' | 'newest' | 'popular' = $state('relevance');
|
||||
let page = $state(1);
|
||||
let mods = $state<ModListItem[]>([]);
|
||||
let total = $state(0);
|
||||
let pages = $state(1);
|
||||
let loading = $state(true);
|
||||
let versionFilter = $state('');
|
||||
let showAllVersions = $state(false);
|
||||
|
||||
const gameVersions = ['1.21.1','1.21','1.20.1','1.20','1.19.4','1.19.2','1.18.2','1.17.1','1.16.5','1.12.2'];
|
||||
const loaders = ['fabric','forge','neoforge','quilt'];
|
||||
const categories = ['Optimization','Library','Utility','Adventure','Technology','World Generation'];
|
||||
let filteredVersions = $derived(gameVersions.filter(v => !versionFilter || v.includes(versionFilter)));
|
||||
let visibleVersions = $derived(showAllVersions ? filteredVersions : filteredVersions.slice(0,4));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetchMods({ query: query || undefined, gameVersion: gameVersion ?? undefined, loader: loader ?? undefined, page, limit: 20, sort });
|
||||
mods = res.data; total = res.pagination.total; pages = res.pagination.pages;
|
||||
} catch { mods = []; } finally { loading = false; }
|
||||
}
|
||||
onMount(load);
|
||||
function toggle(v: string | null, setter: (x: string | null) => void, cur: string | null) { setter(cur === v ? null : v); load(); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Discover mods - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
<div style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;">
|
||||
<a href="/mods" class="mono" style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;">Mods</a>
|
||||
<a href="/plugins" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;">Plugins</a>
|
||||
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.5;font-size:13px;">Resource Packs</a>
|
||||
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.5;font-size:13px;">Shaders</a>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;">
|
||||
<div style="position:relative;flex:1;max-width:560px;">
|
||||
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
|
||||
<input class="input" placeholder="Search mods..." style="padding-left:36px;height:40px;" bind:value={query} oninput={load} />
|
||||
</div>
|
||||
<select class="input" style="width:160px;height:40px;" bind:value={sort} onchange={load}>
|
||||
<option value="relevance">Relevance</option><option value="newest">Newest</option><option value="popular">Popular</option>
|
||||
</select>
|
||||
<span class="mono" style="opacity:.6;font-size:12px;">{total} results</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:260px 1fr;gap:24px;align-items:start;">
|
||||
<!-- filters sidebar modrinth-like -->
|
||||
<aside style="position:sticky;top:76px;display:flex;flex-direction:column;gap:18px;">
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Game version</p>
|
||||
<div style="position:relative;">
|
||||
<input class="input" placeholder="Filter versions..." style="height:32px;font-size:13px;padding-right:28px;" bind:value={versionFilter} />
|
||||
{#if versionFilter}<button onclick={() => versionFilter=''} style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;opacity:.5;">✕</button>{/if}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;max-height:180px;overflow:auto;">
|
||||
{#each visibleVersions as v}<label class="radio" style="font-size:13px;"><input type="radio" name="gv" checked={gameVersion===v} onchange={() => toggle(v, (x)=>gameVersion=x, gameVersion)} /><span class="dot"></span>{v}</label>{/each}
|
||||
{#if !showAllVersions && filteredVersions.length > 4}<button class="mono" onclick={() => showAllVersions = true} style="text-align:left;font-size:12px;color:var(--color-accent);background:none;border:none;cursor:pointer;padding:4px 0;">... +{filteredVersions.length - 4} еще</button>{/if}
|
||||
{#if showAllVersions}<button class="mono" onclick={() => showAllVersions = false} style="text-align:left;font-size:12px;opacity:.6;background:none;border:none;cursor:pointer;padding:4px 0;">свернуть</button>{/if}
|
||||
{#if filteredVersions.length===0}<span class="mono text-muted" style="font-size:12px;">нет совпадений</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Loader</p>
|
||||
{#each loaders as l}<label class="radio" style="font-size:13px;"><input type="radio" name="loader" checked={loader===l} onchange={() => toggle(l, (x)=>loader=x, loader)} /><span class="dot"></span>{l}</label>{/each}
|
||||
</div>
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Category</p>
|
||||
{#each categories as c}<label class="radio" style="font-size:13px;"><input type="radio" name="cat" checked={category===c} onchange={() => toggle(c, (x)=>category=x, category)} /><span class="dot"></span>{c}</label>{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- list -->
|
||||
<main style="display:flex;flex-direction:column;gap:12px;">
|
||||
{#if loading}
|
||||
{#each Array(6) as _}<div class="card" style="height:110px;animation:shimmer 1.4s infinite linear;"></div>{/each}
|
||||
{:else if mods.length===0}
|
||||
<div class="card" style="padding:32px;text-align:center;opacity:.7;">No mods found - try different filters or <a href="/publish">publish</a> one.</div>
|
||||
{:else}
|
||||
{#each mods as m}
|
||||
<a href="/mod/{m.slug}" class="card" style="flex-direction:row;gap:14px;padding:14px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;">
|
||||
<div style="width:64px;height:64px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:20px;flex:none;">{m.name[0]}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:15px;">{m.name}</span>
|
||||
<span class="text-muted" style="font-size:13px;">by {m.author}</span>
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{new Date(m.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p style="margin:4px 0 6px;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{m.summary ?? 'No description'}</p>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
{#each m.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{l}</span>{/each}
|
||||
{#each m.game_versions.slice(0,3) as gv}<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{gv}</span>{/each}
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{m.latest_version ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;">
|
||||
<button class="btn btn-secondary" disabled={page<=1} onclick={() => {page--; load();}}>Prev</button>
|
||||
<span class="mono" style="padding:8px 12px;opacity:.7;">{page} / {pages}</span>
|
||||
<button class="btn btn-secondary" disabled={page>=pages} onclick={() => {page++; load();}}>Next</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
114
indexium-frontend/src/routes/plugins/+page.svelte
Normal file
114
indexium-frontend/src/routes/plugins/+page.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchMods, type ModListItem } from '$lib/api';
|
||||
|
||||
let query = $state('');
|
||||
let gameVersion = $state<string | null>(null);
|
||||
let loader = $state<string | null>(null);
|
||||
let category = $state<string | null>(null);
|
||||
let sort: 'relevance' | 'newest' | 'popular' = $state('relevance');
|
||||
let page = $state(1);
|
||||
let mods = $state<ModListItem[]>([]);
|
||||
let total = $state(0);
|
||||
let pages = $state(1);
|
||||
let loading = $state(true);
|
||||
let versionFilter = $state('');
|
||||
let showAllVersions = $state(false);
|
||||
|
||||
const gameVersions = ['1.21.1','1.21','1.20.1','1.20','1.19.4','1.19.2','1.18.2','1.17.1','1.16.5','1.12.2'];
|
||||
const loaders = ['paper','purpur','spigot','bukkit','velocity','waterfall','folia','pumpkin'];
|
||||
const categories = ['Admin','Economy','Fun','World Management','Chat','Mechanics'];
|
||||
let filteredVersions = $derived(gameVersions.filter(v => !versionFilter || v.includes(versionFilter)));
|
||||
let visibleVersions = $derived(showAllVersions ? filteredVersions : filteredVersions.slice(0,4));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetchMods({ query: query || undefined, gameVersion: gameVersion ?? undefined, loader: loader ?? undefined, page, limit: 20, sort });
|
||||
mods = res.data; total = res.pagination.total; pages = res.pagination.pages;
|
||||
} catch { mods = []; } finally { loading = false; }
|
||||
}
|
||||
onMount(load);
|
||||
function toggle(v: string | null, setter: (x: string | null) => void, cur: string | null) { setter(cur === v ? null : v); load(); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Discover plugins - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
<div style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;">
|
||||
<a href="/mods" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;">Mods</a>
|
||||
<a href="/plugins" class="mono" style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;">Plugins</a>
|
||||
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.6;font-size:13px;">Resource Packs</a>
|
||||
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.6;font-size:13px;">Shaders</a>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;">
|
||||
<div style="position:relative;flex:1;max-width:560px;">
|
||||
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
|
||||
<input class="input" placeholder="Search plugins..." style="padding-left:36px;height:40px;" bind:value={query} oninput={load} />
|
||||
</div>
|
||||
<select class="input" style="width:160px;height:40px;" bind:value={sort} onchange={load}>
|
||||
<option value="relevance">Relevance</option><option value="newest">Newest</option><option value="popular">Popular</option>
|
||||
</select>
|
||||
<span class="mono" style="opacity:.6;font-size:12px;">{total} results</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:260px 1fr;gap:24px;align-items:start;">
|
||||
<aside style="position:sticky;top:76px;display:flex;flex-direction:column;gap:18px;">
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Game version</p>
|
||||
<div style="position:relative;">
|
||||
<input class="input" placeholder="Filter versions..." style="height:32px;font-size:13px;padding-right:28px;" bind:value={versionFilter} />
|
||||
{#if versionFilter}<button onclick={() => versionFilter=''} style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;opacity:.5;">✕</button>{/if}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;max-height:180px;overflow:auto;">
|
||||
{#each visibleVersions as v}<label class="radio" style="font-size:13px;"><input type="radio" name="gv" checked={gameVersion===v} onchange={() => toggle(v, (x)=>gameVersion=x, gameVersion)} /><span class="dot"></span>{v}</label>{/each}
|
||||
{#if !showAllVersions && filteredVersions.length > 4}<button class="mono" onclick={() => showAllVersions = true} style="text-align:left;font-size:12px;color:var(--color-accent);background:none;border:none;cursor:pointer;padding:4px 0;">... +{filteredVersions.length - 4} еще</button>{/if}
|
||||
{#if showAllVersions}<button class="mono" onclick={() => showAllVersions = false} style="text-align:left;font-size:12px;opacity:.6;background:none;border:none;cursor:pointer;padding:4px 0;">свернуть</button>{/if}
|
||||
{#if filteredVersions.length===0}<span class="mono text-muted" style="font-size:12px;">нет совпадений</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Server software</p>
|
||||
{#each loaders as l}<label class="radio" style="font-size:13px;"><input type="radio" name="loader" checked={loader===l} onchange={() => toggle(l, (x)=>loader=x, loader)} /><span class="dot"></span>{l}</label>{/each}
|
||||
</div>
|
||||
<div class="card" style="padding:14px;gap:10px;">
|
||||
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Category</p>
|
||||
{#each categories as c}<label class="radio" style="font-size:13px;"><input type="radio" name="cat" checked={category===c} onchange={() => toggle(c, (x)=>category=x, category)} /><span class="dot"></span>{c}</label>{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- list -->
|
||||
<main style="display:flex;flex-direction:column;gap:12px;">
|
||||
{#if loading}
|
||||
{#each Array(6) as _}<div class="card" style="height:110px;"></div>{/each}
|
||||
{:else if mods.length===0}
|
||||
<div class="card" style="padding:32px;text-align:center;opacity:.7;">No plugins found - try different filters. Plugins are separate from mods (Paper/Purpur etc). <a href="/publish">Publish</a> one.</div>
|
||||
{:else}
|
||||
{#each mods as m}
|
||||
<a href="/mod/{m.slug}" class="card" style="flex-direction:row;gap:14px;padding:14px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;">
|
||||
<div style="width:64px;height:64px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:20px;flex:none;">{m.name[0]}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:15px;">{m.name}</span>
|
||||
<span class="text-muted" style="font-size:13px;">by {m.author}</span>
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{new Date(m.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p style="margin:4px 0 6px;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{m.summary ?? 'No description'}</p>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
{#each m.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{l}</span>{/each}
|
||||
{#each m.game_versions.slice(0,3) as gv}<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{gv}</span>{/each}
|
||||
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{m.latest_version ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;">
|
||||
<button class="btn btn-secondary" disabled={page<=1} onclick={() => {page--; load();}}>Prev</button>
|
||||
<span class="mono" style="padding:8px 12px;opacity:.7;">{page} / {pages}</span>
|
||||
<button class="btn btn-secondary" disabled={page>=pages} onclick={() => {page++; load();}}>Next</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
65
indexium-frontend/src/routes/publish/+page.svelte
Normal file
65
indexium-frontend/src/routes/publish/+page.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let repo = $state('');
|
||||
let status = $state<'idle'|'loading'|'ok'|'error'>('idle');
|
||||
let msg = $state('');
|
||||
let isAuthed = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await fetch('http://localhost:8080/api/v1/auth/me', { credentials: 'include' });
|
||||
isAuthed = r.ok;
|
||||
} catch { isAuthed = false; }
|
||||
});
|
||||
|
||||
async function publish() {
|
||||
if (!isAuthed) { window.location.href = 'http://localhost:8080/api/v1/auth/github'; return; }
|
||||
status='loading'; msg='';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8080/api/v1/mods/import`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include',
|
||||
body: JSON.stringify({ repo })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Failed');
|
||||
status='ok'; msg=`Imported ${repo} - ${data.slug ?? 'ok'}`;
|
||||
} catch(e:any){ status='error'; msg=e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Publish mod - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:720px;margin:0 auto;padding:32px clamp(16px,4vw,32px);">
|
||||
<h1 style="margin:0 0 8px;">Publish your mod</h1>
|
||||
<p class="text-muted" style="margin:0 0 24px;">Open-source only. We index your GitHub Releases - no file upload.</p>
|
||||
|
||||
{#if !isAuthed}
|
||||
<div class="card" style="gap:14px;padding:20px;border:1px solid var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface));">
|
||||
<p style="margin:0;font-weight:600;">Требуется вход через GitHub</p>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">Публикация доступна только авторам. Мы проверим что репо твое, публичное и с LICENSE.</p>
|
||||
<a href="http://localhost:8080/api/v1/auth/github" class="btn btn-primary" style="align-self:flex-start;">Войти через GitHub - Publish</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card" style="gap:16px;">
|
||||
<label style="display:flex;flex-direction:column;gap:6px;">
|
||||
<span class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">GitHub repo</span>
|
||||
<input class="input" placeholder="owner/repo e.g. jellysquid3/sodium" bind:value={repo} />
|
||||
<span class="text-muted" style="font-size:12px;">Repo must be public, have LICENSE and fabric.mod.json / mods.toml in default branch.</span>
|
||||
</label>
|
||||
<button class="btn btn-primary" style="align-self:flex-start;" onclick={publish} disabled={status==='loading' || !repo.includes('/')}>
|
||||
{status==='loading' ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
{#if msg}<div class="card" style="padding:12px;background:{status==='ok' ? 'color-mix(in srgb,var(--color-accent) 12%,var(--color-surface))' : '#3a1a1a'};border:1px solid {status==='ok' ? 'var(--color-accent)' : '#a00'};">{msg}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card" style="margin-top:18px;gap:12px;">
|
||||
<h3 style="margin:0;font-size:16px;">How it works</h3>
|
||||
<ol style="margin:0;padding-left:18px;opacity:.8;font-size:14px;line-height:1.6;">
|
||||
<li>Login with GitHub (read:user)</li>
|
||||
<li>Paste <code class="mono">owner/repo</code> → we verify LICENSE + manifest</li>
|
||||
<li>Install webhook → every release is indexed via Range-Request (no full download)</li>
|
||||
</ol>
|
||||
<p class="text-muted" style="margin:0;font-size:13px;">See <a href="/docs">docs</a> and <a href="https://github.com/loki5512344/Indexium" target="_blank">GitHub</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
28
indexium-frontend/src/routes/u/[login]/+page.svelte
Normal file
28
indexium-frontend/src/routes/u/[login]/+page.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchMods } from '$lib/api';
|
||||
let login = $derived($page.params.login ?? 'unknown');
|
||||
let mods: any[] = $state([]);
|
||||
let loading = $state(true);
|
||||
onMount(async () => {
|
||||
try { const r = await fetchMods({ query: login }); mods = r.data.filter(m => m.author.toLowerCase()===login.toLowerCase()); } catch {} finally { loading=false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{login} - Indexium</title></svelte:head>
|
||||
|
||||
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
|
||||
<div class="card" style="flex-direction:row;gap:16px;padding:20px;margin-top:20px;">
|
||||
<div style="width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:28px;flex:none;">{login[0]?.toUpperCase()}</div>
|
||||
<div>
|
||||
<h1 style="margin:0;">{login}</h1>
|
||||
<p class="text-muted" style="margin:4px 0;"><a href="https://github.com/{login}" target="_blank">github.com/{login}</a> · Indexium author</p>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;"><span class="tag tag-accent mono">verified</span><span class="tag tag-neutral mono">{mods.length} mods</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 style="margin:24px 0 12px;">Mods by {login}</h2>
|
||||
{#if loading}<p class="text-muted">Loading…</p>
|
||||
{:else if mods.length===0}<div class="card" style="padding:24px;text-align:center;opacity:.7;">No mods yet. <a href="/publish">Publish</a> one.</div>
|
||||
{:else}<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;">{#each mods as m}<a href="/mod/{m.slug}" class="card" style="text-decoration:none;color:inherit;"><span style="font-weight:600;">{m.name}</span><span class="text-muted" style="font-size:13px;">{m.summary ?? ''}</span></a>{/each}</div>{/if}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue