generated from Dicken/dickendock
Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown
This commit is contained in:
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