From 996fce1e11065ab32af1fa4b717cacd2a9381311 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Wed, 10 Sep 2025 14:37:37 +0200 Subject: [PATCH 01/18] feat: Add GitHub script list functionality with cards and modal - Add JSON_FOLDER environment variable for GitHub repo JSON path - Create TypeScript types for script JSON structure - Implement GitHub API service to fetch scripts from repository - Add tRPC routes for script management (getScriptCards, getAllScripts, getScriptBySlug, resyncScripts) - Create ScriptCard component for displaying script information - Create ScriptDetailModal for full script details view - Create ScriptsGrid component with loading and error states - Add ResyncButton component for refreshing scripts from upstream - Update main page with tabbed interface (GitHub Scripts vs Local Scripts) - Add proper error handling and loading states throughout --- scripts/json/debian.json | 35 ++++ src/app/_components/ResyncButton.tsx | 79 ++++++++ src/app/_components/ScriptCard.tsx | 88 ++++++++ src/app/_components/ScriptDetailModal.tsx | 233 ++++++++++++++++++++++ src/app/_components/ScriptsGrid.tsx | 98 +++++++++ src/app/page.tsx | 38 +++- src/env.js | 2 + src/server/api/routers/scripts.ts | 74 +++++++ src/server/services/github.ts | 126 ++++++++++++ src/types/script.ts | 61 ++++++ 10 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 scripts/json/debian.json create mode 100644 src/app/_components/ResyncButton.tsx create mode 100644 src/app/_components/ScriptCard.tsx create mode 100644 src/app/_components/ScriptDetailModal.tsx create mode 100644 src/app/_components/ScriptsGrid.tsx create mode 100644 src/server/services/github.ts create mode 100644 src/types/script.ts diff --git a/scripts/json/debian.json b/scripts/json/debian.json new file mode 100644 index 0000000..e0d6a35 --- /dev/null +++ b/scripts/json/debian.json @@ -0,0 +1,35 @@ +{ + "name": "Debian", + "slug": "debian", + "categories": [ + 2 + ], + "date_created": "2024-05-02", + "type": "ct", + "updateable": true, + "privileged": false, + "interface_port": null, + "documentation": null, + "website": "https://www.debian.org/", + "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/debian.webp", + "config_path": "", + "description": "Debian Linux is a distribution that emphasizes free software. It supports many hardware platforms.", + "install_methods": [ + { + "type": "default", + "script": "ct/debian.sh", + "resources": { + "cpu": 1, + "ram": 512, + "hdd": 2, + "os": "debian", + "version": "12" + } + } + ], + "default_credentials": { + "username": null, + "password": null + }, + "notes": [] + } \ No newline at end of file diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx new file mode 100644 index 0000000..53196b5 --- /dev/null +++ b/src/app/_components/ResyncButton.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import { api } from '~/trpc/react'; + +export function ResyncButton() { + const [isResyncing, setIsResyncing] = useState(false); + const [lastSync, setLastSync] = useState(null); + const [syncMessage, setSyncMessage] = useState(null); + + const resyncMutation = api.scripts.resyncScripts.useMutation({ + onSuccess: (data) => { + setIsResyncing(false); + setLastSync(new Date()); + if (data.success) { + setSyncMessage(data.message || 'Scripts synced successfully'); + } else { + setSyncMessage(data.error || 'Failed to sync scripts'); + } + // Clear message after 3 seconds + setTimeout(() => setSyncMessage(null), 3000); + }, + onError: (error) => { + setIsResyncing(false); + setSyncMessage(`Error: ${error.message}`); + setTimeout(() => setSyncMessage(null), 3000); + }, + }); + + const handleResync = async () => { + setIsResyncing(true); + setSyncMessage(null); + resyncMutation.mutate(); + }; + + return ( +
+ + + {lastSync && ( +
+ Last sync: {lastSync.toLocaleTimeString()} +
+ )} + + {syncMessage && ( +
+ {syncMessage} +
+ )} +
+ ); +} diff --git a/src/app/_components/ScriptCard.tsx b/src/app/_components/ScriptCard.tsx new file mode 100644 index 0000000..def2e87 --- /dev/null +++ b/src/app/_components/ScriptCard.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import type { ScriptCard } from '~/types/script'; + +interface ScriptCardProps { + script: ScriptCard; + onClick: (script: ScriptCard) => void; +} + +export function ScriptCard({ script, onClick }: ScriptCardProps) { + const [imageError, setImageError] = useState(false); + + const handleImageError = () => { + setImageError(true); + }; + + return ( +
onClick(script)} + > +
+ {/* Header with logo and name */} +
+
+ {script.logo && !imageError ? ( + {`${script.name} + ) : ( +
+ + {script.name.charAt(0).toUpperCase()} + +
+ )} +
+
+

+ {script.name} +

+
+ + {script.type.toUpperCase()} + + {script.updateable && ( + + Updateable + + )} +
+
+
+ + {/* Description */} +

+ {script.description} +

+ + {/* Footer with website link */} + {script.website && ( + + )} +
+
+ ); +} diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx new file mode 100644 index 0000000..3b5a182 --- /dev/null +++ b/src/app/_components/ScriptDetailModal.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useState } from 'react'; +import type { Script } from '~/types/script'; + +interface ScriptDetailModalProps { + script: Script | null; + isOpen: boolean; + onClose: () => void; +} + +export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModalProps) { + const [imageError, setImageError] = useState(false); + + if (!isOpen || !script) return null; + + const handleImageError = () => { + setImageError(true); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ {script.logo && !imageError ? ( + {`${script.name} + ) : ( +
+ + {script.name.charAt(0).toUpperCase()} + +
+ )} +
+

{script.name}

+
+ + {script.type.toUpperCase()} + + {script.updateable && ( + + Updateable + + )} + {script.privileged && ( + + Privileged + + )} +
+
+
+ +
+ + {/* Content */} +
+ {/* Description */} +
+

Description

+

{script.description}

+
+ + {/* Basic Information */} +
+
+

Basic Information

+
+
+
Slug
+
{script.slug}
+
+
+
Date Created
+
{script.date_created}
+
+
+
Categories
+
{script.categories.join(', ')}
+
+ {script.interface_port && ( +
+
Interface Port
+
{script.interface_port}
+
+ )} + {script.config_path && ( +
+
Config Path
+
{script.config_path}
+
+ )} +
+
+ +
+

Links

+
+ {script.website && ( +
+
Website
+
+ + {script.website} + +
+
+ )} + {script.documentation && ( +
+
Documentation
+
+ + {script.documentation} + +
+
+ )} +
+
+
+ + {/* Install Methods */} + {script.install_methods.length > 0 && ( +
+

Install Methods

+
+ {script.install_methods.map((method, index) => ( +
+
+

{method.type}

+ {method.script} +
+
+
+
CPU
+
{method.resources.cpu} cores
+
+
+
RAM
+
{method.resources.ram} MB
+
+
+
HDD
+
{method.resources.hdd} GB
+
+
+
OS
+
{method.resources.os} {method.resources.version}
+
+
+
+ ))} +
+
+ )} + + {/* Default Credentials */} + {(script.default_credentials.username || script.default_credentials.password) && ( +
+

Default Credentials

+
+ {script.default_credentials.username && ( +
+
Username
+
{script.default_credentials.username}
+
+ )} + {script.default_credentials.password && ( +
+
Password
+
{script.default_credentials.password}
+
+ )} +
+
+ )} + + {/* Notes */} + {script.notes.length > 0 && ( +
+

Notes

+
    + {script.notes.map((note, index) => ( +
  • + {note} +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx new file mode 100644 index 0000000..7cb90aa --- /dev/null +++ b/src/app/_components/ScriptsGrid.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState } from 'react'; +import { api } from '~/trpc/react'; +import { ScriptCard } from './ScriptCard'; +import { ScriptDetailModal } from './ScriptDetailModal'; +import type { ScriptCard as ScriptCardType, Script } from '~/types/script'; + +export function ScriptsGrid() { + const [selectedScript, setSelectedScript] = useState