Refactor DashboardCollectionList: Simplify search input layout and improve tag selection logic for better user experience.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
admin-ui/src/app/login/page.tsx
Normal file
63
admin-ui/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user