round17+18: Emoji durch Font Awesome ersetzt, Lesezeichen-Kategorie-Vorschlaege, Favicon-Upload

This commit is contained in:
2026-07-23 14:56:40 +02:00
parent 050616a5cf
commit bae9e48fc1
12 changed files with 218 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft, faSignature } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
@@ -116,9 +117,30 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
const [hostname, setHostname] = useState(service.hostname);
const [port, setPort] = useState(String(service.port));
const [https, setHttps] = useState(service.https);
// undefined = unverändert (Feld nicht anfassen), null = Nutzer hat's
// explizit entfernt (fällt zurück auf automatische Erkennung/Buchstabe),
// string = neu hochgeladenes Favicon als data:-URL.
const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
const [faviconError, setFaviconError] = useState<string | null>(null);
const FAVICON_MAX_BYTES = 300 * 1024;
function handleFaviconFile(file: File | undefined) {
setFaviconError(null);
if (!file) return;
if (file.size > FAVICON_MAX_BYTES) {
setFaviconError("Datei zu groß (max. 300 KB) bitte ein kleineres Bild wählen.");
return;
}
const reader = new FileReader();
reader.onload = () => setFavicon(reader.result as string);
reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden.");
reader.readAsDataURL(file);
}
const portNumber = Number(port) || service.port;
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
const faviconPreview = favicon === undefined ? service.favicon : favicon;
const mutation = useMutation({
mutationFn: () => {
@@ -142,6 +164,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
if (trimmedName !== service.displayName) patch.displayName = trimmedName;
const trimmedCategory = category.trim() || null;
if (trimmedCategory !== service.category) patch.category = trimmedCategory;
if (favicon !== undefined) patch.favicon = favicon;
return patchService(service.id, patch);
},
onSuccess: () => {
@@ -155,6 +178,32 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Favicon</label>
<div className="flex items-center gap-2">
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
Hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
/>
</label>
{faviconPreview ? (
<button
type="button"
onClick={() => setFavicon(null)}
className="text-xs text-black/40 underline dark:text-white/40"
>
Entfernen
</button>
) : null}
</div>
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input
@@ -311,7 +360,7 @@ function ServiceRow({
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
<FontAwesomeIcon icon={service.favorite ? faStarSolid : faStar} />
</button>
<button
onClick={() => visibleMutation.mutate()}
@@ -319,7 +368,7 @@ function ServiceRow({
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
>
{service.visible ? "👁️" : "🙈"}
{service.visible ? <FontAwesomeIcon icon={faEye} /> : <FontAwesomeIcon icon={faEyeSlash} />}
</button>
</div>
</td>
@@ -330,7 +379,7 @@ function ServiceRow({
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
{service.displayNameEditedManually ? (
<span title="Name wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
</span>
) : null}
{!service.visible ? (
@@ -361,7 +410,7 @@ function ServiceRow({
{service.category ?? ""}
{service.categoryEditedManually ? (
<span title="Kategorie wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
</span>
) : null}
</span>
@@ -429,7 +478,7 @@ function SortableHeader({
}`}
>
{label}
<span className="text-[10px]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</span>
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
</button>
</th>
);
@@ -584,14 +633,14 @@ export function ServicesPage() {
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
🙈 Ausgeblendet ({hiddenCount})
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button>
</div>
{sortColumn ? (
<div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
Zur manuellen Reihenfolge (Drag & Drop) zurück
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
</Button>
</div>
) : null}