INSIGHT-MVP/packages/frontend/src/admin/AdminExternalLinksPage.tsx
Thomas Reitz 3bedda2b9d feat: dark mode, collapsible sidebar, branding customization
- Add Light/Dark/System theme toggle with ThemeContext and CSS variables
- Sidebar fully collapsible (icons-only mode, persisted in localStorage)
- Anwendungen section collapsible with chevron toggle
- Admin "Anpassungen" page: logo upload, sidebar color picker with presets
- Backend branding endpoints (GET/POST /settings/branding) stored in Redis
- Optional custom icon upload for external links (click icon field)
- Backend favicon proxy with HTML parsing for reliable icon loading
- Dark mode CSS variables for all components
- Login page SSO button and error styles use CSS variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:47:51 +01:00

601 lines
16 KiB
TypeScript

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<string | null>(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<HTMLInputElement>(null);
const handleIconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div
style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 1.5fr 80px auto',
gap: '0.75rem',
alignItems: 'end',
padding: '1rem 0',
borderBottom: '1px solid var(--color-border)',
}}
>
{/* Icon-Vorschau (klickbar fuer Upload) */}
<div>
<label style={labelStyle}>Icon</label>
<input
ref={iconInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleIconUpload}
/>
<div
style={{
width: 36,
height: 36,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: 'var(--color-bg)',
cursor: 'pointer',
position: 'relative',
}}
onClick={() => iconInputRef.current?.click()}
title="Klicken zum Icon hochladen"
>
{iconSrc ? (
<img
src={iconSrc}
alt=""
style={{ width: 20, height: 20, objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="var(--color-text-muted)"
strokeWidth="1.5"
>
<circle cx="8" cy="8" r="6" />
<path d="M2 8h12M8 2a10 10 0 014 6 10 10 0 01-4 6 10 10 0 01-4-6 10 10 0 014-6z" />
</svg>
)}
{link.customIcon && (
<button
onClick={(e) => {
e.stopPropagation();
onChange({ ...link, customIcon: undefined });
}}
style={{
position: 'absolute',
top: -4,
right: -4,
width: 14,
height: 14,
borderRadius: '50%',
background: 'var(--color-error)',
color: 'white',
border: 'none',
fontSize: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
lineHeight: 1,
}}
title="Eigenes Icon entfernen"
>
x
</button>
)}
</div>
</div>
{/* Label */}
<div>
<label style={labelStyle}>Bezeichnung *</label>
<input
type="text"
value={link.label}
onChange={(e) => onChange({ ...link, label: e.target.value })}
placeholder="z.B. Jira, Confluence"
style={inputStyle}
/>
</div>
{/* URL */}
<div>
<label style={labelStyle}>URL *</label>
<input
type="text"
value={link.url}
onChange={(e) => onChange({ ...link, url: e.target.value })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Reihenfolge */}
<div
style={{
display: 'flex',
gap: '0.25rem',
paddingBottom: '0.25rem',
}}
>
<button
onClick={onMoveUp}
disabled={isFirst}
style={{
...btnSecondary,
padding: '0.25rem 0.5rem',
opacity: isFirst ? 0.3 : 1,
}}
title="Nach oben"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 11V3M3 7l4-4 4 4" />
</svg>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
style={{
...btnSecondary,
padding: '0.25rem 0.5rem',
opacity: isLast ? 0.3 : 1,
}}
title="Nach unten"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 3v8M3 7l4 4 4-4" />
</svg>
</button>
</div>
{/* Entfernen */}
<button
onClick={onRemove}
style={{
...btnSecondary,
color: 'var(--color-error)',
borderColor: 'transparent',
padding: '0.25rem 0.5rem',
}}
title="Entfernen"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
);
}
export function AdminExternalLinksPage() {
const queryClient = useQueryClient();
const [links, setLinks] = useState<ExternalLink[]>([]);
const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data, isLoading } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'],
queryFn: async () => {
const res = await api.get<ExternalLink[]>('/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 <p style={{ color: 'var(--color-text-secondary)' }}>Laden...</p>;
}
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>
Externe Links
</h1>
<p
style={{
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
marginTop: '0.25rem',
}}
>
Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer
angezeigt werden. Das Icon wird automatisch von der Webseite geladen.
</p>
</div>
</div>
<div style={cardStyle}>
{links.length === 0 ? (
<div
style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--color-text-muted)',
}}
>
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
style={{ margin: '0 auto 1rem' }}
>
<rect x="8" y="8" width="32" height="32" rx="4" />
<path d="M20 24h8M24 20v8" />
</svg>
<p style={{ fontSize: '0.875rem' }}>
Noch keine externen Links konfiguriert.
</p>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-muted)',
marginTop: '0.25rem',
}}
>
Klicke auf &ldquo;Link hinzufuegen&rdquo; um einen externen Link
zur Sidebar hinzuzufuegen.
</p>
</div>
) : (
<div>
{links.map((link, index) => (
<LinkRow
key={link.id}
link={link}
onChange={(updated) => updateLink(index, updated)}
onRemove={() => removeLink(index)}
onMoveUp={() => moveLink(index, -1)}
onMoveDown={() => moveLink(index, 1)}
isFirst={index === 0}
isLast={index === links.length - 1}
/>
))}
</div>
)}
{/* Aktionen */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '1.25rem',
paddingTop: links.length > 0 ? '0.5rem' : 0,
}}
>
<button
onClick={addLink}
style={{
...btnSecondary,
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M7 2v10M2 7h10" />
</svg>
Link hinzufuegen
</button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}}
>
{saveSuccess && (
<span
style={{
fontSize: '0.8125rem',
color: '#166534',
fontWeight: 500,
}}
>
Gespeichert!
</span>
)}
{saveMutation.isError && (
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
}}
>
Fehler beim Speichern
</span>
)}
<button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
style={{
...btnPrimary,
opacity: hasChanges ? 1 : 0.5,
cursor: hasChanges ? 'pointer' : 'not-allowed',
}}
>
{saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Hinweis */}
<div
style={{
padding: '1rem 1.25rem',
background: 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))',
border: '1px solid color-mix(in srgb, var(--color-primary) 25%, transparent)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: 'var(--color-primary)',
}}
>
<strong>Hinweis:</strong> 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.{' '}
<code
style={{
background: 'color-mix(in srgb, var(--color-primary) 15%, transparent)',
padding: '0.125rem 0.25rem',
borderRadius: 2,
}}
>
https://
</code>{' '}
ein.
</div>
</div>
);
}