round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege

This commit is contained in:
2026-07-24 00:26:08 +02:00
parent b098007742
commit d87885f16f
7 changed files with 252 additions and 108 deletions

View File

@@ -22,6 +22,7 @@ interface ServicePatch {
port?: number;
https?: boolean;
url?: string;
favicon?: string | null;
}
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
@@ -503,42 +504,103 @@ function SortableHeader({
);
}
function BackfillFaviconsButton() {
const queryClient = useQueryClient();
const [message, setMessage] = useState<string | null>(null);
interface FaviconSuggestion {
serviceId: string;
serviceName: string;
candidates: { name: string; url: string }[];
}
const mutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/services/backfill-favicons", { method: "POST" });
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
return res.json() as Promise<{ checked: number; updated: number }>;
},
onSuccess: (result) => {
setMessage(
result.updated > 0
? `${result.updated} von ${result.checked} Diensten mit einem passenden Favicon aus der Icon-Datenbank ergänzt.`
: `Keine passenden Treffer gefunden (${result.checked} Dienste ohne Favicon geprüft).`
);
function FaviconSuggestionsPanel() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState<FaviconSuggestion[] | null>(null);
const [checkedCount, setCheckedCount] = useState(0);
const [loading, setLoading] = useState(false);
function load() {
setLoading(true);
setOpen(true);
fetch("/api/services/favicon-suggestions")
.then((res) => res.json())
.then((body) => {
setSuggestions(body.suggestions ?? []);
setCheckedCount(body.checked ?? 0);
})
.finally(() => setLoading(false));
}
const applyMutation = useMutation({
mutationFn: ({ serviceId, url }: { serviceId: string; url: string }) => patchService(serviceId, { favicon: url }),
onSuccess: (_data, { serviceId }) => {
setSuggestions((prev) => (prev ? prev.filter((s) => s.serviceId !== serviceId) : prev));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
onError: () => setMessage("Fehlgeschlagen - ist die Icon-Datenbank erreichbar (Internetzugang)?"),
});
if (!open) {
return (
<div className="pb-2">
<Button
size="sm"
variant="ghost"
onClick={load}
title="Für Dienste ohne Favicon passende Treffer aus der Icon-Datenbank vorschlagen"
>
Fehlende Favicons ergänzen
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2 pb-2">
{message ? <span className="text-xs text-black/40 dark:text-white/40">{message}</span> : null}
<Button
size="sm"
variant="ghost"
onClick={() => {
setMessage(null);
mutation.mutate();
}}
disabled={mutation.isPending}
title="Für alle Dienste ohne Favicon einen passenden Treffer aus der Icon-Datenbank suchen"
>
{mutation.isPending ? "Suche …" : "Fehlende Favicons ergänzen"}
</Button>
<div className="mb-3 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-black/60 dark:text-white/60">
{loading
? "Suche …"
: suggestions && suggestions.length > 0
? `Vorschläge für ${suggestions.length} von ${checkedCount} Diensten ohne Favicon:`
: `Keine Vorschläge gefunden (${checkedCount} Dienste ohne Favicon geprüft).`}
</span>
<button
onClick={() => setOpen(false)}
className="text-xs text-black/40 underline dark:text-white/40"
>
Schließen
</button>
</div>
{suggestions && suggestions.length > 0 ? (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{suggestions.map((s) => (
<li key={s.serviceId} className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-xs text-black/70 dark:text-white/70">
{s.serviceName}
</span>
<div className="flex flex-wrap gap-1.5">
{s.candidates.map((c) => (
<button
key={c.url}
type="button"
title={c.name}
onClick={() => applyMutation.mutate({ serviceId: s.serviceId, url: c.url })}
disabled={applyMutation.isPending}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={c.url} fallbackLetter={c.name} size="sm" />
</button>
))}
</div>
<button
onClick={() => setSuggestions((prev) => (prev ? prev.filter((x) => x.serviceId !== s.serviceId) : prev))}
className="ml-auto shrink-0 text-xs text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
title="Überspringen"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
@@ -696,9 +758,10 @@ export function ServicesPage() {
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button>
</div>
<BackfillFaviconsButton />
</div>
<FaviconSuggestionsPanel />
{sortColumn ? (
<div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>