feat: Add filter persistence with settings integration (#78)

* feat: Add settings modal with GitHub PAT and filter toggle

- Add GeneralSettingsModal with General and GitHub tabs
- Create GitHub PAT input field that saves to .env as GITHUB_TOKEN
- Add animated toggle component for SAVE_FILTER setting
- Create API endpoints for settings management
- Add Input and Toggle UI components
- Implement smooth animations for toggle interactions
- Add proper error handling and user feedback

* feat: Add filter persistence with settings integration

- Add filter persistence system that saves user filter preferences to .env
- Create FILTERS variable in .env to store complete filter state as JSON
- Add SAVE_FILTER toggle in settings to enable/disable persistence
- Implement auto-save functionality with 500ms debounce
- Add loading states and visual feedback for filter restoration
- Create API endpoints for managing saved filters
- Add filter management UI in settings modal
- Support for search query, type filters, sort order, and updatable status
- Seamless integration across all script tabs (Available, Downloaded, Installed)
- Auto-clear saved filters when persistence is disabled
This commit is contained in:
Michel Roegl-Brunner
2025-10-08 15:37:36 +02:00
committed by GitHub
parent 92f78c7008
commit 5eaafbde48
15 changed files with 896 additions and 93 deletions

View File

@@ -16,4 +16,8 @@ ALLOWED_SCRIPT_PATHS="scripts/"
# WebSocket Configuration
WEBSOCKET_PORT="3001"
GITHUB_TOKEN=your_github_token_here
# User settings
GITHUB_TOKEN=
SAVE_FILTER=false
FILTERS=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -20,6 +20,8 @@ export function DownloadedScriptsTab() {
sortBy: 'name',
sortOrder: 'asc',
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -29,6 +31,62 @@ export function DownloadedScriptsTab() {
{ enabled: !!selectedSlug }
);
// Load SAVE_FILTER setting and saved filters on component mount
useEffect(() => {
const loadSettings = async () => {
try {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
saveFilterEnabled = saveFilterData.enabled ?? false;
setSaveFiltersEnabled(saveFilterEnabled);
}
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
}
}
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setIsLoadingFilters(false);
}
};
void loadSettings();
}, []);
// Save filters when they change (if SAVE_FILTER is enabled)
useEffect(() => {
if (!saveFiltersEnabled || isLoadingFilters) return;
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveFilters(), 500);
return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]);
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -341,6 +399,8 @@ export function DownloadedScriptsTab() {
totalScripts={downloadedScripts.length}
filteredCount={filteredScripts.length}
updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
isLoadingFilters={isLoadingFilters}
/>
{/* Scripts Grid */}

View File

@@ -18,6 +18,8 @@ interface FilterBarProps {
totalScripts: number;
filteredCount: number;
updatableCount?: number;
saveFiltersEnabled?: boolean;
isLoadingFilters?: boolean;
}
const SCRIPT_TYPES = [
@@ -33,6 +35,8 @@ export function FilterBar({
totalScripts,
filteredCount,
updatableCount = 0,
saveFiltersEnabled = false,
isLoadingFilters = false,
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
@@ -78,6 +82,28 @@ export function FilterBar({
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* Loading State */}
{isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span>Loading saved filters...</span>
</div>
</div>
)}
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="mb-4 flex items-center justify-center py-1">
<div className="flex items-center space-x-2 text-xs text-green-600">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Filters are being saved automatically</span>
</div>
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">

View File

@@ -0,0 +1,308 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
interface GeneralSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Load existing settings when modal opens
useEffect(() => {
if (isOpen) {
void loadGithubToken();
void loadSaveFilter();
void loadSavedFilters();
}
}, [isOpen]);
const loadGithubToken = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/settings/github-token');
if (response.ok) {
const data = await response.json();
setGithubToken((data.token as string) ?? '');
}
} catch (error) {
console.error('Error loading GitHub token:', error);
} finally {
setIsLoading(false);
}
};
const loadSaveFilter = async () => {
try {
const response = await fetch('/api/settings/save-filter');
if (response.ok) {
const data = await response.json();
setSaveFilter((data.enabled as boolean) ?? false);
}
} catch (error) {
console.error('Error loading save filter setting:', error);
}
};
const saveSaveFilter = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/save-filter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setSaveFilter(enabled);
setMessage({ type: 'success', text: 'Save filter setting updated!' });
// If disabling save filters, clear saved filters
if (!enabled) {
await clearSavedFilters();
}
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save setting' });
}
};
const loadSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters');
if (response.ok) {
const data = await response.json();
setSavedFilters(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
}
};
const clearSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters', {
method: 'DELETE',
});
if (response.ok) {
setSavedFilters(null);
setMessage({ type: 'success', text: 'Saved filters cleared!' });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to clear filters' });
}
};
const saveGithubToken = async () => {
setIsSaving(true);
setMessage(null);
try {
const response = await fetch('/api/settings/github-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: githubToken }),
});
if (response.ok) {
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save token' });
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
<Button
onClick={() => setActiveTab('general')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
General
</Button>
<Button
onClick={() => setActiveTab('github')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'github'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
GitHub
</Button>
</nav>
</div>
{/* Content */}
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
{activeTab === 'general' && (
<div className="space-y-4 sm:space-y-6">
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure general application preferences and behavior.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
<Toggle
checked={saveFilter}
onCheckedChange={saveSaveFilter}
label="Enable filter saving"
/>
{saveFilter && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Saved Filters</p>
<p className="text-xs text-muted-foreground">
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
</p>
{savedFilters && (
<div className="mt-2 text-xs text-muted-foreground">
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
</div>
)}
</div>
{savedFilters && (
<Button
onClick={clearSavedFilters}
variant="outline"
size="sm"
className="text-red-600 hover:text-red-800"
>
Clear
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'github' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure GitHub integration for script management and updates.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
<div className="space-y-3">
<div>
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
Token
</label>
<Input
id="github-token"
type="password"
placeholder="Enter your GitHub Personal Access Token"
value={githubToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
disabled={isLoading || isSaving}
className="w-full"
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveGithubToken}
disabled={isSaving || isLoading || !githubToken.trim()}
className="flex-1"
>
{isSaving ? 'Saving...' : 'Save Token'}
</Button>
<Button
onClick={loadGithubToken}
disabled={isLoading || isSaving}
variant="outline"
>
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -26,6 +26,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
sortBy: 'name',
sortOrder: 'asc',
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -35,6 +37,62 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{ enabled: !!selectedSlug }
);
// Load SAVE_FILTER setting and saved filters on component mount
useEffect(() => {
const loadSettings = async () => {
try {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
saveFilterEnabled = saveFilterData.enabled ?? false;
setSaveFiltersEnabled(saveFilterEnabled);
}
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
}
}
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setIsLoadingFilters(false);
}
};
void loadSettings();
}, []);
// Save filters when they change (if SAVE_FILTER is enabled)
useEffect(() => {
if (!saveFiltersEnabled || isLoadingFilters) return;
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveFilters(), 500);
return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]);
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -337,6 +395,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
totalScripts={scriptsWithStatus.length}
filteredCount={filteredScripts.length}
updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
isLoadingFilters={isLoadingFilters}
/>
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
export function ServerSettingsButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers:
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title="Add PVE Server"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Manage PVE Servers
</Button>
</div>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { GeneralSettingsModal } from './GeneralSettingsModal';
import { Button } from './ui/button';
import { Settings } from 'lucide-react';
export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false);
@@ -11,41 +12,21 @@ export function SettingsButton() {
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers:
Application Settings:
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title="Add PVE Server"
title="Open Settings"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Manage PVE Servers
<Settings className="w-5 h-5 mr-2" />
Settings
</Button>
</div>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -15,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
useEffect(() => {
if (isOpen) {
@@ -116,35 +115,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</Button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
<Button
onClick={() => setActiveTab('servers')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'servers'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Server Settings
</Button>
<Button
onClick={() => setActiveTab('general')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
General
</Button>
</nav>
</div>
{/* Content */}
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
@@ -164,37 +134,28 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} />
</div>
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading servers...</p>
</div>
) : (
<ServerList
servers={servers}
onUpdate={handleUpdateServer}
onDelete={handleDeleteServer}
/>
)}
</div>
</div>
)}
{activeTab === 'general' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground">General settings will be available in a future update.</p>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} />
</div>
)}
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading servers...</p>
</div>
) : (
<ServerList
servers={servers}
onUpdate={handleUpdateServer}
onDelete={handleDeleteServer}
/>
)}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
export interface ToggleProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
label?: string;
}
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
({ className, checked, onCheckedChange, label, ...props }, ref) => {
return (
<div className="flex items-center space-x-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
checked && "bg-blue-600 after:translate-x-full",
className
)} />
</label>
{label && (
<span className="text-sm font-medium text-foreground">
{label}
</span>
)}
</div>
)
}
)
Toggle.displayName = "Toggle"
export { Toggle }

