import { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../api/client'; /** Hook: Favicon-URL ueber Backend-Proxy laden */ function useFavicon(url: string): string | null { const [faviconUrl, setFaviconUrl] = useState(null); const fetchFavicon = useCallback(async () => { if (!url || url.length < 8) { setFaviconUrl(null); return; } try { new URL(url); // Validierung const res = await api.get<{ faviconUrl: string | null }>( `/settings/favicon?url=${encodeURIComponent(url)}`, ); setFaviconUrl(res.data.faviconUrl); } catch { setFaviconUrl(null); } }, [url]); useEffect(() => { // Debounce: nur ausfuehren wenn URL sich 500ms nicht geaendert hat const timer = setTimeout(fetchFavicon, 500); return () => clearTimeout(timer); }, [fetchFavicon]); return faviconUrl; } /** Einfache ID-Generierung (crypto.randomUUID ist nur ueber HTTPS verfuegbar) */ function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 10); } interface ExternalLink { id: string; label: string; url: string; sortOrder: number; customIcon?: string; } const cardStyle: React.CSSProperties = { background: 'var(--color-bg-card)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)', border: '1px solid var(--color-border)', padding: '1.5rem', marginBottom: '1.5rem', }; const inputStyle: React.CSSProperties = { width: '100%', padding: '0.5rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg-card)', color: 'var(--color-text)', outline: 'none', boxSizing: 'border-box' as const, }; const labelStyle: React.CSSProperties = { display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.25rem', textTransform: 'uppercase' as const, letterSpacing: '0.5px', }; const btnPrimary: React.CSSProperties = { padding: '0.5rem 1.25rem', background: 'var(--color-primary)', color: 'white', border: 'none', borderRadius: 'var(--radius-sm)', fontSize: '0.875rem', fontWeight: 600, cursor: 'pointer', }; const btnSecondary: React.CSSProperties = { padding: '0.375rem 0.75rem', background: 'none', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', cursor: 'pointer', }; function LinkRow({ link, onChange, onRemove, onMoveUp, onMoveDown, isFirst, isLast, }: { link: ExternalLink; onChange: (updated: ExternalLink) => void; onRemove: () => void; onMoveUp: () => void; onMoveDown: () => void; isFirst: boolean; isLast: boolean; }) { const faviconUrl = useFavicon(link.url); const iconInputRef = useRef(null); const handleIconUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) return; if (file.size > 100_000) { alert('Icon darf maximal 100KB gross sein'); return; } const reader = new FileReader(); reader.onload = () => { onChange({ ...link, customIcon: reader.result as string }); }; reader.readAsDataURL(file); }; const iconSrc = link.customIcon || faviconUrl; return (
{/* Icon-Vorschau (klickbar fuer Upload) */}
iconInputRef.current?.click()} title="Klicken zum Icon hochladen" > {iconSrc ? ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( )} {link.customIcon && ( )}
{/* Label */}
onChange({ ...link, label: e.target.value })} placeholder="z.B. Jira, Confluence" style={inputStyle} />
{/* URL */}
onChange({ ...link, url: e.target.value })} placeholder="https://..." style={inputStyle} />
{/* Reihenfolge */}
{/* Entfernen */}
); } export function AdminExternalLinksPage() { const queryClient = useQueryClient(); const [links, setLinks] = useState([]); const [hasChanges, setHasChanges] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['settings', 'external-links'], queryFn: async () => { const res = await api.get('/settings/external-links'); return res.data; }, }); useEffect(() => { if (data) { setLinks(data); setHasChanges(false); } }, [data]); const saveMutation = useMutation({ mutationFn: async (linksToSave: ExternalLink[]) => { const res = await api.post('/settings/external-links', { links: linksToSave, }); return res.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings', 'external-links'], }); setHasChanges(false); setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 3000); }, }); const addLink = () => { setLinks((prev) => [ ...prev, { id: generateId(), label: '', url: '', sortOrder: prev.length, }, ]); setHasChanges(true); }; const updateLink = (index: number, updated: ExternalLink) => { setLinks((prev) => prev.map((l, i) => (i === index ? updated : l))); setHasChanges(true); }; const removeLink = (index: number) => { setLinks((prev) => prev.filter((_, i) => i !== index)); setHasChanges(true); }; const moveLink = (index: number, direction: -1 | 1) => { const newIndex = index + direction; if (newIndex < 0 || newIndex >= links.length) return; setLinks((prev) => { const updated = [...prev]; const temp = updated[index]; updated[index] = updated[newIndex]; updated[newIndex] = temp; return updated.map((l, i) => ({ ...l, sortOrder: i })); }); setHasChanges(true); }; const handleSave = () => { const valid = links.every((l) => l.label.trim() && l.url.trim()); if (!valid) { alert('Bitte Bezeichnung und URL fuer alle Links ausfuellen'); return; } saveMutation.mutate(links); }; if (isLoading) { return

Laden...

; } return (

Externe Links

Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer angezeigt werden. Das Icon wird automatisch von der Webseite geladen.

{links.length === 0 ? (

Noch keine externen Links konfiguriert.

Klicke auf “Link hinzufuegen” um einen externen Link zur Sidebar hinzuzufuegen.

) : (
{links.map((link, index) => ( updateLink(index, updated)} onRemove={() => removeLink(index)} onMoveUp={() => moveLink(index, -1)} onMoveDown={() => moveLink(index, 1)} isFirst={index === 0} isLast={index === links.length - 1} /> ))}
)} {/* Aktionen */}
0 ? '0.5rem' : 0, }} >
{saveSuccess && ( Gespeichert! )} {saveMutation.isError && ( Fehler beim Speichern )}
{/* Hinweis */}
Hinweis: Das Icon wird automatisch als Favicon der jeweiligen Webseite geladen. Optional kann ein eigenes Icon hochgeladen werden (Klick auf das Icon-Feld). Gib eine vollstaendige URL inkl.{' '} https:// {' '} ein.
); }