generated from Dicken/dickendock
round17+18: Emoji durch Font Awesome ersetzt, Lesezeichen-Kategorie-Vorschlaege, Favicon-Upload
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user