| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- import { useMemo, useState } from 'react';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import { Eye, EyeOff, Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon, RotateCcw } 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 { Toggle } from './Toggle';
- import { AddExternalLinkModal } from './AddExternalLinkModal';
- import { ConfirmModal } from './ConfirmModal';
- import { getIconByName } from './IconPicker';
- import { defaultNavItems } from './Layout';
- import { useAuth } from '../contexts/AuthContext';
- import { useToast } from '../contexts/ToastContext';
- import {
- getHiddenSidebarSystemItemIds,
- getSidebarOrder,
- saveHiddenSidebarSystemItemIds,
- saveSidebarOrder,
- } from '../utils/sidebarLayout';
- type SidebarLayoutItem =
- | { type: 'system'; id: string; navItem: typeof defaultNavItems[number] }
- | { type: 'external'; id: string; link: ExternalLink };
- export function ExternalLinksSettings() {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { authEnabled, hasPermission } = useAuth();
- const { showToast } = useToast();
- const [showAddModal, setShowAddModal] = useState(false);
- const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
- const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
- const [draggedId, setDraggedId] = useState<string | null>(null);
- const [dragOverId, setDragOverId] = useState<string | null>(null);
- const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
- const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
- const { data: links, isLoading } = useQuery({
- queryKey: ['external-links'],
- queryFn: api.getExternalLinks,
- });
- const deleteMutation = useMutation({
- mutationFn: (id: number) => api.deleteExternalLink(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['external-links'] });
- },
- });
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: api.getSettings,
- enabled: authEnabled && hasPermission('settings:update'),
- });
- const updateDefaultSidebarMutation = useMutation({
- mutationFn: (defaultSidebarOrder: string) => api.updateSettings({ default_sidebar_order: defaultSidebarOrder }),
- onSuccess: (_data, defaultSidebarOrder) => {
- queryClient.invalidateQueries({ queryKey: ['settings'] });
- queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
- showToast(
- defaultSidebarOrder
- ? t('settings.sidebarDefaultSet')
- : t('settings.sidebarDefaultCleared'),
- 'success'
- );
- },
- onError: () => {
- showToast(t('settings.sidebarDefaultFailed'), 'error');
- },
- });
- const layoutItems = useMemo<SidebarLayoutItem[]>(() => {
- const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
- const externalLinksMap = new Map((links || []).map(link => [`ext-${link.id}`, link]));
- const result: SidebarLayoutItem[] = [];
- const seen = new Set<string>();
- const addItem = (id: string) => {
- if (seen.has(id)) return;
- const navItem = navItemsMap.get(id);
- if (navItem) {
- result.push({ type: 'system', id, navItem });
- seen.add(id);
- return;
- }
- const link = externalLinksMap.get(id);
- if (link) {
- result.push({ type: 'external', id, link });
- seen.add(id);
- }
- };
- sidebarOrder.forEach(addItem);
- defaultNavItems.forEach(item => addItem(item.id));
- (links || []).forEach(link => addItem(`ext-${link.id}`));
- return result;
- }, [links, sidebarOrder]);
- const persistSidebarOrder = (order: string[]) => {
- setSidebarOrder(order);
- saveSidebarOrder(order);
- };
- const handleDragStart = (e: React.DragEvent, id: string) => {
- setDraggedId(id);
- e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('text/plain', id);
- };
- const handleDragOver = (e: React.DragEvent, id: string) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
- setDragOverId(id);
- };
- const handleDrop = (e: React.DragEvent, targetId: string) => {
- e.preventDefault();
- if (draggedId === null || draggedId === targetId) {
- setDraggedId(null);
- setDragOverId(null);
- return;
- }
- const currentOrder = layoutItems.map(item => item.id);
- const draggedIndex = currentOrder.indexOf(draggedId);
- const targetIndex = currentOrder.indexOf(targetId);
- if (draggedIndex === -1 || targetIndex === -1) {
- setDraggedId(null);
- setDragOverId(null);
- return;
- }
- currentOrder.splice(draggedIndex, 1);
- currentOrder.splice(targetIndex, 0, draggedId);
- persistSidebarOrder(currentOrder);
- setDraggedId(null);
- setDragOverId(null);
- };
- const handleDragEnd = () => {
- setDraggedId(null);
- setDragOverId(null);
- };
- const handleDelete = (link: ExternalLink) => {
- setDeletingLink(link);
- };
- const confirmDelete = () => {
- if (deletingLink) {
- deleteMutation.mutate(deletingLink.id);
- setDeletingLink(null);
- }
- };
- const resetSidebarLayout = () => {
- const resetOrder = [
- ...defaultNavItems.map(item => item.id),
- ...(links || []).map(link => `ext-${link.id}`),
- ];
- setHiddenSystemItemIds([]);
- setSidebarOrder(resetOrder);
- saveHiddenSidebarSystemItemIds([]);
- saveSidebarOrder(resetOrder);
- };
- const handleToggleDefaultSidebarOrder = (enabled: boolean) => {
- const currentOrder = layoutItems.map(item => item.id);
- updateDefaultSidebarMutation.mutate(enabled ? JSON.stringify({
- order: currentOrder,
- hiddenSystemItemIds,
- }) : '');
- };
- const toggleSystemItemVisibility = (id: string) => {
- if (id === 'settings') return;
- const isHidden = hiddenSystemItemIds.includes(id);
- const nextIds = isHidden
- ? hiddenSystemItemIds.filter(hiddenId => hiddenId !== id)
- : [...hiddenSystemItemIds, id];
- setHiddenSystemItemIds(nextIds);
- saveHiddenSidebarSystemItemIds(nextIds);
- };
- const canSetDefaultSidebarOrder = authEnabled && hasPermission('settings:update');
- const isDefaultSidebarEnabled = !!settings?.default_sidebar_order;
- return (
- <>
- <Card id="card-sidebar-links">
- <CardHeader>
- <div className="flex flex-wrap items-center justify-between gap-y-2 gap-x-3">
- <div className="flex items-center gap-2">
- <Link2 className="w-5 h-5 text-bambu-green" />
- <h2 className="text-lg font-semibold text-white">{t('externalLinks.sidebarLayout')}</h2>
- </div>
- <div className="flex flex-wrap items-center gap-2">
- {canSetDefaultSidebarOrder && (
- <label className="flex items-center gap-2 text-sm text-bambu-gray">
- <span>{t('settings.setDefault')}</span>
- <Toggle
- checked={isDefaultSidebarEnabled}
- onChange={handleToggleDefaultSidebarOrder}
- disabled={updateDefaultSidebarMutation.isPending}
- />
- </label>
- )}
- <Button variant="secondary" size="sm" onClick={resetSidebarLayout} className="whitespace-nowrap">
- <RotateCcw className="w-4 h-4" />
- {t('settings.reset')}
- </Button>
- <Button size="sm" onClick={() => setShowAddModal(true)} className="whitespace-nowrap">
- <Plus className="w-4 h-4" />
- Add Link
- </Button>
- </div>
- </div>
- </CardHeader>
- <CardContent>
- <p className="text-sm text-bambu-gray mb-4">
- {t('externalLinks.sidebarLayoutDescription')}
- </p>
- {isLoading ? (
- <div className="flex justify-center py-8">
- <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
- </div>
- ) : (
- <div className="space-y-2">
- {layoutItems.map((item) => {
- const isHidden = item.type === 'system' && hiddenSystemItemIds.includes(item.id);
- const isSettings = item.id === 'settings';
- if (item.type === 'system') {
- const Icon = item.navItem.icon;
- return (
- <div
- key={item.id}
- draggable
- onDragStart={(e) => handleDragStart(e, item.id)}
- onDragOver={(e) => handleDragOver(e, item.id)}
- onDragLeave={() => setDragOverId(null)}
- onDrop={(e) => handleDrop(e, item.id)}
- onDragEnd={handleDragEnd}
- className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
- draggedId === item.id ? 'opacity-50' : isHidden ? 'opacity-60' : ''
- } ${
- dragOverId === item.id && draggedId !== item.id
- ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
- : ''
- }`}
- >
- <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">
- <span className="text-white font-medium truncate block">{t(item.navItem.labelKey)}</span>
- <span className="text-sm text-bambu-gray truncate block">
- {isSettings
- ? t('externalLinks.requiredInSidebar')
- : isHidden
- ? t('externalLinks.hiddenFromSidebar')
- : t('externalLinks.visibleInSidebar')}
- </span>
- </div>
- <button
- onClick={() => toggleSystemItemVisibility(item.id)}
- disabled={isSettings}
- className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-bambu-gray"
- title={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
- aria-label={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
- >
- {isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
- </button>
- </div>
- );
- }
- const link = item.link;
- const Icon = getIconByName(link.icon);
- return (
- <div
- key={item.id}
- draggable
- onDragStart={(e) => handleDragStart(e, item.id)}
- onDragOver={(e) => handleDragOver(e, item.id)}
- onDragLeave={() => setDragOverId(null)}
- onDrop={(e) => handleDrop(e, item.id)}
- onDragEnd={handleDragEnd}
- className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
- draggedId === item.id ? 'opacity-50' : ''
- } ${
- dragOverId === item.id && draggedId !== item.id
- ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
- : ''
- }`}
- >
- <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">
- {link.custom_icon ? (
- <img
- src={api.getExternalLinkIconUrl(link.id)}
- alt=""
- className="w-4 h-4"
- />
- ) : (
- <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-100 dark:hover:bg-red-500/20 text-bambu-gray hover:text-red-900 dark:hover:text-red-400 transition-colors disabled:opacity-50"
- title={t('externalLinks.deleteLink')}
- >
- <Trash2 className="w-4 h-4" />
- </button>
- </div>
- </div>
- );
- })}
- </div>
- )}
- </CardContent>
- </Card>
- {(showAddModal || editingLink) && (
- <AddExternalLinkModal
- link={editingLink}
- onClose={() => {
- setShowAddModal(false);
- setEditingLink(null);
- }}
- />
- )}
- {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)}
- />
- )}
- </>
- );
- }
|