mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
feat: use website favicons instead of manual icon upload
External links now automatically show the favicon of the target website using Google's favicon service. No manual icon upload needed — just enter label and URL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f89e06c09d
commit
65c5c7b7dd
3 changed files with 107 additions and 99 deletions
|
|
@ -15,18 +15,16 @@ import { RedisService } from '../../redis/redis.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein externer Link fuer die Sidebar-Navigation.
|
* Ein externer Link fuer die Sidebar-Navigation.
|
||||||
|
* Icons werden im Frontend automatisch als Favicon der URL geladen.
|
||||||
*/
|
*/
|
||||||
interface ExternalLink {
|
interface ExternalLink {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
/** Base64-encodiertes Icon (data URI), z.B. "data:image/png;base64,..." */
|
|
||||||
icon?: string;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXTERNAL_LINKS_KEY = 'platform_external_links';
|
const EXTERNAL_LINKS_KEY = 'platform_external_links';
|
||||||
const MAX_ICON_SIZE = 100_000; // ~100KB Base64
|
|
||||||
|
|
||||||
@ApiTags('Settings')
|
@ApiTags('Settings')
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
|
|
@ -76,11 +74,6 @@ export class SettingsController {
|
||||||
if (!link.url?.trim()) {
|
if (!link.url?.trim()) {
|
||||||
throw new BadRequestException('Jeder Link braucht eine URL');
|
throw new BadRequestException('Jeder Link braucht eine URL');
|
||||||
}
|
}
|
||||||
if (link.icon && link.icon.length > MAX_ICON_SIZE) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Icon fuer "${link.label}" ist zu gross (max ~75KB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortierung sicherstellen
|
// Sortierung sicherstellen
|
||||||
|
|
@ -88,7 +81,6 @@ export class SettingsController {
|
||||||
id: link.id || randomUUID(),
|
id: link.id || randomUUID(),
|
||||||
label: link.label.trim(),
|
label: link.label.trim(),
|
||||||
url: link.url.trim(),
|
url: link.url.trim(),
|
||||||
icon: link.icon || undefined,
|
|
||||||
sortOrder: link.sortOrder ?? index,
|
sortOrder: link.sortOrder ?? index,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
|
|
||||||
|
|
@ -7,11 +7,20 @@ function generateId(): string {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Favicon-URL aus einer Website-URL ableiten (Google Favicon Service) */
|
||||||
|
function getFaviconUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ExternalLink {
|
interface ExternalLink {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon?: string;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,60 +93,43 @@ function LinkRow({
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const faviconUrl = getFaviconUrl(link.url);
|
||||||
|
|
||||||
const handleIconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Max 75KB
|
|
||||||
if (file.size > 75_000) {
|
|
||||||
alert('Icon darf max. 75KB gross sein');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
onChange({ ...link, icon: reader.result as string });
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '64px 1fr 1fr 80px auto',
|
gridTemplateColumns: '40px 1fr 1.5fr 80px auto',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
alignItems: 'end',
|
alignItems: 'end',
|
||||||
padding: '1rem 0',
|
padding: '1rem 0',
|
||||||
borderBottom: '1px solid var(--color-border)',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Favicon-Vorschau */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Icon</label>
|
<label style={labelStyle}>Icon</label>
|
||||||
<div
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px dashed var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'var(--color-bg)',
|
background: 'var(--color-bg)',
|
||||||
}}
|
}}
|
||||||
title="Icon hochladen (PNG, SVG, max 75KB)"
|
|
||||||
>
|
>
|
||||||
{link.icon ? (
|
{faviconUrl ? (
|
||||||
<img
|
<img
|
||||||
src={link.icon}
|
src={faviconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 24, height: 24, objectFit: 'contain' }}
|
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -148,17 +140,11 @@ function LinkRow({
|
||||||
stroke="var(--color-text-muted)"
|
stroke="var(--color-text-muted)"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
>
|
>
|
||||||
<path d="M8 3v10M3 8h10" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleIconUpload}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
|
|
@ -308,7 +294,6 @@ export function AdminExternalLinksPage() {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
label: '',
|
label: '',
|
||||||
url: '',
|
url: '',
|
||||||
icon: undefined,
|
|
||||||
sortOrder: prev.length,
|
sortOrder: prev.length,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -374,7 +359,7 @@ export function AdminExternalLinksPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer
|
Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer
|
||||||
angezeigt werden.
|
angezeigt werden. Das Icon wird automatisch von der Webseite geladen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -398,11 +383,21 @@ export function AdminExternalLinksPage() {
|
||||||
style={{ margin: '0 auto 1rem' }}
|
style={{ margin: '0 auto 1rem' }}
|
||||||
>
|
>
|
||||||
<rect x="8" y="8" width="32" height="32" rx="4" />
|
<rect x="8" y="8" width="32" height="32" rx="4" />
|
||||||
<path d="M20 20l8 8M28 20l-8 8" />
|
<path d="M20 24h8M24 20v8" />
|
||||||
</svg>
|
</svg>
|
||||||
<p style={{ fontSize: '0.875rem' }}>
|
<p style={{ fontSize: '0.875rem' }}>
|
||||||
Noch keine externen Links konfiguriert.
|
Noch keine externen Links konfiguriert.
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -509,9 +504,18 @@ export function AdminExternalLinksPage() {
|
||||||
color: '#1e40af',
|
color: '#1e40af',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Hinweis:</strong> Externe Links werden in der Sidebar fuer alle
|
<strong>Hinweis:</strong> Das Icon wird automatisch als Favicon der
|
||||||
angemeldeten Benutzer angezeigt. Icons sollten quadratisch sein (z.B.
|
jeweiligen Webseite geladen. Gib eine vollstaendige URL inkl.{' '}
|
||||||
32x32px) und als PNG, SVG oder JPEG hochgeladen werden (max. 75KB).
|
<code
|
||||||
|
style={{
|
||||||
|
background: '#dbeafe',
|
||||||
|
padding: '0.125rem 0.25rem',
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
https://
|
||||||
|
</code>{' '}
|
||||||
|
ein.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,19 @@ interface ExternalLink {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon?: string;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Favicon-URL aus einer Website-URL ableiten (Google Favicon Service) */
|
||||||
|
function getFaviconUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -67,56 +76,59 @@ export function AppLayout() {
|
||||||
{externalLinks && externalLinks.length > 0 && (
|
{externalLinks && externalLinks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.navSection}>Anwendungen</div>
|
<div className={styles.navSection}>Anwendungen</div>
|
||||||
{externalLinks.map((link) => (
|
{externalLinks.map((link) => {
|
||||||
<a
|
const favicon = getFaviconUrl(link.url);
|
||||||
key={link.id}
|
return (
|
||||||
href={link.url}
|
<a
|
||||||
target="_blank"
|
key={link.id}
|
||||||
rel="noopener noreferrer"
|
href={link.url}
|
||||||
className={styles.navLink}
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
{link.icon ? (
|
className={styles.navLink}
|
||||||
<img
|
>
|
||||||
src={link.icon}
|
{favicon ? (
|
||||||
alt=""
|
<img
|
||||||
style={{
|
src={favicon}
|
||||||
width: 16,
|
alt=""
|
||||||
height: 16,
|
style={{
|
||||||
objectFit: 'contain',
|
width: 16,
|
||||||
borderRadius: 2,
|
height: 16,
|
||||||
}}
|
objectFit: 'contain',
|
||||||
/>
|
borderRadius: 2,
|
||||||
) : (
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 2h4v4" />
|
||||||
|
<path d="M6 10L14 2" />
|
||||||
|
<path d="M14 9v4a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1h4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{link.label}
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="10"
|
||||||
height="16"
|
height="10"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 10 10"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
style={{ marginLeft: 'auto', opacity: 0.4 }}
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
>
|
||||||
<path d="M10 2h4v4" />
|
<path d="M6 1h3v3" />
|
||||||
<path d="M6 10L14 2" />
|
<path d="M4 6l5-5" />
|
||||||
<path d="M14 9v4a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1h4" />
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
</a>
|
||||||
{link.label}
|
);
|
||||||
<svg
|
})}
|
||||||
width="10"
|
|
||||||
height="10"
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
style={{ marginLeft: 'auto', opacity: 0.4 }}
|
|
||||||
>
|
|
||||||
<path d="M6 1h3v3" />
|
|
||||||
<path d="M4 6l5-5" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue