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,4 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowUp } from "@fortawesome/free-solid-svg-icons";
/** /**
* Schwebender Button unten rechts, der zum Seitenanfang zurückscrollt. * Schwebender Button unten rechts, der zum Seitenanfang zurückscrollt.
@@ -29,7 +31,7 @@ export function ScrollToTopButton() {
transition-opacity hover:bg-white dark:border-white/10 dark:bg-neutral-900/90 transition-opacity hover:bg-white dark:border-white/10 dark:bg-neutral-900/90
dark:text-white/70 dark:hover:bg-neutral-900" dark:text-white/70 dark:hover:bg-neutral-900"
> >
<FontAwesomeIcon icon={faArrowUp} />
</button> </button>
); );
} }

View File

@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { rankServices, type SearchResult } from "@launchpad/shared"; import { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js"; import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js"; import { useBookmarks } from "../hooks/useBookmarks.js";
@@ -248,7 +250,7 @@ export function HomePage() {
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5" hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
> >
<FontAwesomeIcon icon={faGear} />
</Link> </Link>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
@@ -256,7 +258,7 @@ export function HomePage() {
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5" hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
> >
{theme === "dark" ? "☀️" : "🌙"} <FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
</button> </button>
</div> </div>

View File

@@ -1,22 +1,39 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faBars,
faXmark,
faHouse,
faChartLine,
faDesktop,
faLink,
faBookmark,
faThumbtack,
faMagnifyingGlass,
faTag,
faPuzzlePiece,
faGear,
faScroll,
} from "@fortawesome/free-solid-svg-icons";
import { StatusBadge } from "@launchpad/ui"; import { StatusBadge } from "@launchpad/ui";
import { useBackendHealth } from "../../hooks/useBackendHealth.js"; import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { ScrollToTopButton } from "../../components/ScrollToTopButton.js"; import { ScrollToTopButton } from "../../components/ScrollToTopButton.js";
const NAV_ITEMS = [ const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [
{ to: "/", label: "Startseite", icon: "🏠" }, { to: "/", label: "Startseite", icon: faHouse },
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" }, { to: "/admin/dashboard", label: "Dashboard", icon: faChartLine },
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" }, { to: "/admin/devices", label: "Geräte", icon: faDesktop },
{ to: "/admin/services", label: "Dienste", icon: "🔗" }, { to: "/admin/services", label: "Dienste", icon: faLink },
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: "🔖" }, { to: "/admin/bookmarks", label: "Lesezeichen", icon: faBookmark },
{ to: "/admin/read-later", label: "Später lesen", icon: "📌" }, { to: "/admin/read-later", label: "Später lesen", icon: faThumbtack },
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" }, { to: "/admin/scanner", label: "Scanner", icon: faMagnifyingGlass },
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" }, { to: "/admin/categories", label: "Kategorien", icon: faTag },
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" }, { to: "/admin/plugins", label: "Plugins", icon: faPuzzlePiece },
{ to: "/admin/settings", label: "Einstellungen", icon: "⚙️" }, { to: "/admin/settings", label: "Einstellungen", icon: faGear },
{ to: "/admin/logs", label: "Logs", icon: "📜" }, { to: "/admin/logs", label: "Logs", icon: faScroll },
] as const; ];
export function AdminLayout() { export function AdminLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname }); const pathname = useRouterState({ select: (s) => s.location.pathname });
@@ -45,7 +62,7 @@ export function AdminLayout() {
className="rounded-lg p-1.5 text-black/70 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10" className="rounded-lg p-1.5 text-black/70 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10"
> >
<span className="block text-xl leading-none" aria-hidden> <span className="block text-xl leading-none" aria-hidden>
<FontAwesomeIcon icon={faBars} />
</span> </span>
</button> </button>
<Link to="/" className="text-base font-semibold text-black dark:text-white"> <Link to="/" className="text-base font-semibold text-black dark:text-white">
@@ -84,7 +101,7 @@ export function AdminLayout() {
aria-label="Menü schließen" aria-label="Menü schließen"
className="rounded-lg p-1 text-black/50 hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10 sm:hidden" className="rounded-lg p-1 text-black/50 hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10 sm:hidden"
> >
<FontAwesomeIcon icon={faXmark} />
</button> </button>
</div> </div>
@@ -101,7 +118,9 @@ export function AdminLayout() {
: "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white" : "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white"
}`} }`}
> >
<span aria-hidden>{item.icon}</span> <span className="w-4 text-center" aria-hidden>
<FontAwesomeIcon icon={item.icon} />
</span>
{item.label} {item.label}
</Link> </Link>
); );

View File

@@ -1,9 +1,11 @@
import { useMemo, useState, type DragEvent, type FormEvent } from "react"; import { useMemo, useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Bookmark } from "@launchpad/shared"; import type { Bookmark } from "@launchpad/shared";
import { suggestBookmarkCategory } from "@launchpad/shared";
import { useBookmarks } from "../../hooks/useBookmarks.js"; import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -16,6 +18,7 @@ interface BookmarkPatch {
favorite?: boolean; favorite?: boolean;
alias?: string[]; alias?: string[];
order?: number; order?: number;
favicon?: string | null;
} }
async function createBookmarkRequest(input: { async function createBookmarkRequest(input: {
@@ -139,6 +142,11 @@ function AddBookmarkForm() {
mutation.mutate(); 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 ( return (
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2"> <form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
<div> <div>
@@ -157,6 +165,15 @@ function AddBookmarkForm() {
Kategorie Kategorie
</label> </label>
<CategorySelect value={category} onChange={setCategory} /> <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>
<div> <div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50"> <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 [description, setDescription] = useState(bookmark.description ?? "");
const [alias, setAlias] = useState(bookmark.alias.join(", ")); 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({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
patchBookmark(bookmark.id, { patchBookmark(bookmark.id, {
@@ -205,6 +241,7 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
.split(",") .split(",")
.map((a) => a.trim()) .map((a) => a.trim())
.filter(Boolean), .filter(Boolean),
...(favicon !== undefined ? { favicon } : {}),
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); 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"> <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"> <td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-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> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input <input
@@ -328,7 +391,7 @@ function BookmarkRow({
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"} 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"}`} 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> </button>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -389,7 +452,7 @@ function SortableHeader({
}`} }`}
> >
{label} {label}
<span className="text-[10px]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</span> <span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
</button> </button>
</th> </th>
); );
@@ -486,7 +549,7 @@ export function BookmarksPage() {
{sortColumn ? ( {sortColumn ? (
<div className="mb-3"> <div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}> <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> </Button>
</div> </div>
) : null} ) : null}

View File

@@ -1,4 +1,6 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse } from "@fortawesome/free-solid-svg-icons";
import { useServices } from "../../hooks/useServices.js"; import { useServices } from "../../hooks/useServices.js";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
@@ -43,7 +45,7 @@ export function DashboardPage() {
text-sm text-black/70 transition-colors hover:bg-black/5 dark:border-white/10 text-sm text-black/70 transition-colors hover:bg-black/5 dark:border-white/10
dark:text-white/70 dark:hover:bg-white/10" dark:text-white/70 dark:hover:bg-white/10"
> >
<span aria-hidden>🏠</span> Startseite <span aria-hidden><FontAwesomeIcon icon={faHouse} /></span> Startseite
</Link> </Link>
} }
/> />

View File

@@ -1,6 +1,6 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; import type { Service } from "@launchpad/shared";
@@ -547,7 +547,7 @@ function SortableHeader({
}`} }`}
> >
{label} {label}
<span className="text-[10px]">{active ? (direction === "asc" ? "" : "") : ""}</span> <span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
</button> </button>
</th> </th>
); );

View File

@@ -1,6 +1,7 @@
import { useMemo, useState, type DragEvent } from "react"; import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; 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 [hostname, setHostname] = useState(service.hostname);
const [port, setPort] = useState(String(service.port)); const [port, setPort] = useState(String(service.port));
const [https, setHttps] = useState(service.https); 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 portNumber = Number(port) || service.port;
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`; const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
const faviconPreview = favicon === undefined ? service.favicon : favicon;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -142,6 +164,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
if (trimmedName !== service.displayName) patch.displayName = trimmedName; if (trimmedName !== service.displayName) patch.displayName = trimmedName;
const trimmedCategory = category.trim() || null; const trimmedCategory = category.trim() || null;
if (trimmedCategory !== service.category) patch.category = trimmedCategory; if (trimmedCategory !== service.category) patch.category = trimmedCategory;
if (favicon !== undefined) patch.favicon = favicon;
return patchService(service.id, patch); return patchService(service.id, patch);
}, },
onSuccess: () => { 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"> <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"> <td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-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> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input <input
@@ -311,7 +360,7 @@ function ServiceRow({
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"} 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"}`} 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>
<button <button
onClick={() => visibleMutation.mutate()} onClick={() => visibleMutation.mutate()}
@@ -319,7 +368,7 @@ function ServiceRow({
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"} 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"}`} 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> </button>
</div> </div>
</td> </td>
@@ -330,7 +379,7 @@ function ServiceRow({
<span className="font-medium text-black dark:text-white">{service.displayName}</span> <span className="font-medium text-black dark:text-white">{service.displayName}</span>
{service.displayNameEditedManually ? ( {service.displayNameEditedManually ? (
<span title="Name wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt"> <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> </span>
) : null} ) : null}
{!service.visible ? ( {!service.visible ? (
@@ -361,7 +410,7 @@ function ServiceRow({
{service.category ?? ""} {service.category ?? ""}
{service.categoryEditedManually ? ( {service.categoryEditedManually ? (
<span title="Kategorie wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt"> <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> </span>
) : null} ) : null}
</span> </span>
@@ -429,7 +478,7 @@ function SortableHeader({
}`} }`}
> >
{label} {label}
<span className="text-[10px]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</span> <span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
</button> </button>
</th> </th>
); );
@@ -584,14 +633,14 @@ export function ServicesPage() {
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70" : "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`} }`}
> >
🙈 Ausgeblendet ({hiddenCount}) <FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button> </button>
</div> </div>
{sortColumn ? ( {sortColumn ? (
<div className="mb-3"> <div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}> <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> </Button>
</div> </div>
) : null} ) : null}

View File

@@ -1,6 +1,8 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { useBackendHealth } from "../../hooks/useBackendHealth.js"; import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { useTheme } from "../../hooks/useTheme.js"; import { useTheme } from "../../hooks/useTheme.js";
import { useSettings } from "../../hooks/useSettings.js"; import { useSettings } from "../../hooks/useSettings.js";
@@ -308,7 +310,7 @@ export function SettingsPage() {
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white
dark:hover:bg-white/10" dark:hover:bg-white/10"
> >
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} wechseln <FontAwesomeIcon icon={theme === "dark" ? faMoon : faSun} /> {theme === "dark" ? "Dunkel" : "Hell"} wechseln
</button> </button>
</div> </div>
<LimitSetting <LimitSetting

Binary file not shown.

View File

@@ -191,3 +191,38 @@ export function rankServices<T extends Rankable>(items: T[], query: string): T[]
}) })
.map((entry) => entry.item); .map((entry) => entry.item);
} }
/**
* Bekannte Domain-Fragmente -> naheliegende Kategorie, für den Kategorie-
* Vorschlag beim Anlegen eines Lesezeichens (siehe BookmarksPage.tsx). Rein
* heuristisch anhand des Hostnamens - keine Netzwerkabfrage, kein externer
* Dienst. Bewusst knapp gehalten: lieber gar kein Vorschlag als ein falscher.
*/
const BOOKMARK_CATEGORY_HINTS: Array<{ pattern: RegExp; category: string }> = [
{ pattern: /github|gitlab|gitea|bitbucket|stackoverflow|npmjs|developer\.mozilla/i, category: "Entwicklung" },
{ pattern: /youtube|netflix|twitch|spotify|disneyplus|primevideo/i, category: "Unterhaltung" },
{ pattern: /amazon|ebay|etsy|otto\.de|zalando/i, category: "Shopping" },
{ pattern: /wikipedia|wiktionary/i, category: "Wissen" },
{ pattern: /reddit|twitter|x\.com|instagram|facebook|mastodon|threads\.net/i, category: "Social Media" },
{ pattern: /paypal|sparkasse|bank|n26\.com|dkb\.de|ing\.de/i, category: "Finanzen" },
{ pattern: /docs\.google|notion\.so|trello|asana|slack\.com|confluence/i, category: "Produktivität" },
{ pattern: /heise\.de|golem\.de|tagesschau|spiegel\.de|zeit\.de|faz\.net/i, category: "Nachrichten" },
];
/**
* Liefert eine Kategorie-Vermutung anhand des Hostnamens einer URL, oder
* null wenn keine der bekannten Regeln zutrifft. Nur ein Vorschlag - wird nie
* automatisch übernommen (siehe BookmarksPage.tsx: nur als Chip zum
* Bestätigen angezeigt).
*/
export function suggestBookmarkCategory(url: string): string | null {
// Kein URL()-Konstruktor - der ist ein DOM-Global und in diesem Pakets
// tsconfig-"lib" nicht garantiert verfügbar (das Paket wird auch vom
// Backend importiert). Einfache manuelle Extraktion reicht hier völlig.
const withoutProtocol = url.replace(/^[a-z]+:\/\//i, "");
const hostname = withoutProtocol.split(/[/?#]/)[0] ?? withoutProtocol;
for (const { pattern, category } of BOOKMARK_CATEGORY_HINTS) {
if (pattern.test(hostname)) return category;
}
return null;
}

View File

@@ -10,6 +10,10 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@launchpad/shared": "workspace:*", "@launchpad/shared": "workspace:*",
"react": "^18.3.1" "react": "^18.3.1"
}, },

View File

@@ -1,5 +1,8 @@
import { useState, type KeyboardEvent } from "react"; import { useState, type KeyboardEvent } from "react";
import type { SearchResult } from "@launchpad/shared"; import type { SearchResult } from "@launchpad/shared";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faStar as faStarSolid, faCheck, faClone } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { Favicon } from "./Favicon.js"; import { Favicon } from "./Favicon.js";
function hexToRgba(hex: string, alpha: number): string | null { function hexToRgba(hex: string, alpha: number): string | null {
@@ -136,7 +139,7 @@ export function ResultsList({
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400" : "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
} ${onToggleFavorite ? "" : "cursor-default"}`} } ${onToggleFavorite ? "" : "cursor-default"}`}
> >
<FontAwesomeIcon icon={item.favorite ? faStarSolid : faStar} />
</button> </button>
</div> </div>
</li> </li>
@@ -169,7 +172,7 @@ function CopyButton({ url }: { url: string }) {
className="shrink-0 text-sm leading-none text-black/20 transition-colors hover:text-black/60 className="shrink-0 text-sm leading-none text-black/20 transition-colors hover:text-black/60
dark:text-white/20 dark:hover:text-white/60" dark:text-white/20 dark:hover:text-white/60"
> >
{copied ? "✓" : "⧉"} <FontAwesomeIcon icon={copied ? faCheck : faClone} />
</button> </button>
); );
} }