mirror of
https://github.com/community-scripts/ProxmoxVE-Local.git
synced 2026-03-31 06:23:54 -04:00
feat: cache logos locally, show config_path, rename Sync button
- 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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
32
scripts/cache-logos.ts
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
129
src/server/services/logoCacheService.ts
Normal file
129
src/server/services/logoCacheService.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user