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:
committed by
GitHub
parent
92f78c7008
commit
5eaafbde48
@@ -16,4 +16,8 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
|||||||
|
|
||||||
# WebSocket Configuration
|
# WebSocket Configuration
|
||||||
WEBSOCKET_PORT="3001"
|
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 |
@@ -20,6 +20,8 @@ export function DownloadedScriptsTab() {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -29,6 +31,62 @@ export function DownloadedScriptsTab() {
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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
|
// 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 [];
|
||||||
@@ -341,6 +399,8 @@ export function DownloadedScriptsTab() {
|
|||||||
totalScripts={downloadedScripts.length}
|
totalScripts={downloadedScripts.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface FilterBarProps {
|
|||||||
totalScripts: number;
|
totalScripts: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
updatableCount?: number;
|
updatableCount?: number;
|
||||||
|
saveFiltersEnabled?: boolean;
|
||||||
|
isLoadingFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_TYPES = [
|
const SCRIPT_TYPES = [
|
||||||
@@ -33,6 +35,8 @@ export function FilterBar({
|
|||||||
totalScripts,
|
totalScripts,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
updatableCount = 0,
|
updatableCount = 0,
|
||||||
|
saveFiltersEnabled = false,
|
||||||
|
isLoadingFilters = false,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||||
@@ -78,6 +82,28 @@ export function FilterBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
<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 */}
|
{/* Search Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
|
|||||||
308
src/app/_components/GeneralSettingsModal.tsx
Normal file
308
src/app/_components/GeneralSettingsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -35,6 +37,62 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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
|
// 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 [];
|
||||||
@@ -337,6 +395,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
totalScripts={scriptsWithStatus.length}
|
totalScripts={scriptsWithStatus.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
|
|||||||
50
src/app/_components/ServerSettingsButton.tsx
Normal file
50
src/app/_components/ServerSettingsButton.tsx
Normal 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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
export function SettingsButton() {
|
export function SettingsButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Add and manage PVE Servers:
|
Application Settings:
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
title="Add PVE Server"
|
title="Open Settings"
|
||||||
>
|
>
|
||||||
<svg
|
<Settings className="w-5 h-5 mr-2" />
|
||||||
className="w-5 h-5 mr-2"
|
Settings
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -116,35 +115,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
@@ -164,7 +134,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'servers' && (
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||||
@@ -187,14 +156,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
src/app/_components/ui/input.tsx
Normal file
23
src/app/_components/ui/input.tsx
Normal 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 }
|
||||||
41
src/app/_components/ui/toggle.tsx
Normal file
41
src/app/_components/ui/toggle.tsx
Normal 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 }
|
||||||
141
src/app/api/settings/filters/route.ts
Normal file
141
src/app/api/settings/filters/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/github-token/route.ts
Normal file
75
src/app/api/settings/github-token/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/save-filter/route.ts
Normal file
75
src/app/api/settings/save-filter/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
|||||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||||
import { ResyncButton } from './_components/ResyncButton';
|
import { ResyncButton } from './_components/ResyncButton';
|
||||||
import { Terminal } from './_components/Terminal';
|
import { Terminal } from './_components/Terminal';
|
||||||
|
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||||
import { SettingsButton } from './_components/SettingsButton';
|
import { SettingsButton } from './_components/SettingsButton';
|
||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { VersionDisplay } from './_components/VersionDisplay';
|
||||||
import { Button } from './_components/ui/button';
|
import { Button } from './_components/ui/button';
|
||||||
@@ -43,15 +44,12 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<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:flex-wrap sm:items-center 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">
|
<ServerSettingsButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
||||||
<ResyncButton />
|
<ResyncButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user