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,9 +1,11 @@
import { useMemo, useState, type DragEvent, type FormEvent } 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 } 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 { Bookmark } from "@launchpad/shared";
import { suggestBookmarkCategory } from "@launchpad/shared";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -16,6 +18,7 @@ interface BookmarkPatch {
favorite?: boolean;
alias?: string[];
order?: number;
favicon?: string | null;
}
async function createBookmarkRequest(input: {
@@ -139,6 +142,11 @@ function AddBookmarkForm() {
mutation.mutate();
}
// Kategorie-Vorschlag anhand der eingegebenen URL (rein heuristisch anhand
// des Hostnamens, siehe suggestBookmarkCategory in @launchpad/shared) - nur
// solange der Nutzer noch keine eigene Kategorie gewählt hat.
const suggestedCategory = category.trim() === "" ? suggestBookmarkCategory(url.trim()) : null;
return (
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
<div>
@@ -157,6 +165,15 @@ function AddBookmarkForm() {
Kategorie
</label>
<CategorySelect value={category} onChange={setCategory} />
{suggestedCategory ? (
<button
type="button"
onClick={() => setCategory(suggestedCategory)}
className="mt-1 block text-xs text-blue-600 hover:underline dark:text-blue-400"
>
Vorschlag: {suggestedCategory} übernehmen?
</button>
) : null}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
@@ -194,6 +211,25 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
const [description, setDescription] = useState(bookmark.description ?? "");
const [alias, setAlias] = useState(bookmark.alias.join(", "));
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 faviconPreview = favicon === undefined ? bookmark.favicon : favicon;
const mutation = useMutation({
mutationFn: () =>
patchBookmark(bookmark.id, {
@@ -205,6 +241,7 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
.split(",")
.map((a) => a.trim())
.filter(Boolean),
...(favicon !== undefined ? { favicon } : {}),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
@@ -217,6 +254,32 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; 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
@@ -328,7 +391,7 @@ function BookmarkRow({
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${bookmark.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
<FontAwesomeIcon icon={bookmark.favorite ? faStarSolid : faStar} />
</button>
</td>
<td className="px-4 py-3">
@@ -389,7 +452,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>
);
@@ -486,7 +549,7 @@ export function BookmarksPage() {
{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}