351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Snippet } from '../types';
|
|
import { Search, Copy, Check, Terminal, Folder, Star, Clock, FolderGit, Cpu, Trash2, Plus, Zap, FileText } from 'lucide-react';
|
|
|
|
export default function SnippetsView() {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeCollection, setActiveCollection] = useState<'all' | 'favorites' | 'recent'>('all');
|
|
const [activeTag, setActiveTag] = useState<string>('all');
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
// Snippets Initial Database State
|
|
const [snippets, setSnippets] = useState<Snippet[]>([
|
|
{
|
|
id: '1',
|
|
title: 'Docker Resource Prune Suite',
|
|
description: 'Force clean-up unused container images, volumes, logs, networks, and untagged builds safely.',
|
|
code: 'docker system prune -a --volumes --force',
|
|
tags: ['Docker', 'DevOps'],
|
|
collection: 'favorites',
|
|
updatedAt: '2 hours ago',
|
|
icon: 'Cpu'
|
|
},
|
|
{
|
|
id: '2',
|
|
title: 'Nginx TLS Reverse Proxy Template',
|
|
description: 'Standard secure production proxy server configuration block forwarding upstream headers.',
|
|
code: `server {
|
|
listen 443 ssl http2;
|
|
server_name api.domain.com;
|
|
|
|
location / {
|
|
proxy_pass http://localhost:3000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
}`,
|
|
tags: ['Nginx', 'SSL'],
|
|
collection: 'favorites',
|
|
updatedAt: 'Yesterday',
|
|
icon: 'Folder'
|
|
},
|
|
{
|
|
id: '3',
|
|
title: 'Git Force Fetch and Hard Reset',
|
|
description: 'Discards local changes completely and resets HEAD to track remote production origin main.',
|
|
code: 'git fetch origin && git reset --hard origin/main',
|
|
tags: ['Git', 'VCS'],
|
|
collection: 'recent',
|
|
updatedAt: '3 days ago',
|
|
icon: 'FolderGit'
|
|
},
|
|
{
|
|
id: '4',
|
|
title: 'Inspect SSL Certification Expiry',
|
|
description: 'Checks expiry metrics and issuer of ssl keys on active remote addresses.',
|
|
code: 'openssl s_client -connect google.com:443 | openssl x509 -noout -dates',
|
|
tags: ['SSL', 'Security'],
|
|
collection: 'recent',
|
|
updatedAt: 'Last week',
|
|
icon: 'Cpu'
|
|
}
|
|
]);
|
|
|
|
const [newTitle, setNewTitle] = useState('');
|
|
const [newDesc, setNewDesc] = useState('');
|
|
const [newCode, setNewCode] = useState('');
|
|
const [newTag, setNewTag] = useState('DevOps');
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
|
|
const handleCopyCode = (id: string, code: string) => {
|
|
navigator.clipboard.writeText(code);
|
|
setCopiedId(id);
|
|
setTimeout(() => {
|
|
setCopiedId(null);
|
|
}, 2000);
|
|
};
|
|
|
|
const handleDeleteSnippet = (id: string) => {
|
|
setSnippets(prev => prev.filter(s => s.id !== id));
|
|
};
|
|
|
|
const handleCreateSnippet = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newTitle || !newCode) return;
|
|
|
|
const added: Snippet = {
|
|
id: Date.now().toString(),
|
|
title: newTitle,
|
|
description: newDesc,
|
|
code: newCode,
|
|
tags: [newTag],
|
|
collection: 'recent',
|
|
updatedAt: 'Just now',
|
|
icon: 'FileText'
|
|
};
|
|
|
|
setSnippets([added, ...snippets]);
|
|
setShowAddForm(false);
|
|
setNewTitle('');
|
|
setNewDesc('');
|
|
setNewCode('');
|
|
};
|
|
|
|
const allTags = ['all', ...Array.from(new Set(snippets.flatMap(s => s.tags)))];
|
|
|
|
const filteredSnippets = snippets.filter(s => {
|
|
const matchesSearch = s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.code.toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
const matchesCollection = activeCollection === 'all' || s.collection === activeCollection;
|
|
const matchesTag = activeTag === 'all' || s.tags.includes(activeTag);
|
|
|
|
return matchesSearch && matchesCollection && matchesTag;
|
|
});
|
|
|
|
return (
|
|
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 animate-fade-in">
|
|
|
|
{/* Search Header Panel */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-4">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-on-surface tracking-tight">Snippets & Scripts Library</h2>
|
|
<p className="text-xs text-on-surface-variant mt-0.5">Quickly reuse and catalog command line shortcuts across remote clusters</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-outline w-4 h-4" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search code snippets..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 pr-4 py-1.5 rounded-full bg-surface-container-low border border-outline-variant/30 text-sm focus:border-primary focus:bg-white focus:outline-none focus:ring-1 focus:ring-primary w-48 sm:w-60 transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowAddForm(true)}
|
|
className="flex items-center gap-1.5 bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-2 px-4 rounded-xl shadow-md transition-all cursor-pointer"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Snippet
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid Architecture */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
|
{/* Left category rails filter */}
|
|
<div className="lg:col-span-3 space-y-5">
|
|
<div className="bg-white rounded-2xl border border-outline-variant p-4 space-y-4">
|
|
<h4 className="text-xs font-bold text-outline-variant uppercase tracking-wider px-2">Collections</h4>
|
|
<div className="space-y-1 text-sm font-semibold">
|
|
<button
|
|
onClick={() => setActiveCollection('all')}
|
|
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'all' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
|
>
|
|
<span className="flex items-center gap-2"><Folder className="w-4 h-4" /> All Code Blocks</span>
|
|
<span className="text-xs text-outline">{snippets.length}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveCollection('favorites')}
|
|
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'favorites' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
|
>
|
|
<span className="flex items-center gap-2"><Star className="w-4 h-4 text-amber-500 fill-amber-500" /> Starred Favorites</span>
|
|
<span className="text-xs text-outline">{snippets.filter(s => s.collection === 'favorites').length}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveCollection('recent')}
|
|
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'recent' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
|
>
|
|
<span className="flex items-center gap-2"><Clock className="w-4 h-4" /> Recent</span>
|
|
<span className="text-xs text-outline">{snippets.filter(s => s.collection === 'recent').length}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl border border-outline-variant p-4 space-y-3">
|
|
<h4 className="text-xs font-bold text-outline-variant uppercase tracking-wider px-2">Tag Categories</h4>
|
|
<div className="flex flex-wrap gap-1.5 p-1">
|
|
{allTags.map(tag => (
|
|
<button
|
|
key={tag}
|
|
onClick={() => setActiveTag(tag)}
|
|
className={`px-3 py-1 rounded-full text-xs font-bold transition-all ${activeTag === tag ? 'bg-primary text-white' : 'bg-surface-container-low text-on-surface-variant hover:bg-surface-container'}`}
|
|
>
|
|
{tag === 'all' ? 'All Tags' : tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right main snippets cards list */}
|
|
<div className="lg:col-span-9 space-y-6">
|
|
{filteredSnippets.length === 0 ? (
|
|
<div className="p-12 border border-dashed border-outline-variant rounded-2xl text-center text-outline">
|
|
No matching snippet found in {activeCollection} category.
|
|
</div>
|
|
) : (
|
|
filteredSnippets.map((snippet) => (
|
|
<div
|
|
key={snippet.id}
|
|
className="bg-white border border-outline-variant rounded-2xl overflow-hidden shadow-sm flex flex-col group"
|
|
>
|
|
<div className="p-5 flex justify-between items-start gap-4">
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="font-bold text-on-surface text-base group-hover:text-primary transition-colors">{snippet.title}</h3>
|
|
<div className="flex flex-wrap gap-1">
|
|
{snippet.tags.map(t => (
|
|
<span key={t} className="px-2 py-0.5 bg-surface-container text-[10px] font-bold rounded text-on-surface-variant">
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant leading-relaxed">{snippet.description}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
onClick={() => handleCopyCode(snippet.id, snippet.code)}
|
|
className="p-1.5 border border-outline-variant rounded-lg bg-surface-container-low hover:border-primary text-outline hover:text-primary transition-colors cursor-pointer"
|
|
title="Copy code to clipboard"
|
|
>
|
|
{copiedId === snippet.id ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteSnippet(snippet.id)}
|
|
className="p-1.5 border border-outline-variant rounded-lg bg-surface-container-low hover:border-error text-outline hover:text-error transition-colors"
|
|
title="Delete code snippet"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Code syntax terminal container */}
|
|
<div className="bg-surface-container-low border-t border-outline-variant/40 p-4 font-mono text-xs text-on-surface relative group/code overflow-x-auto">
|
|
<pre className="text-on-surface select-all leading-relaxed whitespace-pre font-medium">{snippet.code}</pre>
|
|
<button
|
|
onClick={() => handleCopyCode(snippet.id, snippet.code)}
|
|
className="absolute right-4 top-4 bg-white/90 backdrop-blur border border-outline-variant shadow px-2.5 py-1 rounded text-[10px] font-mono font-bold hover:border-primary hover:text-primary transition-colors opacity-0 group-hover/code:opacity-100"
|
|
>
|
|
{copiedId === snippet.id ? 'COPIED ✓' : 'COPY'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-5 py-2 border-t border-outline-variant/20 bg-surface-container-lowest flex justify-between items-center text-[10px] text-outline font-semibold">
|
|
<span>Last adjusted {snippet.updatedAt}</span>
|
|
<span className="flex items-center gap-1 text-primary"><Zap className="w-3 h-3 fill-primary/10" /> Ready to inject to shell</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Snippet Modal Dialog */}
|
|
{showAddForm && (
|
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[150] flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl max-w-lg w-full border border-outline-variant shadow-2xl p-6 space-y-4 animate-scale-up">
|
|
<div className="flex justify-between items-center border-b border-outline-variant/30 pb-3">
|
|
<h3 className="font-bold text-lg text-on-surface">Store Custom Snippet</h3>
|
|
<button
|
|
onClick={() => setShowAddForm(false)}
|
|
className="text-outline hover:text-primary cursor-pointer p-1 rounded"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateSnippet} className="space-y-4 text-sm">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs font-bold text-on-surface-variant">SNIPPET TITLE</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="e.g. Purge Docker Cache"
|
|
value={newTitle}
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
|
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs font-bold text-on-surface-variant">SHORT DESCRIPTION / USAGE</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. Safe cleanup to recover workspace disk capacity."
|
|
value={newDesc}
|
|
onChange={(e) => setNewDesc(e.target.value)}
|
|
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs font-bold text-on-surface-variant">CODE SYNTAX BLOCK</label>
|
|
<textarea
|
|
required
|
|
rows={4}
|
|
placeholder="e.g. systemctl restart nginx"
|
|
value={newCode}
|
|
onChange={(e) => setNewCode(e.target.value)}
|
|
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs font-bold text-on-surface-variant">PRIMARY TAG CATEGORY</label>
|
|
<select
|
|
value={newTag}
|
|
onChange={(e) => setNewTag(e.target.value)}
|
|
className="bg-surface-container-low border border-outline-variant/40 rounded-lg py-2 px-3 focus:outline-none focus:border-primary"
|
|
>
|
|
<option value="DevOps">DevOps</option>
|
|
<option value="Docker">Docker</option>
|
|
<option value="Nginx">Nginx</option>
|
|
<option value="Git">Git</option>
|
|
<option value="SSL">SSL</option>
|
|
<option value="Security">Security</option>
|
|
<option value="Database">Database</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="pt-4 flex justify-end gap-3 border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddForm(false)}
|
|
className="px-4 py-2 text-xs font-bold text-outline hover:text-on-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="bg-primary text-white py-2 px-5 rounded-lg font-bold text-xs shadow-md hover:bg-primary-container hover:text-on-primary-container transition-all"
|
|
>
|
|
Save Code Snippet
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|