Compare commits
2 Commits
fix/variou
...
feat/newes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c9a2ecbf | ||
|
|
16e918e9b4 |
@@ -1376,7 +1376,7 @@ export function InstalledScriptsTab() {
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => handleInputChange('web_ui_ip', e.target.value)}
|
||||
className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
className="w-40 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="IP"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PublicKeyModalProps {
|
||||
|
||||
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -54,6 +55,42 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
const command = `echo "${publicKey}" >> ~/.ssh/authorized_keys`;
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCommandCopied(true);
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback for older browsers or non-HTTPS
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = command;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCommandCopied(true);
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback copy failed:', fallbackError);
|
||||
alert('Please manually copy this command:\n\n' + command);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy command to clipboard:', error);
|
||||
alert('Please manually copy this command:\n\n' + command);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
@@ -129,11 +166,44 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
<textarea
|
||||
value={publicKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Public key will appear here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Command */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Quick Add Command:</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyCommand}
|
||||
className="gap-2"
|
||||
>
|
||||
{commandCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md border border-border">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
echo "{publicKey}" >> ~/.ssh/authorized_keys
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy and paste this command directly into your server terminal to add the key to authorized_keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -30,7 +30,11 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
let content = e.target?.result as string;
|
||||
|
||||
// Auto-trim trailing whitespace and empty lines to fix import issues
|
||||
content = content.replace(/\n\s*$/, '').trimEnd();
|
||||
|
||||
if (validateSSHKey(content)) {
|
||||
onChange(content);
|
||||
onError?.('');
|
||||
@@ -72,7 +76,12 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
};
|
||||
|
||||
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = event.target.value;
|
||||
let content = event.target.value;
|
||||
|
||||
// Auto-trim trailing whitespace and empty lines to fix import issues
|
||||
// This addresses the common problem where pasted SSH keys have extra whitespace
|
||||
content = content.replace(/\n\s*$/, '').trimEnd();
|
||||
|
||||
onChange(content);
|
||||
|
||||
if (content.trim() && !validateSSHKey(content)) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import { Clock } from 'lucide-react';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
|
||||
|
||||
@@ -220,6 +221,31 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
});
|
||||
}, [combinedScripts, localScriptsData]);
|
||||
|
||||
// Check if any filters are active (excluding default state)
|
||||
const hasActiveFilters = React.useMemo(() => {
|
||||
return (
|
||||
filters.searchQuery?.trim() !== '' ||
|
||||
filters.showUpdatable !== null ||
|
||||
filters.selectedTypes.length > 0 ||
|
||||
filters.sortBy !== 'name' ||
|
||||
filters.sortOrder !== 'asc' ||
|
||||
selectedCategory !== null
|
||||
);
|
||||
}, [filters, selectedCategory]);
|
||||
|
||||
// Get the 6 newest scripts based on date_created field
|
||||
const newestScripts = React.useMemo((): ScriptCardType[] => {
|
||||
return scriptsWithStatus
|
||||
.filter(script => script?.date_created) // Only scripts with date_created
|
||||
.sort((a, b) => {
|
||||
const aCreated = a?.date_created ?? '';
|
||||
const bCreated = b?.date_created ?? '';
|
||||
// Sort by date descending (newest first)
|
||||
return bCreated.localeCompare(aCreated);
|
||||
})
|
||||
.slice(0, 6); // Take only the first 6
|
||||
}, [scriptsWithStatus]);
|
||||
|
||||
// Filter scripts based on all filters and category
|
||||
const filteredScripts = React.useMemo((): ScriptCardType[] => {
|
||||
let scripts = scriptsWithStatus;
|
||||
@@ -270,6 +296,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Exclude newest scripts from main grid when no filters are active (they'll be shown in carousel)
|
||||
if (!hasActiveFilters) {
|
||||
const newestScriptSlugs = new Set(newestScripts.map(script => script.slug).filter(Boolean));
|
||||
scripts = scripts.filter(script => !newestScriptSlugs.has(script.slug));
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
scripts.sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
@@ -309,7 +341,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
});
|
||||
|
||||
return scripts;
|
||||
}, [scriptsWithStatus, filters, selectedCategory]);
|
||||
}, [scriptsWithStatus, filters, selectedCategory, hasActiveFilters, newestScripts]);
|
||||
|
||||
// Calculate filter counts for FilterBar
|
||||
const filterCounts = React.useMemo(() => {
|
||||
@@ -619,6 +651,51 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Newest Scripts Carousel - Only show when no filters are active */}
|
||||
{!hasActiveFilters && newestScripts.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Clock className="h-6 w-6 text-primary" />
|
||||
Newest Scripts
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{newestScripts.length} recently added
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
||||
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
|
||||
{newestScripts.map((script, index) => {
|
||||
if (!script || typeof script !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
return (
|
||||
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
|
||||
<div className="relative">
|
||||
<ScriptCard
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||
onToggleSelect={toggleScriptSelection}
|
||||
/>
|
||||
{/* NEW badge */}
|
||||
<div className="absolute top-2 right-2 bg-green-600 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
|
||||
NEW
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
|
||||
@@ -351,13 +351,25 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="gap-2 border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
The private key has been generated and will be saved with the server.
|
||||
|
||||
Reference in New Issue
Block a user