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:
Thomas Reitz 2026-03-10 11:19:34 +01:00
parent f89e06c09d
commit 65c5c7b7dd
3 changed files with 107 additions and 99 deletions

View file

@ -15,18 +15,16 @@ import { RedisService } from '../../redis/redis.service';
/**
* Ein externer Link fuer die Sidebar-Navigation.
* Icons werden im Frontend automatisch als Favicon der URL geladen.
*/
interface ExternalLink {
id: string;
label: string;
url: string;
/** Base64-encodiertes Icon (data URI), z.B. "data:image/png;base64,..." */
icon?: string;
sortOrder: number;
}
const EXTERNAL_LINKS_KEY = 'platform_external_links';
const MAX_ICON_SIZE = 100_000; // ~100KB Base64
@ApiTags('Settings')
@Controller('settings')
@ -76,11 +74,6 @@ export class SettingsController {
if (!link.url?.trim()) {
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
@ -88,7 +81,6 @@ export class SettingsController {
id: link.id || randomUUID(),
label: link.label.trim(),
url: link.url.trim(),
icon: link.icon || undefined,
sortOrder: link.sortOrder ?? index,
}));

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
@ -7,11 +7,20 @@ function generateId(): string {
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 {
id: string;
label: string;
url: string;
icon?: string;
sortOrder: number;
}
@ -84,60 +93,43 @@ function LinkRow({
isFirst: boolean;
isLast: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
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);
};
const faviconUrl = getFaviconUrl(link.url);
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '64px 1fr 1fr 80px auto',
gridTemplateColumns: '40px 1fr 1.5fr 80px auto',
gap: '0.75rem',
alignItems: 'end',
padding: '1rem 0',
borderBottom: '1px solid var(--color-border)',
}}
>
{/* Icon */}
{/* Favicon-Vorschau */}
<div>
<label style={labelStyle}>Icon</label>
<div
onClick={() => fileInputRef.current?.click()}
style={{
width: 40,
height: 40,
width: 36,
height: 36,
borderRadius: 'var(--radius-sm)',
border: '1px dashed var(--color-border)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
overflow: 'hidden',
background: 'var(--color-bg)',
}}
title="Icon hochladen (PNG, SVG, max 75KB)"
>
{link.icon ? (
{faviconUrl ? (
<img
src={link.icon}
src={faviconUrl}
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
@ -148,17 +140,11 @@ function LinkRow({
stroke="var(--color-text-muted)"
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>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/webp"
style={{ display: 'none' }}
onChange={handleIconUpload}
/>
</div>
{/* Label */}
@ -308,7 +294,6 @@ export function AdminExternalLinksPage() {
id: generateId(),
label: '',
url: '',
icon: undefined,
sortOrder: prev.length,
},
]);
@ -374,7 +359,7 @@ export function AdminExternalLinksPage() {
}}
>
Links zu externen Anwendungen, die in der Sidebar fuer alle Benutzer
angezeigt werden.
angezeigt werden. Das Icon wird automatisch von der Webseite geladen.
</p>
</div>
</div>
@ -398,11 +383,21 @@ export function AdminExternalLinksPage() {
style={{ margin: '0 auto 1rem' }}
>
<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>
<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>
@ -509,9 +504,18 @@ export function AdminExternalLinksPage() {
color: '#1e40af',
}}
>
<strong>Hinweis:</strong> Externe Links werden in der Sidebar fuer alle
angemeldeten Benutzer angezeigt. Icons sollten quadratisch sein (z.B.
32x32px) und als PNG, SVG oder JPEG hochgeladen werden (max. 75KB).
<strong>Hinweis:</strong> Das Icon wird automatisch als Favicon der
jeweiligen Webseite geladen. Gib eine vollstaendige URL inkl.{' '}
<code
style={{
background: '#dbeafe',
padding: '0.125rem 0.25rem',
borderRadius: 2,
}}
>
https://
</code>{' '}
ein.
</div>
</div>
);

View file

@ -9,10 +9,19 @@ interface ExternalLink {
id: string;
label: string;
url: string;
icon?: string;
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() {
const { user, logout } = useAuth();
const navigate = useNavigate();
@ -67,7 +76,9 @@ export function AppLayout() {
{externalLinks && externalLinks.length > 0 && (
<>
<div className={styles.navSection}>Anwendungen</div>
{externalLinks.map((link) => (
{externalLinks.map((link) => {
const favicon = getFaviconUrl(link.url);
return (
<a
key={link.id}
href={link.url}
@ -75,9 +86,9 @@ export function AppLayout() {
rel="noopener noreferrer"
className={styles.navLink}
>
{link.icon ? (
{favicon ? (
<img
src={link.icon}
src={favicon}
alt=""
style={{
width: 16,
@ -116,7 +127,8 @@ export function AppLayout() {
<path d="M4 6l5-5" />
</svg>
</a>
))}
);
})}
</>
)}
</nav>