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 { 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<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
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 */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
<div className="hidden mb-8">
|
||||
<div className="relative max-w-md mx-auto">
|
||||
@@ -474,25 +516,47 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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 (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user