AddExternalLinkModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
  7. import { Button } from './Button';
  8. import { IconPicker, getIconByName } from './IconPicker';
  9. interface AddExternalLinkModalProps {
  10. link?: ExternalLink | null;
  11. onClose: () => void;
  12. }
  13. export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
  14. const { t } = useTranslation();
  15. const queryClient = useQueryClient();
  16. const isEditing = !!link;
  17. const fileInputRef = useRef<HTMLInputElement>(null);
  18. const [name, setName] = useState(link?.name || '');
  19. const [url, setUrl] = useState(link?.url || '');
  20. const [icon, setIcon] = useState(link?.icon || 'link');
  21. const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);
  22. const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
  23. const [customIconPreview, setCustomIconPreview] = useState<string | null>(
  24. link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
  25. );
  26. const [pendingIconFile, setPendingIconFile] = useState<File | null>(null);
  27. const [error, setError] = useState<string | null>(null);
  28. // Close on Escape key
  29. useEffect(() => {
  30. const handleKeyDown = (e: KeyboardEvent) => {
  31. if (e.key === 'Escape') onClose();
  32. };
  33. window.addEventListener('keydown', handleKeyDown);
  34. return () => window.removeEventListener('keydown', handleKeyDown);
  35. }, [onClose]);
  36. // Create mutation
  37. const createMutation = useMutation({
  38. mutationFn: async (data: ExternalLinkCreate) => {
  39. const created = await api.createExternalLink(data);
  40. // If there's a pending icon file, upload it
  41. if (pendingIconFile) {
  42. return await api.uploadExternalLinkIcon(created.id, pendingIconFile);
  43. }
  44. return created;
  45. },
  46. onSuccess: () => {
  47. queryClient.invalidateQueries({ queryKey: ['external-links'] });
  48. onClose();
  49. },
  50. onError: (err: Error) => {
  51. setError(err.message);
  52. },
  53. });
  54. // Update mutation
  55. const updateMutation = useMutation({
  56. mutationFn: async (data: ExternalLinkUpdate) => {
  57. let updated = await api.updateExternalLink(link!.id, data);
  58. // Handle icon changes
  59. if (pendingIconFile) {
  60. // Upload new icon
  61. updated = await api.uploadExternalLinkIcon(link!.id, pendingIconFile);
  62. } else if (!useCustomIcon && link?.custom_icon) {
  63. // Remove custom icon if switching to preset
  64. updated = await api.deleteExternalLinkIcon(link!.id);
  65. }
  66. return updated;
  67. },
  68. onSuccess: () => {
  69. queryClient.invalidateQueries({ queryKey: ['external-links'] });
  70. onClose();
  71. },
  72. onError: (err: Error) => {
  73. setError(err.message);
  74. },
  75. });
  76. const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  77. const file = e.target.files?.[0];
  78. if (file) {
  79. // Validate file type
  80. const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/x-icon'];
  81. if (!validTypes.includes(file.type)) {
  82. setError('Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)');
  83. return;
  84. }
  85. // Validate file size (max 1MB)
  86. if (file.size > 1024 * 1024) {
  87. setError('Image file must be less than 1MB');
  88. return;
  89. }
  90. setPendingIconFile(file);
  91. setUseCustomIcon(true);
  92. // Create preview
  93. const reader = new FileReader();
  94. reader.onload = (e) => {
  95. setCustomIconPreview(e.target?.result as string);
  96. };
  97. reader.readAsDataURL(file);
  98. }
  99. };
  100. const handleRemoveCustomIcon = () => {
  101. setPendingIconFile(null);
  102. setCustomIconPreview(null);
  103. setUseCustomIcon(false);
  104. if (fileInputRef.current) {
  105. fileInputRef.current.value = '';
  106. }
  107. };
  108. const handleSubmit = (e: React.FormEvent) => {
  109. e.preventDefault();
  110. setError(null);
  111. if (!name.trim()) {
  112. setError('Name is required');
  113. return;
  114. }
  115. if (!url.trim()) {
  116. setError('URL is required');
  117. return;
  118. }
  119. // Validate URL
  120. if (!url.startsWith('http://') && !url.startsWith('https://')) {
  121. setError('URL must start with http:// or https://');
  122. return;
  123. }
  124. const data = {
  125. name: name.trim(),
  126. url: url.trim(),
  127. icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
  128. open_in_new_tab: openInNewTab,
  129. };
  130. if (isEditing) {
  131. updateMutation.mutate(data);
  132. } else {
  133. createMutation.mutate(data);
  134. }
  135. };
  136. const isPending = createMutation.isPending || updateMutation.isPending;
  137. const PresetIcon = getIconByName(icon);
  138. return (
  139. <div
  140. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  141. onClick={onClose}
  142. >
  143. <div
  144. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
  145. onClick={(e) => e.stopPropagation()}
  146. >
  147. {/* Header */}
  148. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  149. <div className="flex items-center gap-3">
  150. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  151. {useCustomIcon && customIconPreview ? (
  152. <img src={customIconPreview} alt="" className="w-5 h-5 rounded" />
  153. ) : (
  154. <PresetIcon className="w-5 h-5" />
  155. )}
  156. </div>
  157. <h2 className="text-lg font-semibold text-white">
  158. {isEditing ? 'Edit Link' : 'Add External Link'}
  159. </h2>
  160. </div>
  161. <button
  162. onClick={onClose}
  163. className="text-bambu-gray hover:text-white transition-colors"
  164. >
  165. <X className="w-5 h-5" />
  166. </button>
  167. </div>
  168. {/* Form */}
  169. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  170. {error && (
  171. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  172. {error}
  173. </div>
  174. )}
  175. {/* Name */}
  176. <div>
  177. <label className="block text-sm text-bambu-gray mb-1">Name *</label>
  178. <input
  179. type="text"
  180. value={name}
  181. onChange={(e) => setName(e.target.value)}
  182. placeholder="My Link"
  183. maxLength={50}
  184. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  185. />
  186. </div>
  187. {/* URL */}
  188. <div>
  189. <label className="block text-sm text-bambu-gray mb-1">URL *</label>
  190. <input
  191. type="text"
  192. value={url}
  193. onChange={(e) => setUrl(e.target.value)}
  194. placeholder="https://example.com"
  195. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  196. />
  197. </div>
  198. {/* Open in New Tab */}
  199. <div className="flex items-center justify-between">
  200. <label className="text-sm text-bambu-gray">{t('externalLinks.openInNewTab')}</label>
  201. <button
  202. type="button"
  203. onClick={() => setOpenInNewTab(!openInNewTab)}
  204. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
  205. openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  206. }`}
  207. >
  208. <span
  209. className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
  210. openInNewTab ? 'translate-x-6' : 'translate-x-1'
  211. }`}
  212. />
  213. </button>
  214. </div>
  215. {/* Icon Section */}
  216. <div className="space-y-3">
  217. <label className="block text-sm text-bambu-gray">Icon</label>
  218. {/* Custom Icon Upload */}
  219. <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
  220. <div className="flex items-center justify-between mb-2">
  221. <span className="text-sm text-white">Custom Icon</span>
  222. <input
  223. ref={fileInputRef}
  224. type="file"
  225. accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon"
  226. className="hidden"
  227. onChange={handleFileSelect}
  228. />
  229. {useCustomIcon && customIconPreview ? (
  230. <div className="flex items-center gap-2">
  231. <img src={customIconPreview} alt="Custom icon" className="w-8 h-8 rounded border border-bambu-dark-tertiary" />
  232. <button
  233. type="button"
  234. onClick={handleRemoveCustomIcon}
  235. className="p-1 text-red-400 hover:text-red-300 transition-colors"
  236. title="Remove custom icon"
  237. >
  238. <Trash2 className="w-4 h-4" />
  239. </button>
  240. </div>
  241. ) : (
  242. <Button
  243. type="button"
  244. variant="secondary"
  245. size="sm"
  246. onClick={() => fileInputRef.current?.click()}
  247. >
  248. <Upload className="w-4 h-4" />
  249. Upload
  250. </Button>
  251. )}
  252. </div>
  253. <p className="text-xs text-bambu-gray">
  254. PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.
  255. </p>
  256. </div>
  257. {/* Preset Icon Picker */}
  258. {!useCustomIcon && (
  259. <div>
  260. <span className="text-sm text-bambu-gray block mb-2">Or choose a preset icon</span>
  261. <IconPicker value={icon} onChange={setIcon} />
  262. </div>
  263. )}
  264. </div>
  265. {/* Actions */}
  266. <div className="flex gap-3 pt-2">
  267. <Button
  268. type="button"
  269. variant="secondary"
  270. onClick={onClose}
  271. className="flex-1"
  272. >
  273. Cancel
  274. </Button>
  275. <Button
  276. type="submit"
  277. disabled={isPending}
  278. className="flex-1"
  279. >
  280. {isPending ? (
  281. <Loader2 className="w-4 h-4 animate-spin" />
  282. ) : (
  283. <Save className="w-4 h-4" />
  284. )}
  285. {isEditing ? 'Save' : 'Add'}
  286. </Button>
  287. </div>
  288. </form>
  289. </div>
  290. </div>
  291. );
  292. }