generated from Dicken/dickendock
Kritische Bugfixes: Export/CA-Download (Service-Worker), Speichern-Bug bei Diensten/Lesezeichen, Dark-Mode-Direktlink, Favicon-Mixed-Content-Proxy, Live-Update Zuletzt-besucht, Kategorie-Farb-Picker, Startseite-Nav
This commit is contained in:
@@ -46,7 +46,18 @@ export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>;
|
||||
|
||||
export const ServiceUpdateSchema = ServiceCreateSchema.omit({
|
||||
deviceId: true,
|
||||
}).partial();
|
||||
})
|
||||
.partial()
|
||||
.extend({
|
||||
// Frontend schickt beim Leeren eines Felds bewusst null (nicht nur
|
||||
// "Feld weglassen") - .partial() allein akzeptiert dafür nur
|
||||
// string|undefined, ein PATCH mit category:null schlug daher bisher mit
|
||||
// 400 fehl, ohne dass das Formular das sichtbar gemacht hätte.
|
||||
category: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
icon: z.string().nullable().optional(),
|
||||
favicon: z.string().nullable().optional(),
|
||||
});
|
||||
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
||||
|
||||
/** Für Drag & Drop: neue Reihenfolge mehrerer Dienste auf einmal setzen. */
|
||||
@@ -98,7 +109,14 @@ export const BookmarkCreateSchema = z.object({
|
||||
});
|
||||
export type BookmarkCreateInput = z.infer<typeof BookmarkCreateSchema>;
|
||||
|
||||
export const BookmarkUpdateSchema = BookmarkCreateSchema.partial();
|
||||
export const BookmarkUpdateSchema = BookmarkCreateSchema.partial().extend({
|
||||
// Siehe Kommentar bei ServiceUpdateSchema: null muss erlaubt sein, damit
|
||||
// sich Kategorie/Beschreibung/Icons im Bearbeiten-Formular leeren lassen.
|
||||
category: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
icon: z.string().nullable().optional(),
|
||||
favicon: z.string().nullable().optional(),
|
||||
});
|
||||
export type BookmarkUpdateInput = z.infer<typeof BookmarkUpdateSchema>;
|
||||
|
||||
/** Für Drag & Drop: neue Reihenfolge mehrerer Lesezeichen auf einmal setzen. */
|
||||
|
||||
@@ -18,6 +18,18 @@ const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, string> = {
|
||||
* einem fehlgeschlagenen Ladeversuch (kaputte URL, 404, CORS) wird
|
||||
* stattdessen der erste Buchstabe des Namens gezeigt.
|
||||
*/
|
||||
/**
|
||||
* Externe/Geräte-Favicons laufen über den Backend-Proxy (/api/favicon-proxy):
|
||||
* Ohne das würden auf der HTTPS-Seite viele http://-Favicons (typisch für
|
||||
* Homelab-Geräte ohne eigenes TLS) vom Browser als "Mixed Content" blockiert
|
||||
* und nie angezeigt - das Backend selbst fetcht sie ohne diese Einschränkung.
|
||||
* Data-URLs (z. B. selbst hochgeladene Icons) werden direkt durchgereicht.
|
||||
*/
|
||||
function resolveFaviconSrc(src: string): string {
|
||||
if (src.startsWith("data:")) return src;
|
||||
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
|
||||
const dimension = SIZE_CLASSES[size];
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -46,7 +58,7 @@ export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
|
||||
bg-white p-0.5 ring-1 ring-black/5`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
src={resolveFaviconSrc(src)}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function FavoritesBar({
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<div className="mb-1.5 text-center text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||
<div className="mb-2 text-center text-xs font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -98,8 +98,8 @@ export function FavoritesBar({
|
||||
: `${item.displayName} (${item.hostname})`
|
||||
}
|
||||
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border
|
||||
border-black/10 bg-white/70 transition-colors hover:bg-black/5
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-xl border
|
||||
border-black/10 bg-white/70 shadow-sm transition-colors hover:bg-black/5
|
||||
dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10 ${
|
||||
onReorder ? "cursor-grab active:cursor-grabbing" : ""
|
||||
} ${draggedId === item.id ? "opacity-40" : ""}`}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
/** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */
|
||||
hint?: string;
|
||||
/** Zeigt ein X zum Leeren des Felds, sobald Text eingegeben wurde. */
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10,7 +12,9 @@ export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElem
|
||||
* Bewusst schlicht gehalten: großer Text, viel Weißraum, keine Ablenkung.
|
||||
*/
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ hint, className = "", ...props }, ref) => {
|
||||
({ hint, onClear, className = "", ...props }, ref) => {
|
||||
const hasValue = typeof props.value === "string" && props.value.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border border-black/10 bg-white/80
|
||||
@@ -40,6 +44,30 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
dark:text-white dark:placeholder:text-white/30"
|
||||
{...props}
|
||||
/>
|
||||
{hasValue && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
aria-label="Suche leeren"
|
||||
className="shrink-0 rounded-full p-1 text-black/30 transition-colors
|
||||
hover:bg-black/5 hover:text-black/60 dark:text-white/30
|
||||
dark:hover:bg-white/10 dark:hover:text-white/60"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<span className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 text-xs
|
||||
text-black/40 dark:border-white/10 dark:text-white/40">
|
||||
|
||||
Reference in New Issue
Block a user