diff --git a/.env.example b/.env.example index 22ab3db..0057588 100644 --- a/.env.example +++ b/.env.example @@ -16,4 +16,8 @@ ALLOWED_SCRIPT_PATHS="scripts/" # WebSocket Configuration WEBSOCKET_PORT="3001" -GITHUB_TOKEN=your_github_token_here \ No newline at end of file + +# User settings +GITHUB_TOKEN= +SAVE_FILTER=false +FILTERS= diff --git a/public/favicon.png b/public/favicon.png index 1d2186d..5e4b11b 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/src/app/_components/DownloadedScriptsTab.tsx b/src/app/_components/DownloadedScriptsTab.tsx index 1b361ce..dfb5af4 100644 --- a/src/app/_components/DownloadedScriptsTab.tsx +++ b/src/app/_components/DownloadedScriptsTab.tsx @@ -20,6 +20,8 @@ export function DownloadedScriptsTab() { sortBy: 'name', sortOrder: 'asc', }); + const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false); + const [isLoadingFilters, setIsLoadingFilters] = useState(true); const gridRef = useRef(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 */} diff --git a/src/app/_components/FilterBar.tsx b/src/app/_components/FilterBar.tsx index 895a13a..72b66f7 100644 --- a/src/app/_components/FilterBar.tsx +++ b/src/app/_components/FilterBar.tsx @@ -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 (
+ {/* Loading State */} + {isLoadingFilters && ( +
+
+
+ Loading saved filters... +
+
+ )} + + {/* Filter Persistence Status */} + {!isLoadingFilters && saveFiltersEnabled && ( +
+
+ + + + Filters are being saved automatically +
+
+ )} + {/* Search Bar */}
diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx new file mode 100644 index 0000000..05e5dd7 --- /dev/null +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+

Settings

+ +
+ + {/* Tabs */} +
+ +
+ + {/* Content */} +
+ {activeTab === 'general' && ( +
+ +

General Settings

+

+ Configure general application preferences and behavior. +

+
+
+

Save Filters

+

Save your configured script filters.

+ + + {saveFilter && ( +
+
+
+

Saved Filters

+

+ {savedFilters ? 'Filters are currently saved' : 'No filters saved yet'} +

+ {savedFilters && ( +
+
Search: {savedFilters.searchQuery ?? 'None'}
+
Types: {savedFilters.selectedTypes?.length ?? 0} selected
+
Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})
+
+ )} +
+ {savedFilters && ( + + )} +
+
+ )} +
+
+
+ )} + + {activeTab === 'github' && ( +
+
+

GitHub Integration

+

+ Configure GitHub integration for script management and updates. +

+
+
+

GitHub Personal Access Token

+

Save a GitHub Personal Access Token to circumvent GitHub API rate limits.

+ +
+
+ + ) => setGithubToken(e.target.value)} + disabled={isLoading || isSaving} + className="w-full" + /> +
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+
+
+
+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index 475a0a2..ec50191 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -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(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) */} diff --git a/src/app/_components/ServerSettingsButton.tsx b/src/app/_components/ServerSettingsButton.tsx new file mode 100644 index 0000000..ccbafd3 --- /dev/null +++ b/src/app/_components/ServerSettingsButton.tsx @@ -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 ( + <> +
+
+ Add and manage PVE Servers: +
+ +
+ + setIsOpen(false)} /> + + ); +} diff --git a/src/app/_components/SettingsButton.tsx b/src/app/_components/SettingsButton.tsx index 2b57125..88a21f2 100644 --- a/src/app/_components/SettingsButton.tsx +++ b/src/app/_components/SettingsButton.tsx @@ -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() { <>
- Add and manage PVE Servers: + Application Settings:
- setIsOpen(false)} /> + setIsOpen(false)} /> ); } - diff --git a/src/app/_components/SettingsModal.tsx b/src/app/_components/SettingsModal.tsx index 5a9edf6..f94a789 100644 --- a/src/app/_components/SettingsModal.tsx +++ b/src/app/_components/SettingsModal.tsx @@ -15,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers'); useEffect(() => { if (isOpen) { @@ -116,35 +115,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
- {/* Tabs */} -
- -
{/* Content */}
@@ -164,37 +134,28 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} - {activeTab === 'servers' && ( -
-
-

Server Configurations

- -
- -
-

Saved Servers

- {loading ? ( -
-
-

Loading servers...

-
- ) : ( - - )} -
-
- )} - - {activeTab === 'general' && ( +
-

General Settings

-

General settings will be available in a future update.

+

Server Configurations

+
- )} + +
+

Saved Servers

+ {loading ? ( +
+
+

Loading servers...

+
+ ) : ( + + )} +
+
diff --git a/src/app/_components/ui/input.tsx b/src/app/_components/ui/input.tsx new file mode 100644 index 0000000..c653f2e --- /dev/null +++ b/src/app/_components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +import { cn } from "../../../lib/utils" + +export type InputProps = React.InputHTMLAttributes + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/app/_components/ui/toggle.tsx b/src/app/_components/ui/toggle.tsx new file mode 100644 index 0000000..bd8d3f2 --- /dev/null +++ b/src/app/_components/ui/toggle.tsx @@ -0,0 +1,41 @@ +import * as React from "react" +import { cn } from "../../../lib/utils" + +export interface ToggleProps + extends Omit, 'type'> { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + label?: string; +} + +const Toggle = React.forwardRef( + ({ className, checked, onCheckedChange, label, ...props }, ref) => { + return ( +
+