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

@@ -73,6 +73,44 @@ export interface PluginInfo {
capabilities: string[];
}
/**
* Ein manuell angelegtes Lesezeichen im Unterschied zu Diensten nicht an
* ein gescanntes Gerät gebunden. Erscheint zusammen mit Diensten in der
* Suche, wird aber als eigene Entität verwaltet (siehe Admin -> Lesezeichen).
*/
export interface Bookmark {
id: string;
url: string;
displayName: string;
hostname: string; // aus der URL abgeleitet, für konsistentes Ranking
description: string | null;
category: string | null;
icon: string | null;
favicon: string | null;
favorite: boolean;
alias: string[];
order: number;
}
/** Gemeinsame Form, die Service und Bookmark fürs Ranking erfüllen. */
export interface Rankable {
displayName: string;
hostname: string;
alias: string[];
description: string | null;
favorite: boolean;
order: number;
}
/**
* Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste und
* Lesezeichen zusammen, aber unterscheidbar über `kind` (siehe
* "getrennt von Diensten" bei Favoriten).
*/
export type SearchResult =
| (Service & { kind: "service" })
| (Bookmark & { kind: "bookmark" });
/**
* Ranking-Stufen für die Suche, gemäß Spezifikation:
* 1. Displayname beginnt mit Suchtext
@@ -83,15 +121,17 @@ export interface PluginInfo {
* 6. Beschreibung enthält Suchtext
*
* Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer.
* Funktioniert generisch für alles, was die Rankable-Form erfüllt
* (Service, Bookmark).
*/
export function rankService(service: Service, query: string): number | null {
export function rankService<T extends Rankable>(item: T, query: string): number | null {
const q = query.trim().toLowerCase();
if (q.length === 0) return null;
const displayName = service.displayName.toLowerCase();
const hostname = service.hostname.toLowerCase();
const description = (service.description ?? "").toLowerCase();
const alias = service.alias.map((a) => a.toLowerCase());
const displayName = item.displayName.toLowerCase();
const hostname = item.hostname.toLowerCase();
const description = (item.description ?? "").toLowerCase();
const alias = item.alias.map((a) => a.toLowerCase());
if (displayName.startsWith(q)) return 1;
if (alias.some((a) => a.startsWith(q))) return 2;
@@ -104,26 +144,27 @@ export function rankService(service: Service, query: string): number | null {
}
/**
* Sortiert und filtert eine Liste von Diensten anhand des Suchtexts.
* Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge.
* Sortiert und filtert eine Liste (Dienste, Lesezeichen oder eine Mischung
* über einen gemeinsamen Union-Typ) anhand des Suchtexts. Favoriten werden
* bei gleichem Rang bevorzugt, danach die definierte Reihenfolge.
*/
export function rankServices(services: Service[], query: string): Service[] {
export function rankServices<T extends Rankable>(items: T[], query: string): T[] {
const q = query.trim();
if (q.length === 0) {
return [...services].sort((a, b) => {
return [...items].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return a.order - b.order;
});
}
return services
.map((service) => ({ service, rank: rankService(service, q) }))
.filter((entry): entry is { service: Service; rank: number } => entry.rank !== null)
return items
.map((item) => ({ item, rank: rankService(item, q) }))
.filter((entry): entry is { item: T; rank: number } => entry.rank !== null)
.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1;
return a.service.order - b.service.order;
if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
return a.item.order - b.item.order;
})
.map((entry) => entry.service);
.map((entry) => entry.item);
}

View File

@@ -78,3 +78,31 @@ export const CategoryReorderSchema = z
)
.min(1, "mindestens ein Eintrag erforderlich");
export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>;
export const BookmarkCreateSchema = z.object({
url: z.string().url("url muss eine gültige URL sein"),
// Optional: wird nicht angegeben, versucht das Backend automatisch den
// Seitentitel zu lesen (siehe apps/backend/src/routes/bookmarks.ts).
displayName: z.string().min(1).optional(),
description: z.string().optional(),
category: z.string().optional(),
icon: z.string().optional(),
favorite: z.boolean().optional(),
alias: z.array(z.string()).optional(),
order: z.number().optional(),
});
export type BookmarkCreateInput = z.infer<typeof BookmarkCreateSchema>;
export const BookmarkUpdateSchema = BookmarkCreateSchema.partial();
export type BookmarkUpdateInput = z.infer<typeof BookmarkUpdateSchema>;
/** Für Drag & Drop: neue Reihenfolge mehrerer Lesezeichen auf einmal setzen. */
export const BookmarkReorderSchema = z
.array(
z.object({
id: z.string().min(1),
order: z.number(),
})
)
.min(1, "mindestens ein Eintrag erforderlich");
export type BookmarkReorderInput = z.infer<typeof BookmarkReorderSchema>;

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";