AddExternalLinkModal.tsx 9.8 KB

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