mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 21:16:40 +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.
|
||||
* 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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 “Link hinzufuegen” 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,56 +76,59 @@ export function AppLayout() {
|
|||
{externalLinks && externalLinks.length > 0 && (
|
||||
<>
|
||||
<div className={styles.navSection}>Anwendungen</div>
|
||||
{externalLinks.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.navLink}
|
||||
>
|
||||
{link.icon ? (
|
||||
<img
|
||||
src={link.icon}
|
||||
alt=""
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
{externalLinks.map((link) => {
|
||||
const favicon = getFaviconUrl(link.url);
|
||||
return (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.navLink}
|
||||
>
|
||||
{favicon ? (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
style={{
|
||||
width: 16,
|
||||
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
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ marginLeft: 'auto', opacity: 0.4 }}
|
||||
>
|
||||
<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" />
|
||||
<path d="M6 1h3v3" />
|
||||
<path d="M4 6l5-5" />
|
||||
</svg>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue