generated from Dicken/dickendock
Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
41
packages/ui/src/Favicon.tsx
Normal file
41
packages/ui/src/Favicon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}`}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user