feat: redesign search bars with prominent search input and compact filters
Restructure LiveSearchForm: full-width search input with magnifying glass icon, filters in a compact row below with contextual icons per field (library, status, sort, etc.) and inline labels. Remove per-field className overrides from series and books pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,10 +114,10 @@ export default async function BooksPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/books"
|
basePath="/books"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder"), className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-40" },
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { useRef, useCallback, useEffect } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
|
// SVG path data for filter icons, keyed by field name
|
||||||
|
const FILTER_ICONS: Record<string, string> = {
|
||||||
|
// Library - building/collection
|
||||||
|
library: "M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z",
|
||||||
|
// Reading status - open book
|
||||||
|
status: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||||
|
// Series status - signal/activity
|
||||||
|
series_status: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
|
||||||
|
// Missing books - warning triangle
|
||||||
|
has_missing: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
||||||
|
// Metadata provider - tag
|
||||||
|
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||||
|
// Sort - arrows up/down
|
||||||
|
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
||||||
|
};
|
||||||
|
|
||||||
interface FieldDef {
|
interface FieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
type: "text" | "select";
|
type: "text" | "select";
|
||||||
@@ -60,6 +76,9 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
return val && val.trim() !== "";
|
return val && val.trim() !== "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const textFields = fields.filter((f) => f.type === "text");
|
||||||
|
const selectFields = fields.filter((f) => f.type === "select");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
@@ -68,33 +87,52 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
router.replace(buildUrl() as any);
|
router.replace(buildUrl() as any);
|
||||||
}}
|
}}
|
||||||
className="flex flex-col sm:flex-row sm:flex-wrap gap-3 items-start sm:items-end"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{fields.map((field) =>
|
{/* Search input with icon */}
|
||||||
field.type === "text" ? (
|
{textFields.map((field) => (
|
||||||
<div key={field.name} className={field.className || "flex-1 w-full"}>
|
<div key={field.name} className="relative">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
<svg
|
||||||
{field.label}
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
||||||
</label>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
name={field.name}
|
name={field.name}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
defaultValue={searchParams.get(field.name) || ""}
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
onChange={() => navigate(false)}
|
onChange={() => navigate(false)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<div key={field.name} className={field.className || "w-full sm:w-48"}>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
{/* Filters row */}
|
||||||
|
{selectFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
{textFields.length > 0 && (
|
||||||
|
<div className="border-t border-border/60" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{selectFields.map((field) => (
|
||||||
|
<div key={field.name} className="flex items-center gap-1.5">
|
||||||
|
{FILTER_ICONS[field.name] && (
|
||||||
|
<svg className="w-3.5 h-3.5 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={FILTER_ICONS[field.name]} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<label className="text-xs font-medium text-muted-foreground whitespace-nowrap">
|
||||||
{field.label}
|
{field.label}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name={field.name}
|
name={field.name}
|
||||||
defaultValue={searchParams.get(field.name) || ""}
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
onChange={() => navigate(true)}
|
onChange={() => navigate(true)}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{field.options?.map((opt) => (
|
{field.options?.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
@@ -103,28 +141,30 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.replace(basePath as any)}
|
onClick={() => router.replace(basePath as any)}
|
||||||
className="
|
className="
|
||||||
inline-flex items-center justify-center
|
inline-flex items-center gap-1
|
||||||
h-10 px-4
|
h-8 px-2.5
|
||||||
border border-input
|
text-xs font-medium
|
||||||
text-sm font-medium
|
|
||||||
text-muted-foreground
|
text-muted-foreground
|
||||||
bg-background
|
|
||||||
rounded-md
|
rounded-md
|
||||||
hover:bg-accent hover:text-accent-foreground
|
hover:bg-accent hover:text-accent-foreground
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
w-full sm:w-auto
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
{t("common.clear")}
|
{t("common.clear")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ export default async function SeriesPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/series"
|
basePath="/series"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder"), className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder") },
|
||||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions, className: "w-full sm:w-44" },
|
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||||
{ name: "status", type: "select", label: t("series.reading"), options: statusOptions, className: "w-full sm:w-32" },
|
{ name: "status", type: "select", label: t("series.reading"), options: statusOptions },
|
||||||
{ name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions, className: "w-full sm:w-36" },
|
{ name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions },
|
||||||
{ name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions, className: "w-full sm:w-36" },
|
{ name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions },
|
||||||
{ name: "metadata_provider", type: "select", label: t("series.metadata"), options: metadataOptions, className: "w-full sm:w-36" },
|
{ name: "metadata_provider", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-32" },
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user