Files
bambuddy/frontend/src/components/ExternalLinksSettings.tsx
T
maziggy 180db8e8ad Expand Settings search with module-level registry
Search field at the top of Settings now finds Sidebar Links,
  Spoolman, Spool/Color Catalog, all four Failure Detection
  sections, Email auth (Advanced + SMTP test), 2FA (TOTP, Email
  OTP, Linked Accounts), SSO/OIDC, LDAP Server Config, and the
  four Backup sub-cards (GitHub, History, Local, Scheduled).

  Replaces the hardcoded searchIndex array in SettingsPage.tsx
  with a module-level registry (frontend/src/lib/settingsSearch.ts).
  Each settings card calls registerSettingsSearch(...) at module
  scope, so adding a new card means adding one colocated line
  instead of editing a distant central array. Anchor ids were
  added to the corresponding Card elements in the affected
  components so scrollIntoView lands on the right section.
2026-04-13 16:13:04 +02:00

190 lines
6.9 KiB
TypeScript

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { ExternalLink } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { AddExternalLinkModal } from './AddExternalLinkModal';
import { ConfirmModal } from './ConfirmModal';
import { getIconByName } from './IconPicker';
export function ExternalLinksSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showAddModal, setShowAddModal] = useState(false);
const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
const [draggedId, setDraggedId] = useState<number | null>(null);
// Fetch external links
const { data: links, isLoading } = useQuery({
queryKey: ['external-links'],
queryFn: api.getExternalLinks,
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteExternalLink(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['external-links'] });
},
});
// Reorder mutation
const reorderMutation = useMutation({
mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['external-links'] });
},
});
const handleDragStart = (e: React.DragEvent, id: number) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetId: number) => {
e.preventDefault();
if (draggedId === null || draggedId === targetId || !links) return;
const currentIds = links.map((l) => l.id);
const draggedIndex = currentIds.indexOf(draggedId);
const targetIndex = currentIds.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return;
// Reorder
const newIds = [...currentIds];
newIds.splice(draggedIndex, 1);
newIds.splice(targetIndex, 0, draggedId);
reorderMutation.mutate(newIds);
setDraggedId(null);
};
const handleDelete = (link: ExternalLink) => {
setDeletingLink(link);
};
const confirmDelete = () => {
if (deletingLink) {
deleteMutation.mutate(deletingLink.id);
setDeletingLink(null);
}
};
return (
<>
<Card id="card-sidebar-links">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Sidebar Links</h2>
</div>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Link
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
Add external links to the sidebar navigation. Drag to reorder.
</p>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : links && links.length > 0 ? (
<div className="space-y-2">
{links.map((link) => {
const Icon = getIconByName(link.icon);
return (
<div
key={link.id}
draggable
onDragStart={(e) => handleDragStart(e, link.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, link.id)}
className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
draggedId === link.id ? 'opacity-50' : ''
}`}
>
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white font-medium truncate">{link.name}</span>
<ExternalLinkIcon className="w-3 h-3 text-bambu-gray flex-shrink-0" />
</div>
<span className="text-sm text-bambu-gray truncate block">{link.url}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => setEditingLink(link)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title={t('common.edit')}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(link)}
disabled={deleteMutation.isPending}
className="p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50"
title={t('externalLinks.deleteLink')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>{t('externalLinks.noLinksConfigured')}</p>
<p className="text-sm">Click "Add Link" to add one</p>
</div>
)}
</CardContent>
</Card>
{/* Add/Edit Modal */}
{(showAddModal || editingLink) && (
<AddExternalLinkModal
link={editingLink}
onClose={() => {
setShowAddModal(false);
setEditingLink(null);
}}
/>
)}
{/* Delete Confirmation Modal */}
{deletingLink && (
<ConfirmModal
title="Delete Link"
message={`Are you sure you want to delete "${deletingLink.name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={confirmDelete}
onCancel={() => setDeletingLink(null)}
/>
)}
</>
);
}