diff --git a/src/app/_components/DownloadedScriptsTab.tsx b/src/app/_components/DownloadedScriptsTab.tsx index 7e18354..799d4b9 100644 --- a/src/app/_components/DownloadedScriptsTab.tsx +++ b/src/app/_components/DownloadedScriptsTab.tsx @@ -3,9 +3,11 @@ import React, { useState, useRef, useEffect } from 'react'; import { api } from '~/trpc/react'; import { ScriptCard } from './ScriptCard'; +import { ScriptCardList } from './ScriptCardList'; import { ScriptDetailModal } from './ScriptDetailModal'; import { CategorySidebar } from './CategorySidebar'; import { FilterBar, type FilterState } from './FilterBar'; +import { ViewToggle } from './ViewToggle'; import { Button } from './ui/button'; import type { ScriptCard as ScriptCardType } from '~/types/script'; @@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr const [selectedSlug, setSelectedSlug] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedCategory, setSelectedCategory] = useState(null); + const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); const [filters, setFilters] = useState({ searchQuery: '', showUpdatable: null, @@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr { enabled: !!selectedSlug } ); - // Load SAVE_FILTER setting and saved filters on component mount + // Load SAVE_FILTER setting, saved filters, and view mode on component mount useEffect(() => { const loadSettings = async () => { try { @@ -63,6 +66,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr } } } + + // Load view mode + const viewModeResponse = await fetch('/api/settings/view-mode'); + if (viewModeResponse.ok) { + const viewModeData = await viewModeResponse.json(); + const viewMode = viewModeData.viewMode; + if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) { + setViewMode(viewMode); + } + } } catch (error) { console.error('Error loading settings:', error); } finally { @@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr return () => clearTimeout(timeoutId); }, [filters, saveFiltersEnabled, isLoadingFilters]); + // Save view mode when it changes + useEffect(() => { + if (isLoadingFilters) return; + + const saveViewMode = async () => { + try { + await fetch('/api/settings/view-mode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ viewMode }), + }); + } catch (error) { + console.error('Error saving view mode:', error); + } + }; + + // Debounce the save operation + const timeoutId = setTimeout(() => void saveViewMode(), 300); + return () => clearTimeout(timeoutId); + }, [viewMode, isLoadingFilters]); + // Extract categories from metadata const categories = React.useMemo((): string[] => { if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; @@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr return (
- {/* Header with Stats */} -
-

Downloaded Scripts

- -
-
-
{downloadedScripts.length}
-
Total Downloaded
-
-
-
{filterCounts.updatableCount}
-
Updatable
-
-
-
{filteredScripts.length}
-
Filtered Results
-
-
-
+ +
{/* Category Sidebar */} @@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr isLoadingFilters={isLoadingFilters} /> + {/* View Toggle */} + + {/* Scripts Grid */} {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
) : ( -
- {filteredScripts.map((script, index) => { - // Add validation to ensure script has required properties - if (!script || typeof script !== 'object') { - return null; - } - - // Create a unique key by combining slug, name, and index to handle duplicates - const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; - - return ( - - ); - })} -
+ viewMode === 'card' ? ( +
+ {filteredScripts.map((script, index) => { + // Add validation to ensure script has required properties + if (!script || typeof script !== 'object') { + return null; + } + + // Create a unique key by combining slug, name, and index to handle duplicates + const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; + + return ( + + ); + })} +
+ ) : ( +
+ {filteredScripts.map((script, index) => { + // Add validation to ensure script has required properties + if (!script || typeof script !== 'object') { + return null; + } + + // Create a unique key by combining slug, name, and index to handle duplicates + const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; + + return ( + + ); + })} +
+ ) )} void; +} + +export function ScriptCardList({ script, onClick }: ScriptCardListProps) { + const [imageError, setImageError] = useState(false); + + const handleImageError = () => { + setImageError(true); + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return 'Unknown'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch { + return 'Unknown'; + } + }; + + const getCategoryNames = () => { + if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized'; + return script.categoryNames.join(', '); + }; + + return ( +
onClick(script)} + > +
+
+ {/* Logo */} +
+ {script.logo && !imageError ? ( + {`${script.name} + ) : ( +
+ + {script.name?.charAt(0)?.toUpperCase() || '?'} + +
+ )} +
+ + {/* Main Content */} +
+ {/* Header Row */} +
+
+

+ {script.name || 'Unnamed Script'} +

+
+ + {script.updateable && } +
+
+ + {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} + +
+
+
+ + {/* Right side - Website link */} + {script.website && ( + e.stopPropagation()} + > + Website + + + + + )} +
+ + {/* Description */} +

+ {script.description || 'No description available'} +

+ + {/* Metadata Row */} +
+
+
+ + + + Categories: {getCategoryNames()} +
+
+ + + + Created: {formatDate(script.date_created)} +
+ {(script.os ?? script.version) && ( +
+ + + + + {script.os && script.version + ? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}` + : script.os + ? script.os.charAt(0).toUpperCase() + script.os.slice(1) + : script.version + ? `Version ${script.version}` + : '' + } + +
+ )} + {script.interface_port && ( +
+ + + + Port: {script.interface_port} +
+ )} +
+
+ + + + ID: {script.slug || 'unknown'} +
+
+
+
+
+
+ ); +} diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index ec50191..520fdc5 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -3,9 +3,11 @@ import React, { useState, useRef, useEffect } from 'react'; import { api } from '~/trpc/react'; import { ScriptCard } from './ScriptCard'; +import { ScriptCardList } from './ScriptCardList'; import { ScriptDetailModal } from './ScriptDetailModal'; import { CategorySidebar } from './CategorySidebar'; import { FilterBar, type FilterState } from './FilterBar'; +import { ViewToggle } from './ViewToggle'; import { Button } from './ui/button'; import type { ScriptCard as ScriptCardType } from '~/types/script'; @@ -19,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { const [isModalOpen, setIsModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState(null); + const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); const [filters, setFilters] = useState({ searchQuery: '', showUpdatable: null, @@ -37,7 +40,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { { enabled: !!selectedSlug } ); - // Load SAVE_FILTER setting and saved filters on component mount + // Load SAVE_FILTER setting, saved filters, and view mode on component mount useEffect(() => { const loadSettings = async () => { try { @@ -60,6 +63,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { } } } + + // Load view mode + const viewModeResponse = await fetch('/api/settings/view-mode'); + if (viewModeResponse.ok) { + const viewModeData = await viewModeResponse.json(); + const viewMode = viewModeData.viewMode; + if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) { + setViewMode(viewMode); + } + } } catch (error) { console.error('Error loading settings:', error); } finally { @@ -93,6 +106,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { return () => clearTimeout(timeoutId); }, [filters, saveFiltersEnabled, isLoadingFilters]); + // Save view mode when it changes + useEffect(() => { + if (isLoadingFilters) return; + + const saveViewMode = async () => { + try { + await fetch('/api/settings/view-mode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ viewMode }), + }); + } catch (error) { + console.error('Error saving view mode:', error); + } + }; + + // Debounce the save operation + const timeoutId = setTimeout(() => void saveViewMode(), 300); + return () => clearTimeout(timeoutId); + }, [viewMode, isLoadingFilters]); + // Extract categories from metadata const categories = React.useMemo((): string[] => { if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; @@ -399,6 +435,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { isLoadingFilters={isLoadingFilters} /> + {/* View Toggle */} + + {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
) : ( -
- {filteredScripts.map((script, index) => { - // Add validation to ensure script has required properties - if (!script || typeof script !== 'object') { - return null; - } - - // Create a unique key by combining slug, name, and index to handle duplicates - const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; - - return ( - - ); - })} -
+ viewMode === 'card' ? ( +
+ {filteredScripts.map((script, index) => { + // Add validation to ensure script has required properties + if (!script || typeof script !== 'object') { + return null; + } + + // Create a unique key by combining slug, name, and index to handle duplicates + const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; + + return ( + + ); + })} +
+ ) : ( +
+ {filteredScripts.map((script, index) => { + // Add validation to ensure script has required properties + if (!script || typeof script !== 'object') { + return null; + } + + // Create a unique key by combining slug, name, and index to handle duplicates + const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; + + return ( + + ); + })} +
+ ) )} void; +} + +export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/app/api/settings/view-mode/route.ts b/src/app/api/settings/view-mode/route.ts new file mode 100644 index 0000000..f71fea3 --- /dev/null +++ b/src/app/api/settings/view-mode/route.ts @@ -0,0 +1,81 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export async function POST(request: NextRequest) { + try { + const { viewMode } = await request.json(); + + if (!viewMode || !['card', 'list'].includes(viewMode as string)) { + return NextResponse.json( + { error: 'View mode must be either "card" or "list"' }, + { status: 400 } + ); + } + + // Path to the .env file + const envPath = path.join(process.cwd(), '.env'); + + // Read existing .env file + let envContent = ''; + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf8'); + } + + // Check if VIEW_MODE already exists + const viewModeRegex = /^VIEW_MODE=.*$/m; + const viewModeMatch = viewModeRegex.exec(envContent); + + if (viewModeMatch) { + // Replace existing VIEW_MODE + envContent = envContent.replace(viewModeRegex, `VIEW_MODE=${viewMode}`); + } else { + // Add new VIEW_MODE + envContent += (envContent.endsWith('\n') ? '' : '\n') + `VIEW_MODE=${viewMode}\n`; + } + + // Write back to .env file + fs.writeFileSync(envPath, envContent); + + return NextResponse.json({ success: true, message: 'View mode saved successfully' }); + } catch (error) { + console.error('Error saving view mode:', error); + return NextResponse.json( + { error: 'Failed to save view mode' }, + { status: 500 } + ); + } +} + +export async function GET() { + try { + // Path to the .env file + const envPath = path.join(process.cwd(), '.env'); + + if (!fs.existsSync(envPath)) { + return NextResponse.json({ viewMode: 'card' }); // Default to card view + } + + // Read .env file and extract VIEW_MODE + const envContent = fs.readFileSync(envPath, 'utf8'); + const viewModeRegex = /^VIEW_MODE=(.*)$/m; + const viewModeMatch = viewModeRegex.exec(envContent); + + if (!viewModeMatch) { + return NextResponse.json({ viewMode: 'card' }); // Default to card view + } + + const viewMode = viewModeMatch[1]?.trim(); + + // Validate the view mode + if (!viewMode || !['card', 'list'].includes(viewMode)) { + return NextResponse.json({ viewMode: 'card' }); // Default to card view + } + + return NextResponse.json({ viewMode }); + } catch (error) { + console.error('Error reading view mode:', error); + return NextResponse.json({ viewMode: 'card' }); // Default to card view + } +} diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts index 58e70d0..fdd801f 100644 --- a/src/server/api/routers/scripts.ts +++ b/src/server/api/routers/scripts.ts @@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({ const script = scripts.find(s => s.slug === card.slug); const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? []; + // Extract OS and version from first install method + const firstInstallMethod = script?.install_methods?.[0]; + const os = firstInstallMethod?.resources?.os; + const version = firstInstallMethod?.resources?.version; + return { ...card, categories: script?.categories ?? [], categoryNames: categoryNames, // Add date_created from script date_created: script?.date_created, + // Add OS and version from install methods + os: os, + version: version, + // Add interface port + interface_port: script?.interface_port, } as ScriptCard; }); diff --git a/src/types/script.ts b/src/types/script.ts index 2de1401..6c353f5 100644 --- a/src/types/script.ts +++ b/src/types/script.ts @@ -57,6 +57,9 @@ export interface ScriptCard { categories?: number[]; categoryNames?: string[]; date_created?: string; + os?: string; + version?: string; + interface_port?: number | null; } export interface GitHubFile {