generated from Dicken/dickendock
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user