feat: cache logos locally, show config_path, rename Sync button (#511)

- logoCacheService.ts: download script logos to public/logos/ for local serving
- cache-logos.ts: build-time script caching 500+ logos from PocketBase
- scripts.ts router: resolve local logo paths, resyncScripts now caches logos
- autoSyncService.js: cache logos during background auto-sync
- ScriptDetailModal: show config_path per install method
- ResyncButton: renamed 'Sync Json Files' to 'Sync Scripts'
- GeneralSettingsModal: updated auto-sync description text
- .gitignore: ignore public/logos/ and data/*.db
This commit is contained in:
CanbiZ (MickLesk)
2026-03-17 16:35:03 +01:00
committed by GitHub
parent d8e92e0445
commit e2a950da58
9 changed files with 229 additions and 15 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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",

32
scripts/cache-logos.ts Normal file
View File

@@ -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);
});

View File

@@ -1210,7 +1210,7 @@ export function GeneralSettingsModal({
Enable Auto-Sync
</h4>
<p className="text-muted-foreground text-sm">
Automatically sync JSON files from GitHub at specified
Automatically sync scripts from PocketBase at specified
intervals
</p>
</div>

View File

@@ -104,7 +104,7 @@ export function ResyncButton() {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Sync scripts with configured repositories
Sync scripts and cache logos locally
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-2">
@@ -125,7 +125,7 @@ export function ResyncButton() {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Sync Json Files</span>
<span>Sync Scripts</span>
</>
)}
</Button>

View File

@@ -808,6 +808,16 @@ export function ScriptDetailModal({
</dd>
</div>
</div>
{method.config_path && (
<div className="mt-2 text-xs sm:text-sm">
<dt className="text-muted-foreground font-medium">
Config Path
</dt>
<dd className="text-foreground font-mono text-xs break-all">
{method.config_path}
</dd>
</div>
)}
</div>
))}
</div>

View File

@@ -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 () => {
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: 'Script catalog is served directly from PocketBase and is always up to date.',
count: 0,
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

View File

@@ -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 => ({

View File

@@ -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<string>): Promise<number> {
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;
}