ExternalLinksSettings.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { useMemo, useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Eye, EyeOff, Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon, RotateCcw } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { ExternalLink } from '../api/client';
  7. import { Card, CardContent, CardHeader } from './Card';
  8. import { Button } from './Button';
  9. import { Toggle } from './Toggle';
  10. import { AddExternalLinkModal } from './AddExternalLinkModal';
  11. import { ConfirmModal } from './ConfirmModal';
  12. import { getIconByName } from './IconPicker';
  13. import { defaultNavItems } from './Layout';
  14. import { useAuth } from '../contexts/AuthContext';
  15. import { useToast } from '../contexts/ToastContext';
  16. import {
  17. getHiddenSidebarSystemItemIds,
  18. getSidebarOrder,
  19. saveHiddenSidebarSystemItemIds,
  20. saveSidebarOrder,
  21. } from '../utils/sidebarLayout';
  22. type SidebarLayoutItem =
  23. | { type: 'system'; id: string; navItem: typeof defaultNavItems[number] }
  24. | { type: 'external'; id: string; link: ExternalLink };
  25. export function ExternalLinksSettings() {
  26. const { t } = useTranslation();
  27. const queryClient = useQueryClient();
  28. const { authEnabled, hasPermission } = useAuth();
  29. const { showToast } = useToast();
  30. const [showAddModal, setShowAddModal] = useState(false);
  31. const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
  32. const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
  33. const [draggedId, setDraggedId] = useState<string | null>(null);
  34. const [dragOverId, setDragOverId] = useState<string | null>(null);
  35. const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
  36. const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
  37. const { data: links, isLoading } = useQuery({
  38. queryKey: ['external-links'],
  39. queryFn: api.getExternalLinks,
  40. });
  41. const deleteMutation = useMutation({
  42. mutationFn: (id: number) => api.deleteExternalLink(id),
  43. onSuccess: () => {
  44. queryClient.invalidateQueries({ queryKey: ['external-links'] });
  45. },
  46. });
  47. const { data: settings } = useQuery({
  48. queryKey: ['settings'],
  49. queryFn: api.getSettings,
  50. enabled: authEnabled && hasPermission('settings:update'),
  51. });
  52. const updateDefaultSidebarMutation = useMutation({
  53. mutationFn: (defaultSidebarOrder: string) => api.updateSettings({ default_sidebar_order: defaultSidebarOrder }),
  54. onSuccess: (_data, defaultSidebarOrder) => {
  55. queryClient.invalidateQueries({ queryKey: ['settings'] });
  56. queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
  57. showToast(
  58. defaultSidebarOrder
  59. ? t('settings.sidebarDefaultSet')
  60. : t('settings.sidebarDefaultCleared'),
  61. 'success'
  62. );
  63. },
  64. onError: () => {
  65. showToast(t('settings.sidebarDefaultFailed'), 'error');
  66. },
  67. });
  68. const layoutItems = useMemo<SidebarLayoutItem[]>(() => {
  69. const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
  70. const externalLinksMap = new Map((links || []).map(link => [`ext-${link.id}`, link]));
  71. const result: SidebarLayoutItem[] = [];
  72. const seen = new Set<string>();
  73. const addItem = (id: string) => {
  74. if (seen.has(id)) return;
  75. const navItem = navItemsMap.get(id);
  76. if (navItem) {
  77. result.push({ type: 'system', id, navItem });
  78. seen.add(id);
  79. return;
  80. }
  81. const link = externalLinksMap.get(id);
  82. if (link) {
  83. result.push({ type: 'external', id, link });
  84. seen.add(id);
  85. }
  86. };
  87. sidebarOrder.forEach(addItem);
  88. defaultNavItems.forEach(item => addItem(item.id));
  89. (links || []).forEach(link => addItem(`ext-${link.id}`));
  90. return result;
  91. }, [links, sidebarOrder]);
  92. const persistSidebarOrder = (order: string[]) => {
  93. setSidebarOrder(order);
  94. saveSidebarOrder(order);
  95. };
  96. const handleDragStart = (e: React.DragEvent, id: string) => {
  97. setDraggedId(id);
  98. e.dataTransfer.effectAllowed = 'move';
  99. e.dataTransfer.setData('text/plain', id);
  100. };
  101. const handleDragOver = (e: React.DragEvent, id: string) => {
  102. e.preventDefault();
  103. e.dataTransfer.dropEffect = 'move';
  104. setDragOverId(id);
  105. };
  106. const handleDrop = (e: React.DragEvent, targetId: string) => {
  107. e.preventDefault();
  108. if (draggedId === null || draggedId === targetId) {
  109. setDraggedId(null);
  110. setDragOverId(null);
  111. return;
  112. }
  113. const currentOrder = layoutItems.map(item => item.id);
  114. const draggedIndex = currentOrder.indexOf(draggedId);
  115. const targetIndex = currentOrder.indexOf(targetId);
  116. if (draggedIndex === -1 || targetIndex === -1) {
  117. setDraggedId(null);
  118. setDragOverId(null);
  119. return;
  120. }
  121. currentOrder.splice(draggedIndex, 1);
  122. currentOrder.splice(targetIndex, 0, draggedId);
  123. persistSidebarOrder(currentOrder);
  124. setDraggedId(null);
  125. setDragOverId(null);
  126. };
  127. const handleDragEnd = () => {
  128. setDraggedId(null);
  129. setDragOverId(null);
  130. };
  131. const handleDelete = (link: ExternalLink) => {
  132. setDeletingLink(link);
  133. };
  134. const confirmDelete = () => {
  135. if (deletingLink) {
  136. deleteMutation.mutate(deletingLink.id);
  137. setDeletingLink(null);
  138. }
  139. };
  140. const resetSidebarLayout = () => {
  141. const resetOrder = [
  142. ...defaultNavItems.map(item => item.id),
  143. ...(links || []).map(link => `ext-${link.id}`),
  144. ];
  145. setHiddenSystemItemIds([]);
  146. setSidebarOrder(resetOrder);
  147. saveHiddenSidebarSystemItemIds([]);
  148. saveSidebarOrder(resetOrder);
  149. };
  150. const handleToggleDefaultSidebarOrder = (enabled: boolean) => {
  151. const currentOrder = layoutItems.map(item => item.id);
  152. updateDefaultSidebarMutation.mutate(enabled ? JSON.stringify({
  153. order: currentOrder,
  154. hiddenSystemItemIds,
  155. }) : '');
  156. };
  157. const toggleSystemItemVisibility = (id: string) => {
  158. if (id === 'settings') return;
  159. const isHidden = hiddenSystemItemIds.includes(id);
  160. const nextIds = isHidden
  161. ? hiddenSystemItemIds.filter(hiddenId => hiddenId !== id)
  162. : [...hiddenSystemItemIds, id];
  163. setHiddenSystemItemIds(nextIds);
  164. saveHiddenSidebarSystemItemIds(nextIds);
  165. };
  166. const canSetDefaultSidebarOrder = authEnabled && hasPermission('settings:update');
  167. const isDefaultSidebarEnabled = !!settings?.default_sidebar_order;
  168. return (
  169. <>
  170. <Card id="card-sidebar-links">
  171. <CardHeader>
  172. <div className="flex flex-wrap items-center justify-between gap-y-2 gap-x-3">
  173. <div className="flex items-center gap-2">
  174. <Link2 className="w-5 h-5 text-bambu-green" />
  175. <h2 className="text-lg font-semibold text-white">{t('externalLinks.sidebarLayout')}</h2>
  176. </div>
  177. <div className="flex flex-wrap items-center gap-2">
  178. {canSetDefaultSidebarOrder && (
  179. <label className="flex items-center gap-2 text-sm text-bambu-gray">
  180. <span>{t('settings.setDefault')}</span>
  181. <Toggle
  182. checked={isDefaultSidebarEnabled}
  183. onChange={handleToggleDefaultSidebarOrder}
  184. disabled={updateDefaultSidebarMutation.isPending}
  185. />
  186. </label>
  187. )}
  188. <Button variant="secondary" size="sm" onClick={resetSidebarLayout} className="whitespace-nowrap">
  189. <RotateCcw className="w-4 h-4" />
  190. {t('settings.reset')}
  191. </Button>
  192. <Button size="sm" onClick={() => setShowAddModal(true)} className="whitespace-nowrap">
  193. <Plus className="w-4 h-4" />
  194. Add Link
  195. </Button>
  196. </div>
  197. </div>
  198. </CardHeader>
  199. <CardContent>
  200. <p className="text-sm text-bambu-gray mb-4">
  201. {t('externalLinks.sidebarLayoutDescription')}
  202. </p>
  203. {isLoading ? (
  204. <div className="flex justify-center py-8">
  205. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  206. </div>
  207. ) : (
  208. <div className="space-y-2">
  209. {layoutItems.map((item) => {
  210. const isHidden = item.type === 'system' && hiddenSystemItemIds.includes(item.id);
  211. const isSettings = item.id === 'settings';
  212. if (item.type === 'system') {
  213. const Icon = item.navItem.icon;
  214. return (
  215. <div
  216. key={item.id}
  217. draggable
  218. onDragStart={(e) => handleDragStart(e, item.id)}
  219. onDragOver={(e) => handleDragOver(e, item.id)}
  220. onDragLeave={() => setDragOverId(null)}
  221. onDrop={(e) => handleDrop(e, item.id)}
  222. onDragEnd={handleDragEnd}
  223. className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
  224. draggedId === item.id ? 'opacity-50' : isHidden ? 'opacity-60' : ''
  225. } ${
  226. dragOverId === item.id && draggedId !== item.id
  227. ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
  228. : ''
  229. }`}
  230. >
  231. <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
  232. <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
  233. <Icon className="w-4 h-4" />
  234. </div>
  235. <div className="flex-1 min-w-0">
  236. <span className="text-white font-medium truncate block">{t(item.navItem.labelKey)}</span>
  237. <span className="text-sm text-bambu-gray truncate block">
  238. {isSettings
  239. ? t('externalLinks.requiredInSidebar')
  240. : isHidden
  241. ? t('externalLinks.hiddenFromSidebar')
  242. : t('externalLinks.visibleInSidebar')}
  243. </span>
  244. </div>
  245. <button
  246. onClick={() => toggleSystemItemVisibility(item.id)}
  247. disabled={isSettings}
  248. 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"
  249. title={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
  250. aria-label={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
  251. >
  252. {isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
  253. </button>
  254. </div>
  255. );
  256. }
  257. const link = item.link;
  258. const Icon = getIconByName(link.icon);
  259. return (
  260. <div
  261. key={item.id}
  262. draggable
  263. onDragStart={(e) => handleDragStart(e, item.id)}
  264. onDragOver={(e) => handleDragOver(e, item.id)}
  265. onDragLeave={() => setDragOverId(null)}
  266. onDrop={(e) => handleDrop(e, item.id)}
  267. onDragEnd={handleDragEnd}
  268. className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
  269. draggedId === item.id ? 'opacity-50' : ''
  270. } ${
  271. dragOverId === item.id && draggedId !== item.id
  272. ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
  273. : ''
  274. }`}
  275. >
  276. <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
  277. <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
  278. {link.custom_icon ? (
  279. <img
  280. src={api.getExternalLinkIconUrl(link.id)}
  281. alt=""
  282. className="w-4 h-4"
  283. />
  284. ) : (
  285. <Icon className="w-4 h-4" />
  286. )}
  287. </div>
  288. <div className="flex-1 min-w-0">
  289. <div className="flex items-center gap-2">
  290. <span className="text-white font-medium truncate">{link.name}</span>
  291. <ExternalLinkIcon className="w-3 h-3 text-bambu-gray flex-shrink-0" />
  292. </div>
  293. <span className="text-sm text-bambu-gray truncate block">{link.url}</span>
  294. </div>
  295. <div className="flex items-center gap-1 flex-shrink-0">
  296. <button
  297. onClick={() => setEditingLink(link)}
  298. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
  299. title={t('common.edit')}
  300. >
  301. <Pencil className="w-4 h-4" />
  302. </button>
  303. <button
  304. onClick={() => handleDelete(link)}
  305. disabled={deleteMutation.isPending}
  306. 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"
  307. title={t('externalLinks.deleteLink')}
  308. >
  309. <Trash2 className="w-4 h-4" />
  310. </button>
  311. </div>
  312. </div>
  313. );
  314. })}
  315. </div>
  316. )}
  317. </CardContent>
  318. </Card>
  319. {(showAddModal || editingLink) && (
  320. <AddExternalLinkModal
  321. link={editingLink}
  322. onClose={() => {
  323. setShowAddModal(false);
  324. setEditingLink(null);
  325. }}
  326. />
  327. )}
  328. {deletingLink && (
  329. <ConfirmModal
  330. title="Delete Link"
  331. message={`Are you sure you want to delete "${deletingLink.name}"? This action cannot be undone.`}
  332. confirmText="Delete"
  333. cancelText="Cancel"
  334. variant="danger"
  335. onConfirm={confirmDelete}
  336. onCancel={() => setDeletingLink(null)}
  337. />
  338. )}
  339. </>
  340. );
  341. }