mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 09:06:40 +02:00
- 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>
601 lines
16 KiB
TypeScript
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 “Link hinzufuegen” 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>
|
|
);
|
|
}
|