diff --git a/.gitignore b/.gitignore
index 1e7ad08..dbc949a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,7 @@
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
-data/settings.db
+data/*.db
# prisma generated client
/prisma/generated/
@@ -27,6 +27,9 @@ data/ssh-keys/
/out/
next-env.d.ts
+# cached logos (downloaded at runtime)
+/public/logos/
+
# production
/build
diff --git a/package.json b/package.json
index aeb75eb..f3c336d 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "build": "prisma generate && next build --webpack",
+ "build": "prisma generate && node --import tsx scripts/cache-logos.ts && next build --webpack",
"check": "eslint . && tsc --noEmit",
"dev": "next dev --webpack",
"dev:server": "node --import tsx server.js",
diff --git a/scripts/cache-logos.ts b/scripts/cache-logos.ts
new file mode 100644
index 0000000..ce7a835
--- /dev/null
+++ b/scripts/cache-logos.ts
@@ -0,0 +1,32 @@
+/**
+ * Build-time script: fetch all logos from PocketBase and cache them to public/logos/.
+ * Called as part of `npm run build` so the app starts with logos pre-cached.
+ */
+
+import { getPb } from '../src/server/services/pbService';
+import { cacheLogos } from '../src/server/services/logoCacheService';
+
+async function main() {
+ console.log('[cache-logos] Fetching script list from PocketBase...');
+ const pb = getPb();
+ const records = await pb.collection('script_scripts').getFullList({
+ fields: 'slug,logo',
+ batch: 500,
+ });
+
+ const entries = records
+ .filter((r) => r.logo)
+ .map((r) => ({ slug: r.slug, url: r.logo }));
+
+ console.log(`[cache-logos] Caching ${entries.length} logos...`);
+ const result = await cacheLogos(entries);
+ console.log(
+ `[cache-logos] Done: ${result.downloaded} downloaded, ${result.skipped} already cached, ${result.errors} errors`,
+ );
+}
+
+main().catch((err) => {
+ console.error('[cache-logos] Failed:', err);
+ // Non-fatal — build should continue even if logo caching fails
+ process.exit(0);
+});
diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx
index a9146de..6ea0cb2 100644
--- a/src/app/_components/GeneralSettingsModal.tsx
+++ b/src/app/_components/GeneralSettingsModal.tsx
@@ -1210,7 +1210,7 @@ export function GeneralSettingsModal({
Enable Auto-Sync
- Automatically sync JSON files from GitHub at specified
+ Automatically sync scripts from PocketBase at specified
intervals
diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx
index 6ba6171..6d94830 100644
--- a/src/app/_components/ResyncButton.tsx
+++ b/src/app/_components/ResyncButton.tsx
@@ -104,7 +104,7 @@ export function ResyncButton() {
return (
- Sync scripts with configured repositories
+ Sync scripts and cache logos locally
@@ -125,7 +125,7 @@ export function ResyncButton() {
-
Sync Json Files
+
Sync Scripts
>
)}
diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx
index 07a703b..b744fd0 100644
--- a/src/app/_components/ScriptDetailModal.tsx
+++ b/src/app/_components/ScriptDetailModal.tsx
@@ -808,6 +808,16 @@ export function ScriptDetailModal({
+ {method.config_path && (
+
+
+ Config Path
+
+
+ {method.config_path}
+
+
+ )}
))}
diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts
index c7d0b3b..118f7cb 100644
--- a/src/server/api/routers/scripts.ts
+++ b/src/server/api/routers/scripts.ts
@@ -16,6 +16,7 @@ import {
} from "~/server/services/pbScripts";
import type { Script, ScriptCard } from "~/types/script";
import type { Server } from "~/types/server";
+import { cacheLogos, getLocalLogoPath } from "~/server/services/logoCacheService";
// ---------------------------------------------------------------------------
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)
@@ -177,7 +178,14 @@ export const scriptsRouter = createTRPCRouter({
.query(async () => {
try {
const cards = await getScriptCards();
- return { success: true, cards: cards.map(pbCardToScriptCard) };
+ return {
+ success: true,
+ cards: cards.map((c) => {
+ const card = pbCardToScriptCard(c);
+ card.logo = getLocalLogoPath(c.slug, card.logo);
+ return card;
+ }),
+ };
} catch (error) {
console.error('Error in getScriptCards:', error);
return {
@@ -212,7 +220,9 @@ export const scriptsRouter = createTRPCRouter({
if (!pb) {
return { success: false, error: 'Script not found', script: null };
}
- return { success: true, script: pbToScript(pb) };
+ const script = pbToScript(pb);
+ script.logo = getLocalLogoPath(pb.slug, script.logo);
+ return { success: true, script };
} catch (error) {
console.error('Error in getScriptBySlug:', error);
return {
@@ -245,7 +255,11 @@ export const scriptsRouter = createTRPCRouter({
try {
// PocketBase already returns category names expanded on each card
const cards = await getScriptCards();
- const scriptCards = cards.map(pbCardToScriptCard);
+ const scriptCards = cards.map((c) => {
+ const card = pbCardToScriptCard(c);
+ card.logo = getLocalLogoPath(c.slug, card.logo);
+ return card;
+ });
// Also return the category list for the sidebar filter
const metadata = await pbGetMetadata();
@@ -262,15 +276,29 @@ export const scriptsRouter = createTRPCRouter({
}
}),
- // PocketBase is always up to date – this is a no-op kept for API compatibility
+ // Sync: cache logos locally from PocketBase script data
resyncScripts: publicProcedure
.mutation(async () => {
- return {
- success: true,
- message: 'Script catalog is served directly from PocketBase and is always up to date.',
- count: 0,
- error: undefined as string | undefined,
- };
+ try {
+ const cards = await getScriptCards();
+ const entries = cards
+ .filter((c) => c.logo)
+ .map((c) => ({ slug: c.slug, url: c.logo! }));
+ const result = await cacheLogos(entries);
+ return {
+ success: true,
+ message: `Logo cache updated: ${result.downloaded} downloaded, ${result.skipped} cached, ${result.errors} errors.`,
+ count: result.downloaded,
+ error: undefined as string | undefined,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ message: 'Failed to sync logos',
+ count: 0,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
}),
// Load script files from the community repository
diff --git a/src/server/services/autoSyncService.js b/src/server/services/autoSyncService.js
index 4b1ff1f..0d94ba4 100644
--- a/src/server/services/autoSyncService.js
+++ b/src/server/services/autoSyncService.js
@@ -360,6 +360,18 @@ export class AutoSyncService {
const pbScripts = await pbGetAllScripts();
console.log(`Retrieved ${pbScripts.length} scripts from PocketBase`);
+ // Step 1b: Cache logos locally
+ try {
+ const { cacheLogos } = await import('./logoCacheService');
+ const logoEntries = pbScripts
+ .filter(pb => pb.logo)
+ .map(pb => ({ slug: pb.slug, url: /** @type {string} */ (pb.logo) }));
+ const logoResult = await cacheLogos(logoEntries);
+ console.log(`Logo cache: ${logoResult.downloaded} new, ${logoResult.skipped} cached, ${logoResult.errors} errors`);
+ } catch (logoErr) {
+ console.warn('Logo caching failed (non-fatal):', logoErr);
+ }
+
// Map PocketBase records to the internal Script format used by scriptDownloader
const { scriptDownloaderService: sds } = await import('./scriptDownloader.js');
const allScripts = pbScripts.map(pb => ({
diff --git a/src/server/services/logoCacheService.ts b/src/server/services/logoCacheService.ts
new file mode 100644
index 0000000..0ba3920
--- /dev/null
+++ b/src/server/services/logoCacheService.ts
@@ -0,0 +1,129 @@
+/**
+ * Logo cache service — downloads script logos to public/logos/ so they can be
+ * served locally by Next.js instead of fetching from remote CDNs on every request.
+ *
+ * Logos are stored as `public/logos/{slug}.webp` (keeping original extension when not webp).
+ * ScriptCard / ScriptDetailModal can then use `/logos/{slug}.{ext}` as the src.
+ */
+
+import { existsSync, mkdirSync } from 'fs';
+import { writeFile, readdir, unlink } from 'fs/promises';
+import { join, extname } from 'path';
+
+const LOGOS_DIR = join(process.cwd(), 'public', 'logos');
+
+/** Ensure the logos directory exists. */
+function ensureLogosDir(): void {
+ if (!existsSync(LOGOS_DIR)) {
+ mkdirSync(LOGOS_DIR, { recursive: true });
+ }
+}
+
+/** Extract a reasonable file extension from a logo URL. */
+function getExtension(url: string): string {
+ try {
+ const pathname = new URL(url).pathname;
+ const ext = extname(pathname).toLowerCase();
+ if (['.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif', '.ico'].includes(ext)) {
+ return ext;
+ }
+ } catch { /* invalid URL */ }
+ return '.webp'; // default
+}
+
+export interface LogoEntry {
+ slug: string;
+ url: string;
+}
+
+/**
+ * Download logos for the given scripts to `public/logos/`.
+ * Skips logos that already exist locally unless `force` is set.
+ * Returns the number of newly downloaded logos.
+ */
+export async function cacheLogos(
+ entries: LogoEntry[],
+ options?: { force?: boolean; concurrency?: number }
+): Promise<{ downloaded: number; skipped: number; errors: number }> {
+ ensureLogosDir();
+
+ const force = options?.force ?? false;
+ const concurrency = options?.concurrency ?? 10;
+ let downloaded = 0;
+ let skipped = 0;
+ let errors = 0;
+
+ // Process in batches of `concurrency`
+ for (let i = 0; i < entries.length; i += concurrency) {
+ const batch = entries.slice(i, i + concurrency);
+ const results = await Promise.allSettled(
+ batch.map(async (entry) => {
+ if (!entry.url) {
+ skipped++;
+ return;
+ }
+
+ const ext = getExtension(entry.url);
+ const filename = `${entry.slug}${ext}`;
+ const filepath = join(LOGOS_DIR, filename);
+
+ if (!force && existsSync(filepath)) {
+ skipped++;
+ return;
+ }
+
+ const response = await fetch(entry.url, {
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status} for ${entry.url}`);
+ }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ await writeFile(filepath, buffer);
+ downloaded++;
+ }),
+ );
+
+ for (const r of results) {
+ if (r.status === 'rejected') {
+ errors++;
+ }
+ }
+ }
+
+ return { downloaded, skipped, errors };
+}
+
+/**
+ * Given a remote logo URL and a slug, return the local path if the logo
+ * has been cached, otherwise return the original URL.
+ */
+export function getLocalLogoPath(slug: string, remoteUrl: string | null): string | null {
+ if (!remoteUrl) return null;
+ const ext = getExtension(remoteUrl);
+ const filename = `${slug}${ext}`;
+ const filepath = join(LOGOS_DIR, filename);
+ if (existsSync(filepath)) {
+ return `/logos/${filename}`;
+ }
+ return remoteUrl;
+}
+
+/**
+ * Clean up logos for scripts that no longer exist.
+ */
+export async function cleanupOrphanedLogos(activeSlugs: Set): Promise {
+ ensureLogosDir();
+ let removed = 0;
+ try {
+ const files = await readdir(LOGOS_DIR);
+ for (const file of files) {
+ const slug = file.replace(/\.[^.]+$/, '');
+ if (!activeSlugs.has(slug)) {
+ await unlink(join(LOGOS_DIR, file));
+ removed++;
+ }
+ }
+ } catch { /* directory may not exist yet */ }
+ return removed;
+}