Refactor DashboardCollectionList: Simplify search input layout and improve tag selection logic for better user experience.

This commit is contained in:
Peter Meier
2026-03-12 16:36:20 +01:00
parent 22b4367c47
commit 7754d800f5
17 changed files with 759 additions and 151 deletions

View File

@@ -1,8 +1,17 @@
{
"LoginPage": {
"title": "Anmelden",
"apiKeyLabel": "API-Schlüssel",
"apiKeyPlaceholder": "API-Schlüssel eingeben",
"submit": "Anmelden",
"hint": "Gleicher Schlüssel wie RUSTYCMS_API_KEY auf dem Server. Ohne Schlüssel nur Lesen, mit Schlüssel Bearbeiten."
},
"Sidebar": {
"dashboard": "Dashboard",
"types": "Typen",
"assets": "Assets",
"login": "Anmelden",
"logout": "Abmelden",
"searchPlaceholder": "Sammlungen suchen…",
"searchAriaLabel": "Sammlungen suchen",
"closeMenu": "Menü schließen",

View File

@@ -1,8 +1,17 @@
{
"LoginPage": {
"title": "Login",
"apiKeyLabel": "API key",
"apiKeyPlaceholder": "Enter your API key",
"submit": "Login",
"hint": "Use the same key as RUSTYCMS_API_KEY on the server. Without a key you can only read; with a key you can edit."
},
"Sidebar": {
"dashboard": "Dashboard",
"types": "Types",
"assets": "Assets",
"login": "Login",
"logout": "Logout",
"searchPlaceholder": "Search collections…",
"searchAriaLabel": "Search collections",
"closeMenu": "Close menu",

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { setApiKey } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default function LoginPage() {
const t = useTranslations("LoginPage");
const router = useRouter();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = key.trim();
if (!trimmed) {
setError("API key is required.");
return;
}
setError(null);
setApiKey(trimmed);
router.push("/");
router.refresh();
}
return (
<div className="mx-auto max-w-sm py-8">
<div className="rounded-xl border border-accent-200 bg-white p-6 shadow-sm">
<h1 className="mb-4 text-xl font-semibold text-gray-900">{t("title")}</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="api-key" className="mb-1 block text-sm font-medium text-gray-700">
{t("apiKeyLabel")}
</label>
<Input
id="api-key"
type="password"
autoComplete="off"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder={t("apiKeyPlaceholder")}
className="w-full"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full">
{t("submit")}
</Button>
</form>
<p className="mt-4 text-xs text-gray-500">{t("hint")}</p>
<p className="mt-3">
<Link href="/" className="text-sm text-accent-600 hover:underline">
Back to dashboard
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,9 +1,14 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useState, useEffect } from "react";
import { syncStoredApiKey } from "@/lib/api";
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
syncStoredApiKey();
}, []);
const [client] = useState(
() =>
new QueryClient({

View File

@@ -44,48 +44,46 @@ export function DashboardCollectionList({ collections }: Props) {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="relative flex-1">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
className="w-full max-w-md"
/>
</div>
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600">{t("filterByTag")}</span>
<div className="relative max-w-md">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
className="w-full"
/>
</div>
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600">{t("filterByTag")}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === null
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{t("tagAll")}
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(null)}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === null
selectedTag === tag
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{t("tagAll")}
{tag}
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === tag
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{tag}
</button>
))}
</div>
)}
</div>
))}
</div>
)}
{collections.length === 0 ? (
<p className="text-gray-500">

View File

@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
import { Icon } from "@iconify/react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { fetchCollections, fetchContentList } from "@/lib/api";
import { fetchCollections, fetchContentList, getApiKey, setApiKey } from "@/lib/api";
import { LocaleSwitcher } from "./LocaleSwitcher";
const navLinkClass =
@@ -22,6 +22,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const t = useTranslations("Sidebar");
const pathname = usePathname();
const [search, setSearch] = useState("");
const [, setLogoutVersion] = useState(0);
const apiKey = getApiKey();
const hasEnvKey = typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY;
const hasStoredKey =
typeof window !== "undefined" && !!sessionStorage.getItem("rustycms_admin_api_key");
const { data, isLoading, error } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
@@ -205,6 +210,31 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
})}
</div>
</nav>
<div className="mt-2 border-t border-accent-200/50 pt-2">
{hasStoredKey ? (
<button
type="button"
onClick={() => {
setApiKey(null);
setLogoutVersion((v) => v + 1);
onClose?.();
}}
className={`${navLinkClass} w-full text-left text-gray-700 hover:bg-accent-100/80 hover:text-gray-900`}
>
<Icon icon="mdi:logout" className="size-4" aria-hidden />
{t("logout")}
</button>
) : !hasEnvKey ? (
<Link
href="/login"
onClick={onClose}
className={`${navLinkClass} w-full text-left text-gray-700 no-underline hover:bg-accent-100/80 hover:text-gray-900 ${pathname === "/login" ? "bg-accent-200/70 font-medium text-gray-900" : ""}`}
>
<Icon icon="mdi:login" className="size-4" aria-hidden />
{t("login")}
</Link>
) : null}
</div>
<LocaleSwitcher locale={locale} />
</aside>
);

View File

@@ -1,20 +1,49 @@
/**
* RustyCMS API client. Base URL from NEXT_PUBLIC_RUSTYCMS_API_URL.
* Optional RUSTYCMS_API_KEY for write operations (sent as X-API-Key).
* API key: from env (NEXT_PUBLIC_RUSTYCMS_API_KEY) or from login (sessionStorage).
*/
export const getBaseUrl = () =>
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
const STORAGE_KEY = "rustycms_admin_api_key";
/** Client-side only: key set by login when no env key. */
let clientApiKey: string | null = null;
/** Get API key (env or login). Call from client; server uses env only. */
export function getApiKey(): string | null {
if (typeof window !== "undefined") {
const fromEnv = process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null;
if (fromEnv) return fromEnv;
return clientApiKey ?? sessionStorage.getItem(STORAGE_KEY);
}
return process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null;
}
/** Set API key after login (sessionStorage + in-memory). */
export function setApiKey(key: string | null): void {
if (key) {
sessionStorage.setItem(STORAGE_KEY, key);
clientApiKey = key;
} else {
sessionStorage.removeItem(STORAGE_KEY);
clientApiKey = null;
}
}
/** Sync stored key into memory (call once on app load). */
export function syncStoredApiKey(): void {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) clientApiKey = stored;
}
const getHeaders = (): HeadersInit => {
const headers: HeadersInit = {
"Content-Type": "application/json",
Accept: "application/json",
};
const key =
typeof window !== "undefined"
? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null
: process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null;
const key = getApiKey();
if (key) headers["X-API-Key"] = key;
return headers;
};
@@ -306,11 +335,8 @@ export async function deleteFolder(name: string): Promise<void> {
}
const getUploadHeaders = (): HeadersInit => {
const key =
typeof window !== "undefined"
? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null
: process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null;
const headers: HeadersInit = {};
const key = getApiKey();
if (key) headers["X-API-Key"] = key;
return headers;
};