generated from Dicken/dickendock
round20: Startseite ueberarbeitet, Favicon-Auswahl bereinigt und Icon-Datenbank integriert, Ping-Schalter repariert
This commit is contained in:
141
packages/ui/src/FaviconPicker.tsx
Normal file
141
packages/ui/src/FaviconPicker.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Favicon } from "./Favicon.js";
|
||||
|
||||
export interface FaviconPickerProps {
|
||||
/** Bereits im System verwendete Favicons: [url, Anzeigename-für-Tooltip][], dedupliziert. */
|
||||
existingFavicons: [string, string][];
|
||||
onSelect: (url: string) => void;
|
||||
}
|
||||
|
||||
interface IconDbResult {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Button, der einen Picker öffnet, der ZWEI Favicon-Quellen kombiniert:
|
||||
* bereits im System verwendete Favicons (z. B. von anderen Diensten) und
|
||||
* eine Live-Suche in der externen Icon-Datenbank (homarr-labs/dashboard-
|
||||
* icons, kuratierte Icons für Selfhosted-Software). Bewusst EIN Button statt
|
||||
* zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem
|
||||
* weiteren Button überladen werden.
|
||||
*/
|
||||
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [dbResults, setDbResults] = useState<IconDbResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
setDbResults([]);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const handle = setTimeout(() => {
|
||||
fetch(`/api/icons/search?q=${encodeURIComponent(q)}`)
|
||||
.then((res) => (res.ok ? res.json() : { results: [] }))
|
||||
.then((body) => setDbResults(body.results ?? []))
|
||||
.catch(() => setDbResults([]))
|
||||
.finally(() => setSearching(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
const showingSearch = query.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-lg border border-black/10 px-2 py-1 text-xs text-black/70
|
||||
hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10"
|
||||
>
|
||||
Favicon wählen
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className="absolute left-0 top-full z-20 mt-1 w-72 rounded-xl border border-black/10
|
||||
bg-white p-2 shadow-lg dark:border-white/10 dark:bg-neutral-900"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Icon-Datenbank durchsuchen … z. B. „plex“"
|
||||
className="mb-2 w-full rounded-lg border border-black/10 bg-white px-2 py-1 text-xs
|
||||
text-black outline-none placeholder:text-black/30 focus:border-black/30
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30"
|
||||
/>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{showingSearch ? (
|
||||
searching ? (
|
||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche …</p>
|
||||
) : dbResults.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{dbResults.map((r) => (
|
||||
<button
|
||||
key={r.name}
|
||||
type="button"
|
||||
title={r.name}
|
||||
onClick={() => {
|
||||
onSelect(r.url);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<Favicon src={r.url} fallbackLetter={r.name} size="sm" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">Keine Treffer.</p>
|
||||
)
|
||||
) : existingFavicons.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||
Bereits im System verwendet
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{existingFavicons.map(([iconUrl, name]) => (
|
||||
<button
|
||||
key={iconUrl}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => {
|
||||
onSelect(iconUrl);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<Favicon src={iconUrl} fallbackLetter={name} size="sm" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="p-2 text-xs text-black/40 dark:text-white/40">
|
||||
Noch keine anderen Favicons vorhanden. Tippe oben, um die Icon-Datenbank zu durchsuchen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,15 @@ export interface FavoritesBarProps {
|
||||
/** Kategoriename -> Hex-Farbe, zeigt sich als Ring um das Icon. */
|
||||
categoryColors?: Record<string, string>;
|
||||
onOpen: (item: FavoriteItem) => void;
|
||||
/** Wenn gesetzt, sind die Chips per Drag & Drop sortierbar. */
|
||||
/** Wenn gesetzt, sind die Kacheln per Drag & Drop sortierbar. */
|
||||
onReorder?: (orderedIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Favoriten als kompakte, nur-Icon-Chips – Name erscheint als Tooltip
|
||||
* beim Hover, nicht als sichtbarer Text (bewusst platzsparend). Anklickbar,
|
||||
* Zeigt Einträge als Icon-Kacheln mit sichtbarer Beschriftung darunter
|
||||
* (Launchpad-/Homescreen-Stil) statt als reine Icon-Chips mit Tooltip -
|
||||
* lesbarer und wirkt strukturierter, wenn mehrere Gruppen (Favoriten,
|
||||
* Zuletzt besucht, Später lesen) auf derselben Seite stehen. Anklickbar,
|
||||
* per Drag & Drop sortierbar. Dienste und Lesezeichen werden über getrennte
|
||||
* FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein generisch
|
||||
* über FavoriteItem statt fest an Service gebunden.
|
||||
@@ -72,15 +74,11 @@ export function FavoritesBar({
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<div className="mb-2 text-center text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||
<div className="mb-2.5 text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label ?? "Favoriten"}
|
||||
className="flex flex-wrap items-center justify-center gap-2"
|
||||
>
|
||||
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2.5">
|
||||
{list.map((item) => {
|
||||
const ringColor = item.category ? categoryColors?.[item.category] : undefined;
|
||||
return (
|
||||
@@ -97,14 +95,22 @@ export function FavoritesBar({
|
||||
? `${item.displayName} (${item.hostname}:${item.port})`
|
||||
: `${item.displayName} (${item.hostname})`
|
||||
}
|
||||
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-xl border
|
||||
border-black/10 bg-white/70 shadow-sm transition-colors hover:bg-black/5
|
||||
dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10 ${
|
||||
className={`group flex w-16 flex-col items-center gap-1.5 rounded-xl p-1.5
|
||||
transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${
|
||||
onReorder ? "cursor-grab active:cursor-grabbing" : ""
|
||||
} ${draggedId === item.id ? "opacity-40" : ""}`}
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
<span
|
||||
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border
|
||||
border-black/10 bg-white/80 shadow-sm transition-transform
|
||||
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
</span>
|
||||
<span className="w-full truncate text-center text-[11px] leading-tight text-black/55 dark:text-white/55">
|
||||
{item.displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -15,3 +15,6 @@ export type { FavoritesBarProps } from "./FavoritesBar.js";
|
||||
|
||||
export { Favicon } from "./Favicon.js";
|
||||
export type { FaviconProps } from "./Favicon.js";
|
||||
|
||||
export { FaviconPicker } from "./FaviconPicker.js";
|
||||
export type { FaviconPickerProps } from "./FaviconPicker.js";
|
||||
|
||||
Reference in New Issue
Block a user