feat: Add card/list view toggle with enhanced list view (#101)
* feat: Add card/list view toggle with enhanced list view - Add ViewToggle component with grid/list icons and active state styling - Create ScriptCardList component with horizontal layout design - Add view-mode API endpoint for GET/POST operations to persist view preference - Update ScriptsGrid and DownloadedScriptsTab with view mode state and conditional rendering - Enhance list view with additional information: - Categories with tag icon - Creation date with calendar icon - OS and version with computer icon - Default port with terminal icon - Script ID with info icon - View preference persists across page reloads - Same view mode applies to both Available and Downloaded scripts pages - List view shows same information as card view but in compact horizontal layout * fix: Resolve TypeScript/ESLint build errors - Fix unsafe argument type errors in view mode loading - Use proper type guards for viewMode validation - Replace logical OR with nullish coalescing operator - Add explicit type casting for API response validation
This commit is contained in:
committed by
GitHub
parent
c618fef2ef
commit
d819cd79fe
@@ -3,9 +3,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
}, [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
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with Stats */}
|
|
||||||
<div className="bg-card rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
|
|
||||||
<div className="text-sm text-blue-300">Total Downloaded</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
|
|
||||||
<div className="text-sm text-green-300">Updatable</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
|
|
||||||
<div className="text-sm text-purple-300">Filtered Results</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
isLoadingFilters={isLoadingFilters}
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
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}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
164
src/app/_components/ScriptCardList.tsx
Normal file
164
src/app/_components/ScriptCardList.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import type { ScriptCard } from '~/types/script';
|
||||||
|
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||||
|
|
||||||
|
interface ScriptCardListProps {
|
||||||
|
script: ScriptCard;
|
||||||
|
onClick: (script: ScriptCard) => 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 (
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
|
||||||
|
onClick={() => onClick(script)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{script.logo && !imageError ? (
|
||||||
|
<Image
|
||||||
|
src={script.logo}
|
||||||
|
alt={`${script.name} logo`}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
className="w-14 h-14 rounded-lg object-contain"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
|
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||||
|
{script.name || 'Unnamed Script'}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<TypeBadge type={script.type ?? 'unknown'} />
|
||||||
|
{script.updateable && <UpdateableBadge />}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}></div>
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
script.isDownloaded ? 'text-green-700' : 'text-destructive'
|
||||||
|
}`}>
|
||||||
|
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Website link */}
|
||||||
|
{script.website && (
|
||||||
|
<a
|
||||||
|
href={script.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span>Website</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||||
|
{script.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Metadata Row */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Categories: {getCategoryNames()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Created: {formatDate(script.date_created)}</span>
|
||||||
|
</div>
|
||||||
|
{(script.os ?? script.version) && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{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}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.interface_port && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Port: {script.interface_port}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>ID: {script.slug || 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -37,7 +40,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,6 +106,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
}, [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
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -399,6 +435,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
isLoadingFilters={isLoadingFilters}
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
<div className="hidden mb-8">
|
<div className="hidden mb-8">
|
||||||
<div className="relative max-w-md mx-auto">
|
<div className="relative max-w-md mx-auto">
|
||||||
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
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}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
45
src/app/_components/ViewToggle.tsx
Normal file
45
src/app/_components/ViewToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Grid3X3, List } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ViewToggleProps {
|
||||||
|
viewMode: 'card' | 'list';
|
||||||
|
onViewModeChange: (mode: 'card' | 'list') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('card')}
|
||||||
|
variant={viewMode === 'card' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Card View</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
<span className="text-sm">List View</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/api/settings/view-mode/route.ts
Normal file
81
src/app/api/settings/view-mode/route.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
const script = scripts.find(s => s.slug === card.slug);
|
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') ?? [];
|
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 {
|
return {
|
||||||
...card,
|
...card,
|
||||||
categories: script?.categories ?? [],
|
categories: script?.categories ?? [],
|
||||||
categoryNames: categoryNames,
|
categoryNames: categoryNames,
|
||||||
// Add date_created from script
|
// Add date_created from script
|
||||||
date_created: script?.date_created,
|
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;
|
} as ScriptCard;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export interface ScriptCard {
|
|||||||
categories?: number[];
|
categories?: number[];
|
||||||
categoryNames?: string[];
|
categoryNames?: string[];
|
||||||
date_created?: string;
|
date_created?: string;
|
||||||
|
os?: string;
|
||||||
|
version?: string;
|
||||||
|
interface_port?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubFile {
|
export interface GitHubFile {
|
||||||
|
|||||||
Reference in New Issue
Block a user