Skip to content

Commit d819cd7

Browse files
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
1 parent c618fef commit d819cd7

File tree

7 files changed

+473
-59
lines changed

7 files changed

+473
-59
lines changed

src/app/_components/DownloadedScriptsTab.tsx

Lines changed: 86 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import React, { useState, useRef, useEffect } from 'react';
44
import { api } from '~/trpc/react';
55
import { ScriptCard } from './ScriptCard';
6+
import { ScriptCardList } from './ScriptCardList';
67
import { ScriptDetailModal } from './ScriptDetailModal';
78
import { CategorySidebar } from './CategorySidebar';
89
import { FilterBar, type FilterState } from './FilterBar';
10+
import { ViewToggle } from './ViewToggle';
911
import { Button } from './ui/button';
1012
import type { ScriptCard as ScriptCardType } from '~/types/script';
1113

@@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
2224
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
2325
const [isModalOpen, setIsModalOpen] = useState(false);
2426
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
27+
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
2528
const [filters, setFilters] = useState<FilterState>({
2629
searchQuery: '',
2730
showUpdatable: null,
@@ -40,7 +43,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
4043
{ enabled: !!selectedSlug }
4144
);
4245

43-
// Load SAVE_FILTER setting and saved filters on component mount
46+
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
4447
useEffect(() => {
4548
const loadSettings = async () => {
4649
try {
@@ -63,6 +66,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
6366
}
6467
}
6568
}
69+
70+
// Load view mode
71+
const viewModeResponse = await fetch('/api/settings/view-mode');
72+
if (viewModeResponse.ok) {
73+
const viewModeData = await viewModeResponse.json();
74+
const viewMode = viewModeData.viewMode;
75+
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
76+
setViewMode(viewMode);
77+
}
78+
}
6679
} catch (error) {
6780
console.error('Error loading settings:', error);
6881
} finally {
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
96109
return () => clearTimeout(timeoutId);
97110
}, [filters, saveFiltersEnabled, isLoadingFilters]);
98111