View File

@@ -0,0 +1,141 @@
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 { filters } = await request.json();
if (!filters || typeof filters !== 'object') {
return NextResponse.json(
{ error: 'Filters object is required' },
{ status: 400 }
);
}
// Validate filter structure
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
for (const field of requiredFields) {
if (!(field in filters)) {
return NextResponse.json(
{ error: `Missing required field: ${field}` },
{ 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');
}
// Serialize filters to JSON string
const filtersJson = JSON.stringify(filters);
// Check if FILTERS already exists
const filtersRegex = /^FILTERS=.*$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (filtersMatch) {
// Replace existing FILTERS
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
} else {
// Add new FILTERS
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
} catch (error) {
console.error('Error saving filters:', error);
return NextResponse.json(
{ error: 'Failed to save filters' },
{ 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({ filters: null });
}
// Read .env file and extract FILTERS
const envContent = fs.readFileSync(envPath, 'utf8');
const filtersRegex = /^FILTERS=(.*)$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (!filtersMatch) {
return NextResponse.json({ filters: null });
}
try {
const filters = JSON.parse(filtersMatch[1]!);
// Validate the parsed filters
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
const isValid = requiredFields.every(field => field in filters);
if (!isValid) {
return NextResponse.json({ filters: null });
}
return NextResponse.json({ filters });
} catch (parseError) {
console.error('Error parsing saved filters:', parseError);
return NextResponse.json({ filters: null });
}
} catch (error) {
console.error('Error reading filters:', error);
return NextResponse.json(
{ error: 'Failed to read filters' },
{ status: 500 }
);
}
}
export async function DELETE() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ success: true, message: 'No filters to clear' });
}
// Read existing .env file
let envContent = fs.readFileSync(envPath, 'utf8');
// Remove FILTERS line
const filtersRegex = /^FILTERS=.*$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (filtersMatch) {
envContent = envContent.replace(filtersRegex, '');
}
// Clean up extra newlines
envContent = envContent.replace(/\n\n+/g, '\n');
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
} catch (error) {
console.error('Error clearing filters:', error);
return NextResponse.json(
{ error: 'Failed to clear filters' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
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 { token } = await request.json();
if (!token || typeof token !== 'string') {
return NextResponse.json(
{ error: 'Token is required and must be a string' },
{ 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 GITHUB_TOKEN already exists
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
const githubTokenMatch = githubTokenRegex.exec(envContent);
if (githubTokenMatch) {
// Replace existing GITHUB_TOKEN
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
} else {
// Add new GITHUB_TOKEN
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
} catch (error) {
console.error('Error saving GitHub token:', error);
return NextResponse.json(
{ error: 'Failed to save GitHub token' },
{ 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({ token: null });
}
// Read .env file and extract GITHUB_TOKEN
const envContent = fs.readFileSync(envPath, 'utf8');
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
const githubTokenMatch = githubTokenRegex.exec(envContent);
const token = githubTokenMatch ? githubTokenMatch[1] : null;
return NextResponse.json({ token });
} catch (error) {
console.error('Error reading GitHub token:', error);
return NextResponse.json(
{ error: 'Failed to read GitHub token' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
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 { enabled } = await request.json();
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled value must be a boolean' },
{ 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 SAVE_FILTER already exists
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
const saveFilterMatch = saveFilterRegex.exec(envContent);
if (saveFilterMatch) {
// Replace existing SAVE_FILTER
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
} else {
// Add new SAVE_FILTER
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
} catch (error) {
console.error('Error saving save filter setting:', error);
return NextResponse.json(
{ error: 'Failed to save save filter setting' },
{ 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({ enabled: false });
}
// Read .env file and extract SAVE_FILTER
const envContent = fs.readFileSync(envPath, 'utf8');
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
const saveFilterMatch = saveFilterRegex.exec(envContent);
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
return NextResponse.json({ enabled });
} catch (error) {
console.error('Error reading save filter setting:', error);
return NextResponse.json(
{ error: 'Failed to read save filter setting' },
{ status: 500 }
);
}
}

View File

@@ -7,6 +7,7 @@ import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button';
@@ -43,13 +44,10 @@ export default function Home() {
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="flex flex-col gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<SettingsButton />
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<ResyncButton />
</div>
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
</div>
</div>