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. * 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,
})); }));

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 { 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 &ldquo;Link hinzufuegen&rdquo; 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>
); );

View file

@ -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>