Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung

This commit is contained in:
2026-07-20 07:52:38 +02:00
parent dc91a9aba9
commit 96cf29fef4
37 changed files with 1988 additions and 494 deletions

View File

@@ -1,3 +1,5 @@
import { useState, useEffect } from "react";
export interface FaviconProps {
src?: string | null;
fallbackLetter: string;
@@ -12,13 +14,21 @@ const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, string> = {
/**
* Zeigt ein Favicon mit einem immer hellen Hintergrund (unabhängig vom
* Dark/Light-Theme der App) viele Favicons sind selbst dunkel/schwarz und
* wären auf dunklem Hintergrund sonst kaum zu erkennen. Ohne Favicon wird
* wären auf dunklem Hintergrund sonst kaum zu erkennen. Ohne Favicon oder bei
* einem fehlgeschlagenen Ladeversuch (kaputte URL, 404, CORS) wird
* stattdessen der erste Buchstabe des Namens gezeigt.
*/
export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
const dimension = SIZE_CLASSES[size];
const [failed, setFailed] = useState(false);
if (!src) {
// Bei geändertem src (z. B. anderer Listeneintrag durch Virtualisierung/Key-Wiederverwendung)
// erneut versuchen statt dauerhaft im Fehlerzustand zu bleiben.
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
@@ -35,7 +45,12 @@ export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-white p-0.5 ring-1 ring-black/5`}
>
<img src={src} alt="" className="h-full w-full object-contain" />
<img
src={src}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}

View File

@@ -7,23 +7,33 @@ export interface FavoriteItem {
favicon: string | null;
hostname: string;
port?: number;
category?: string | null;
}
export interface FavoritesBarProps {
items: FavoriteItem[];
label?: string;
/** 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. */
onReorder?: (orderedIds: string[]) => void;
}
/**
* Zeigt Favoriten als anklickbare, per Drag & Drop sortierbare Chips immer
* sichtbar, unabhängig vom Suchfeld. Dienste und Lesezeichen werden über
* getrennte FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein
* generisch über FavoriteItem statt fest an Service gebunden.
* Zeigt Favoriten als kompakte, nur-Icon-Chips Name erscheint als Tooltip
* beim Hover, nicht als sichtbarer Text (bewusst platzsparend). 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.
*/
export function FavoritesBar({ items, label, onOpen, onReorder }: FavoritesBarProps) {
export function FavoritesBar({
items,
label,
categoryColors,
onOpen,
onReorder,
}: FavoritesBarProps) {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<FavoriteItem[] | null>(null);
@@ -71,30 +81,33 @@ export function FavoritesBar({ items, label, onOpen, onReorder }: FavoritesBarPr
aria-label={label ?? "Favoriten"}
className="flex flex-wrap items-center justify-center gap-2"
>
{list.map((item) => (
<button
key={item.id}
type="button"
draggable={!!onReorder}
onDragStart={handleDragStart(item.id)}
onDragOver={handleDragOver(item.id)}
onDrop={handleDrop}
onClick={() => onOpen(item)}
title={
item.port !== undefined
? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})`
}
className={`flex items-center gap-2 rounded-full border border-black/10 bg-white/70
px-3 py-1.5 text-sm text-black transition-colors hover:bg-black/5
dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10 ${
onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`}
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<span className="max-w-[10rem] truncate">{item.displayName}</span>
</button>
))}
{list.map((item) => {
const ringColor = item.category ? categoryColors?.[item.category] : undefined;
return (
<button
key={item.id}
type="button"
draggable={!!onReorder}
onDragStart={handleDragStart(item.id)}
onDragOver={handleDragOver(item.id)}
onDrop={handleDrop}
onClick={() => onOpen(item)}
title={
item.port !== undefined
? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})`
}
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
className={`flex h-10 w-10 items-center justify-center rounded-full border
border-black/10 bg-white/70 transition-colors hover:bg-black/5
dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10 ${
onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`}
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</button>
);
})}
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import type { KeyboardEvent } from "react";
import { useState, type KeyboardEvent } from "react";
import type { SearchResult } from "@launchpad/shared";
import { Favicon } from "./Favicon.js";
@@ -6,6 +6,8 @@ export interface ResultsListProps {
results: SearchResult[];
selectedIndex: number;
emptyLabel?: string;
/** Kategoriename -> Hex-Farbe, für den kleinen Farbpunkt neben dem Kategorie-Badge. */
categoryColors?: Record<string, string>;
onHover: (index: number) => void;
onOpen: (item: SearchResult) => void;
onToggleFavorite?: (item: SearchResult) => void;
@@ -16,11 +18,16 @@ export interface ResultsListProps {
* und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die
* Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert;
* diese Komponente ist rein darstellend + klick-/tastaturbar.
*
* Füllt die Höhe des Elternelements aus (h-full) und scrollt selbst intern
* das Elternelement muss dafür `flex-1 min-h-0` sein (klassischer
* Flexbox-Scroll-Trick), damit nur die Liste scrollt und nicht die ganze Seite.
*/
export function ResultsList({
results,
selectedIndex,
emptyLabel = "Keine Treffer gefunden.",
categoryColors,
onHover,
onOpen,
onToggleFavorite,
@@ -28,7 +35,7 @@ export function ResultsList({
if (results.length === 0) {
return (
<div
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
className="rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
text-sm text-black/40 dark:border-white/5 dark:bg-white/5 dark:text-white/40"
>
{emptyLabel}
@@ -39,14 +46,14 @@ export function ResultsList({
return (
<ul
role="listbox"
className="mt-4 flex max-h-[60vh] flex-col overflow-y-auto rounded-2xl border
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
dark:bg-white/5"
className="flex h-full flex-col overflow-y-auto rounded-2xl border border-black/10
bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-white/5"
>
{results.map((item, index) => {
const active = index === selectedIndex;
const subtitle =
item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname;
const categoryColor = item.category ? categoryColors?.[item.category] : undefined;
return (
<li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
@@ -83,11 +90,20 @@ export function ResultsList({
</span>
{item.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30">
<span className="flex shrink-0 items-center gap-1 text-xs text-black/30 dark:text-white/30">
{categoryColor ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: categoryColor }}
aria-hidden
/>
) : null}
{item.category}
</span>
) : null}
<CopyButton url={item.url} />
<button
type="button"
onClick={(e) => {
@@ -112,3 +128,31 @@ export function ResultsList({
</ul>
);
}
function CopyButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard
.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
/* Zwischenablage evtl. ohne Berechtigung (z. B. unsicherer Kontext) - stillschweigend ignorieren */
});
}}
aria-label="Adresse kopieren"
title={copied ? "Kopiert!" : "Adresse kopieren"}
className="shrink-0 text-sm leading-none text-black/20 transition-colors hover:text-black/60
dark:text-white/20 dark:hover:text-white/60"
>
{copied ? "✓" : "⧉"}
</button>
);
}

View File

@@ -5,7 +5,8 @@
"rootDir": "src",
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler"
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}