Compare commits
1 Commits
main
...
preserve_i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0338db4026 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,7 +14,7 @@
|
|||||||
/prisma/db.sqlite
|
/prisma/db.sqlite
|
||||||
/prisma/db.sqlite-journal
|
/prisma/db.sqlite-journal
|
||||||
db.sqlite
|
db.sqlite
|
||||||
data/settings.db
|
data/*.db
|
||||||
|
|
||||||
# prisma generated client
|
# prisma generated client
|
||||||
/prisma/generated/
|
/prisma/generated/
|
||||||
@@ -27,6 +27,9 @@ data/ssh-keys/
|
|||||||
/out/
|
/out/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# cached logos (downloaded at runtime)
|
||||||
|
/public/logos/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && next build --webpack",
|
"build": "prisma generate && node --import tsx scripts/cache-logos.ts && next build --webpack",
|
||||||
"check": "eslint . && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"dev:server": "node --import tsx server.js",
|
"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
|
Enable Auto-Sync
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Automatically sync JSON files from GitHub at specified
|
Automatically sync scripts from PocketBase at specified
|
||||||
intervals
|
intervals
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function ResyncButton() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Sync scripts with configured repositories
|
Sync scripts and cache logos locally
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span>Sync Json Files</span>
|
<span>Sync Scripts</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -808,6 +808,16 @@ export function ScriptDetailModal({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "~/server/services/pbScripts";
|
} from "~/server/services/pbScripts";
|
||||||
import type { Script, ScriptCard } from "~/types/script";
|
import type { Script, ScriptCard } from "~/types/script";
|
||||||
import type { Server } from "~/types/server";
|
import type { Server } from "~/types/server";
|
||||||
|
import { cacheLogos, getLocalLogoPath } from "~/server/services/logoCacheService";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)
|
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)
|
||||||
@@ -177,7 +178,14 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const cards = await getScriptCards();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error in getScriptCards:', error);
|
console.error('Error in getScriptCards:', error);
|
||||||
return {
|
return {
|
||||||
@@ -212,7 +220,9 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
if (!pb) {
|
if (!pb) {
|
||||||
return { success: false, error: 'Script not found', script: null };
|
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) {
|
} catch (error) {
|
||||||
console.error('Error in getScriptBySlug:', error);
|
console.error('Error in getScriptBySlug:', error);
|
||||||
return {
|
return {
|
||||||
@@ -245,7 +255,11 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
// PocketBase already returns category names expanded on each card
|
// PocketBase already returns category names expanded on each card
|
||||||
const cards = await getScriptCards();
|
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
|
// Also return the category list for the sidebar filter
|
||||||
const metadata = await pbGetMetadata();
|
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
|
resyncScripts: publicProcedure
|
||||||
.mutation(async () => {
|
.mutation(async () => {
|
||||||
return {
|
try {
|
||||||
success: true,
|
const cards = await getScriptCards();
|
||||||
message: 'Script catalog is served directly from PocketBase and is always up to date.',
|
const entries = cards
|
||||||
count: 0,
|
.filter((c) => c.logo)
|
||||||
error: undefined as string | undefined,
|
.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
|
// Load script files from the community repository
|
||||||
|
|||||||
@@ -360,6 +360,18 @@ export class AutoSyncService {
|
|||||||
const pbScripts = await pbGetAllScripts();
|
const pbScripts = await pbGetAllScripts();
|
||||||
console.log(`Retrieved ${pbScripts.length} scripts from PocketBase`);
|
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
|
// Map PocketBase records to the internal Script format used by scriptDownloader
|
||||||
const { scriptDownloaderService: sds } = await import('./scriptDownloader.js');
|
const { scriptDownloaderService: sds } = await import('./scriptDownloader.js');
|
||||||
const allScripts = pbScripts.map(pb => ({
|
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