Skip to content

Commit 85db310

Browse files
committed
feat(web): add batch import and export for keys
- Change Input to Textarea for multi-line key input - Add 'Import from File' button to load keys from .txt file - Add Export button to download all keys as factory-keys.txt Closes #8
1 parent 87bf9e6 commit 85db310

1 file changed

Lines changed: 65 additions & 14 deletions

File tree

web/src/components/KeyList.tsx

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useState, useEffect, useCallback } from 'react';
2-
import { Trash2, Plus, RefreshCw, Terminal, CheckCircle2, Copy, Circle, X, AlertTriangle } from 'lucide-react';
1+
import { useState, useEffect, useCallback, useRef } from 'react';
2+
import { Trash2, Plus, RefreshCw, Terminal, CheckCircle2, Copy, Circle, X, AlertTriangle, Download, Upload } from 'lucide-react';
33
import { toast } from 'sonner';
44
import { sounds } from '@/lib/sound';
55
import { decryptKeys, maskKey } from '@/utils/crypto';
@@ -11,7 +11,7 @@ import { Badge } from '@/components/ui/badge';
1111
import { Progress } from '@/components/ui/progress';
1212
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
1313
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
14-
import { Input } from '@/components/ui/input';
14+
import { Textarea } from '@/components/ui/textarea';
1515
import {
1616
AlertDialog,
1717
AlertDialogAction,
@@ -164,6 +164,7 @@ export default function KeyList() {
164164
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
165165
const [newKey, setNewKey] = useState('');
166166
const [adding, setAdding] = useState(false);
167+
const fileInputRef = useRef<HTMLInputElement>(null);
167168

168169
const loadData = useCallback(async (showRefreshing = false, autoRefresh = true, silent = false) => {
169170
try {
@@ -233,17 +234,52 @@ export default function KeyList() {
233234
const handleAddKey = async () => {
234235
if (!newKey.trim()) return;
235236
setAdding(true);
236-
const result = await addKey(newKey.trim());
237-
if (result.success) {
237+
const lines = newKey.split('\n').map(l => l.trim()).filter(Boolean);
238+
let successCount = 0;
239+
let lastError = '';
240+
for (const key of lines) {
241+
const result = await addKey(key);
242+
if (result.success) {
243+
successCount++;
244+
} else {
245+
lastError = result.error || 'Failed to add key';
246+
}
247+
}
248+
if (successCount > 0) {
238249
setNewKey('');
239250
setAddDialogOpen(false);
240251
await loadData(true);
241-
} else {
242-
alert(result.error || 'Failed to add key');
252+
}
253+
if (lastError && successCount < lines.length) {
254+
alert(`Added ${successCount}/${lines.length} keys. Error: ${lastError}`);
243255
}
244256
setAdding(false);
245257
};
246258

259+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
260+
const file = e.target.files?.[0];
261+
if (!file) return;
262+
const reader = new FileReader();
263+
reader.onload = (event) => {
264+
const text = event.target?.result as string;
265+
setNewKey(text);
266+
};
267+
reader.readAsText(file);
268+
e.target.value = '';
269+
};
270+
271+
const handleExport = () => {
272+
if (keys.length === 0) return;
273+
const text = keys.map(k => k.key).join('\n');
274+
const blob = new Blob([text], { type: 'text/plain' });
275+
const url = URL.createObjectURL(blob);
276+
const a = document.createElement('a');
277+
a.href = url;
278+
a.download = 'factory-keys.txt';
279+
a.click();
280+
URL.revokeObjectURL(url);
281+
};
282+
247283
const handleRemoveKey = (index: number) => {
248284
setDeleteIndex(index);
249285
};
@@ -319,6 +355,9 @@ export default function KeyList() {
319355
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={refreshing} className="h-8 w-8" title="Refresh">
320356
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
321357
</Button>
358+
<Button variant="outline" size="icon" onClick={handleExport} disabled={keys.length === 0} className="h-8 w-8" title="Export All Keys">
359+
<Download className="h-3.5 w-3.5" />
360+
</Button>
322361
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
323362
<DialogTrigger asChild>
324363
<Button size="sm" className="h-8 text-xs px-3">
@@ -328,19 +367,31 @@ export default function KeyList() {
328367
</DialogTrigger>
329368
<DialogContent>
330369
<DialogHeader>
331-
<DialogTitle>Inject New Key</DialogTitle>
332-
<DialogDescription>Enter your Factory API key below.</DialogDescription>
370+
<DialogTitle>Inject Keys</DialogTitle>
371+
<DialogDescription>Enter your Factory API keys below (one per line).</DialogDescription>
333372
</DialogHeader>
334-
<Input
335-
placeholder="fk-..."
373+
<Textarea
374+
placeholder={"fk-xxx\nfk-yyy\nfk-zzz"}
336375
value={newKey}
337376
onChange={(e) => setNewKey(e.target.value)}
338-
onKeyDown={(e) => e.key === 'Enter' && handleAddKey()}
377+
rows={5}
378+
className="font-mono text-sm"
379+
/>
380+
<input
381+
type="file"
382+
accept=".txt"
383+
ref={fileInputRef}
384+
onChange={handleFileSelect}
385+
className="hidden"
339386
/>
340-
<DialogFooter>
387+
<DialogFooter className="flex-col sm:flex-row gap-2">
388+
<Button variant="outline" onClick={() => fileInputRef.current?.click()} className="sm:mr-auto">
389+
<Upload className="h-3.5 w-3.5 mr-1.5" />
390+
IMPORT FROM FILE
391+
</Button>
341392
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>CANCEL</Button>
342393
<Button onClick={handleAddKey} disabled={adding || !newKey.trim()}>
343-
{adding ? 'INJECTING...' : 'INJECT KEY'}
394+
{adding ? 'INJECTING...' : 'INJECT KEYS'}
344395
</Button>
345396
</DialogFooter>
346397
</DialogContent>

0 commit comments

Comments
 (0)