112+
// Save view mode when it changes
113+
useEffect(() => {
114+
if (isLoadingFilters) return;
115+
116+
const saveViewMode = async () => {
117+
try {
118+
await fetch('/api/settings/view-mode', {
119+
method: 'POST',
120+
headers: {
121+
'Content-Type': 'application/json',
122+
},
123+
body: JSON.stringify({ viewMode }),
124+
});
125+
} catch (error) {
126+
console.error('Error saving view mode:', error);
127+
}
128+
};
129+
130+
// Debounce the save operation
131+
const timeoutId = setTimeout(() => void saveViewMode(), 300);
132+
return () => clearTimeout(timeoutId);
133+
}, [viewMode, isLoadingFilters]);
134+
99135
// Extract categories from metadata
100136
const categories = React.useMemo((): string[] => {
101137
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
367403

368404
return (
369405
<div className="space-y-6">
370-
{/* Header with Stats */}
371-
<div className="bg-card rounded-lg shadow p-6">
372-
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
373-
374-
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
375-
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
376-
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
377-
<div className="text-sm text-blue-300">Total Downloaded</div>
378-
</div>
379-
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
380-
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
381-
<div className="text-sm text-green-300">Updatable</div>
382-
</div>
383-
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
384-
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
385-
<div className="text-sm text-purple-300">Filtered Results</div>
386-
</div>
387-
</div>
388-
</div>
406+
407+
389408

390409
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
391410
{/* Category Sidebar */}
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
412431
isLoadingFilters={isLoadingFilters}
413432
/>
414433

434+
{/* View Toggle */}
435+
<ViewToggle
436+
viewMode={viewMode}
437+
onViewModeChange={setViewMode}
438+
/>
439+
415440
{/* Scripts Grid */}
416441
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
417442
<div className="text-center py-12">
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
446471
</div>
447472
</div>
448473
) : (
449-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
450-
{filteredScripts.map((script, index) => {
451-
// Add validation to ensure script has required properties
452-
if (!script || typeof script !== 'object') {
453-
return null;
454-
}
455-
456-
// Create a unique key by combining slug, name, and index to handle duplicates
457-
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
458-
459-
return (
460-
<ScriptCard
461-
key={uniqueKey}
462-
script={script}
463-
onClick={handleCardClick}
464-
/>
465-
);
466-
})}
467-
</div>
474+
viewMode === 'card' ? (
475+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
476+
{filteredScripts.map((script, index) => {
477+
// Add validation to ensure script has required properties
478+
if (!script || typeof script !== 'object') {
479+
return null;
480+
}
481+
482+
// Create a unique key by combining slug, name, and index to handle duplicates
483+
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
484+
485+
return (
486+
<ScriptCard
487+
key={uniqueKey}
488+
script={script}
489+
onClick={handleCardClick}
490+
/>
491+
);
492+
})}
493+
</div>
494+
) : (
495+
<div className="space-y-3">
496+
{filteredScripts.map((script, index) => {
497+
// Add validation to ensure script has required properties
498+
if (!script || typeof script !== 'object') {
499+
return null;
500+
}
501+
502+
// Create a unique key by combining slug, name, and index to handle duplicates
503+
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
504+
505+
return (
506+
<ScriptCardList
507+
key={uniqueKey}
508+
script={script}
509+
onClick={handleCardClick}
510+
/>
511+
);
512+
})}
513+
</div>
514+
)
468515
)}
469516

470517
<ScriptDetailModal
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Image from 'next/image';
5+
import type { ScriptCard } from '~/types/script';
6+
import { TypeBadge, UpdateableBadge } from './Badge';
7+
8+
interface ScriptCardListProps {
9+
script: ScriptCard;
10+
onClick: (script: ScriptCard) => void;
11+
}
12+
13+
export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
14+
const [imageError, setImageError] = useState(false);
15+
16+
const handleImageError = () => {
17+
setImageError(true);
18+
};
19+
20+
const formatDate = (dateString?: string) => {
21+
if (!dateString) return 'Unknown';
22+
try {
23+
return new Date(dateString).toLocaleDateString('en-US', {
24+
year: 'numeric',
25+
month: 'short',
26+
day: 'numeric'
27+
});
28+
} catch {
29+
return 'Unknown';
30+
}
31+
};
32+
33+
const getCategoryNames = () => {
34+
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
35+
return script.categoryNames.join(', ');
36+
};
37+
38+
return (
39+
<div
40+
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
41+
onClick={() => onClick(script)}
42+
>
43+
<div className="p-6">
44+
<div className="flex items-start space-x-4">
45+
{/* Logo */}
46+
<div className="flex-shrink-0">
47+
{script.logo && !imageError ? (
48+
<Image
49+
src={script.logo}
50+
alt={`${script.name} logo`}
51+
width={56}
52+
height={56}
53+
className="w-14 h-14 rounded-lg object-contain"
54+
onError={handleImageError}
55+
/>
56+
) : (
57+
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
58+
<span className="text-muted-foreground text-lg font-semibold">
59+
{script.name?.charAt(0)?.toUpperCase() || '?'}
60+
</span>
61+
</div>
62+
)}
63+
</div>
64+
65+
{/* Main Content */}
66+
<div className="flex-1 min-w-0">
67+
{/* Header Row */}
68+
<div className="flex items-start justify-between mb-3">
69+
<div className="flex-1 min-w-0">
70+
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
71+
{script.name || 'Unnamed Script'}
72+
</h3>
73+
<div className="flex items-center space-x-3">
74+
<TypeBadge type={script.type ?? 'unknown'} />
75+
{script.updateable && <UpdateableBadge />}
76+
<div className="flex items-center space-x-1">
77+
<div className={`w-2 h-2 rounded-full ${
78+
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
79+
}`}></div>
80+
<span className={`text-sm font-medium ${
81+
script.isDownloaded ? 'text-green-700' : 'text-destructive'
82+
}`}>
83+
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
84+
</span>
85+
</div>
86+
</div>
87+
</div>
88+
89+
{/* Right side - Website link */}
90+
{script.website && (
91+
<a
92+
href={script.website}
93+
target="_blank"
94+
rel="noopener noreferrer"
95+
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
96+
onClick={(e) => e.stopPropagation()}
97+
>
98+
<span>Website</span>
99+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
100+
<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" />
101+
</svg>
102+
</a>
103+
)}
104+
</div>
105+
106+
{/* Description */}
107+
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
108+
{script.description || 'No description available'}
109+
</p>
110+
111+
{/* Metadata Row */}
112+
<div className="flex items-center justify-between text-xs text-muted-foreground">
113+
<div className="flex items-center space-x-4">
114+
<div className="flex items-center space-x-1">
115+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
116+
<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" />
117+
</svg>
118+
<span>Categories: {getCategoryNames()}</span>
119+
</div>
120+
<div className="flex items-center space-x-1">
121+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
122+
<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" />
123+
</svg>
124+
<span>Created: {formatDate(script.date_created)}</span>
125+
</div>
126+
{(script.os ?? script.version) && (
127+
<div className="flex items-center space-x-1">
128+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
129+
<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" />
130+
</svg>
131+
<span>
132+
{script.os && script.version
133+
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
134+
: script.os
135+
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
136+
: script.version
137+
? `Version ${script.version}`
138+
: ''
139+
}
140+
</span>
141+
</div>
142+
)}
143+
{script.interface_port && (
144+
<div className="flex items-center space-x-1">
145+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
146+
<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" />
147+
</svg>
148+
<span>Port: {script.interface_port}</span>
149+
</div>
150+
)}
151+
</div>
152+
<div className="flex items-center space-x-1">
153+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
154+
<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" />
155+
</svg>
156+
<span>ID: {script.slug || 'unknown'}</span>
157+
</div>
158+
</div>
159+
</div>
160+
</div>
161+
</div>
162+
</div>
163+
);
164+
}

0 commit comments

Comments
 (0)