Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown

This commit is contained in:
2026-07-19 21:56:25 +02:00
parent 31e17ff77b
commit dc91a9aba9
24 changed files with 1655 additions and 180 deletions

View File

@@ -0,0 +1,41 @@
export interface FaviconProps {
src?: string | null;
fallbackLetter: string;
size?: "sm" | "md";
}
const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, string> = {
sm: "h-4 w-4",
md: "h-5 w-5",
};
/**
* 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
* stattdessen der erste Buchstabe des Namens gezeigt.
*/
export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
const dimension = SIZE_CLASSES[size];
if (!src) {
return (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50`}
>
{fallbackLetter.charAt(0).toUpperCase()}
</span>
);
}
return (
<span
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" />
</span>
);
}

View File

@@ -1,47 +1,101 @@
import type { Service } from "@launchpad/shared";
import { useState, type DragEvent } from "react";
import { Favicon } from "./Favicon.js";
export interface FavoriteItem {
id: string;
displayName: string;
favicon: string | null;
hostname: string;
port?: number;
}
export interface FavoritesBarProps {
services: Service[];
onOpen: (service: Service) => void;
items: FavoriteItem[];
label?: string;
onOpen: (item: FavoriteItem) => void;
/** Wenn gesetzt, sind die Chips per Drag & Drop sortierbar. */
onReorder?: (orderedIds: string[]) => void;
}
/**
* Zeigt Favoriten als anklickbare Chips immer sichtbar, unabhängig vom
* Suchfeld. Reihenfolge folgt service.order (im Adminbereich per Drag & Drop
* änderbar).
* 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.
*/
export function FavoritesBar({ services, onOpen }: FavoritesBarProps) {
if (services.length === 0) return null;
export function FavoritesBar({ items, label, onOpen, onReorder }: FavoritesBarProps) {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<FavoriteItem[] | null>(null);
if (items.length === 0) return null;
const list = localOrder ?? items;
function handleDragStart(id: string) {
return (_e: DragEvent<HTMLButtonElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!draggedId || draggedId === targetId) return;
const current = localOrder ?? items;
const fromIndex = current.findIndex((i) => i.id === draggedId);
const toIndex = current.findIndex((i) => i.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const next = [...current];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
setLocalOrder(next);
};
}
function handleDrop(e: DragEvent<HTMLButtonElement>) {
e.preventDefault();
setDraggedId(null);
const current = localOrder ?? items;
onReorder?.(current.map((i) => i.id));
}
return (
<div
role="toolbar"
aria-label="Favoriten"
className="flex flex-wrap items-center justify-center gap-2"
>
{services.map((service) => (
<button
key={service.id}
type="button"
onClick={() => onOpen(service)}
title={`${service.displayName} (${service.hostname}:${service.port})`}
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"
>
{service.favicon ? (
<img src={service.favicon} alt="" className="h-4 w-4 shrink-0 rounded" />
) : (
<span
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full
bg-black/10 text-[9px] font-medium text-black/50 dark:bg-white/10 dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<span className="max-w-[10rem] truncate">{service.displayName}</span>
</button>
))}
<div>
{label ? (
<div className="mb-1.5 text-center text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
{label}
</div>
) : null}
<div
role="toolbar"
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>
))}
</div>
</div>
);
}

View File

@@ -1,29 +1,31 @@
import type { KeyboardEvent } from "react";
import type { Service } from "@launchpad/shared";
import type { SearchResult } from "@launchpad/shared";
import { Favicon } from "./Favicon.js";
export interface ResultsListProps {
services: Service[];
results: SearchResult[];
selectedIndex: number;
emptyLabel?: string;
onHover: (index: number) => void;
onOpen: (service: Service) => void;
onToggleFavorite?: (service: Service) => void;
onOpen: (item: SearchResult) => void;
onToggleFavorite?: (item: SearchResult) => void;
}
/**
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an.
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar.
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an Dienste
* und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die
* Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert;
* diese Komponente ist rein darstellend + klick-/tastaturbar.
*/
export function ResultsList({
services,
results,
selectedIndex,
emptyLabel = "Keine Dienste gefunden.",
emptyLabel = "Keine Treffer gefunden.",
onHover,
onOpen,
onToggleFavorite,
}: ResultsListProps) {
if (services.length === 0) {
if (results.length === 0) {
return (
<div
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
@@ -41,16 +43,19 @@ export function ResultsList({
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
dark:bg-white/5"
>
{services.map((service, index) => {
{results.map((item, index) => {
const active = index === selectedIndex;
const subtitle =
item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname;
return (
<li key={service.id} role="option" aria-selected={active}>
<li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
<div
tabIndex={-1}
onMouseEnter={() => onHover(index)}
onClick={() => onOpen(service)}
onClick={() => onOpen(item)}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") onOpen(service);
if (e.key === "Enter") onOpen(item);
}}
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
transition-colors ${
@@ -59,30 +64,27 @@ export function ResultsList({
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
}`}
>
{service.favicon ? (
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
) : (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<Favicon src={item.favicon} fallbackLetter={item.displayName} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-black dark:text-white">
{service.displayName}
<span className="flex items-center gap-1.5">
<span className="block truncate text-sm font-medium text-black dark:text-white">
{item.displayName}
</span>
{item.kind === "bookmark" ? (
<span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/40 dark:bg-white/10 dark:text-white/40">
Lesezeichen
</span>
) : null}
</span>
<span className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname}:{service.port}
{subtitle}
</span>
</span>
{service.category ? (
{item.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30">
{service.category}
{item.category}
</span>
) : null}
@@ -90,15 +92,13 @@ export function ResultsList({
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(service);
onToggleFavorite?.(item);
}}
disabled={!onToggleFavorite}
aria-pressed={service.favorite}
aria-label={
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
}
aria-pressed={item.favorite}
aria-label={item.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"}
className={`shrink-0 text-lg leading-none transition-colors ${
service.favorite
item.favorite
? "text-amber-500"
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
} ${onToggleFavorite ? "" : "cursor-default"}`}

View File

@@ -12,3 +12,6 @@ export type { ButtonProps } from "./Button.js";
export { FavoritesBar } from "./FavoritesBar.js";
export type { FavoritesBarProps } from "./FavoritesBar.js";
export { Favicon } from "./Favicon.js";
export type { FaviconProps } from "./Favicon.js";