feat: enhance UI components and improve user experience

- Updated Toaster position to bottom-right and added custom styles for success and error messages.
- Increased font size of the brand link in AppHeader for better visibility.
- Refactored DashboardPage and GroupResultsPage to include a Footer component for consistent layout.
- Improved LandingPage with new group input handling and enhanced user feedback for ORCID input.
This commit is contained in:
Alexis
2026-05-12 10:41:45 +02:00
parent ecdfadbf20
commit d8fa8031b6
8 changed files with 428 additions and 251 deletions
+168 -86
View File
@@ -1,15 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { DocumentIcon, UsersIcon } from "../components/ui/Icons";
import { UsersIcon } from "../components/ui/Icons";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import { Spinner } from "../components/ui/Spinner";
import { getInitials } from "../utils/formatters";
import { formatOrcidInput, isValidOrcid } from "../utils/orcid";
import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext";
import Footer from "../components/layout/Footer";
/**
* Entry view: login con ORCID iD + búsqueda individual anónima +
@@ -21,7 +23,22 @@ import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext";
*/
export function LandingPage() {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const { isAuthenticated, userName, userOrcidId } = useAuth();
// If the JWT doesn't carry the name (ORCID sandbox omits it), fetch it
// lazily from the public researcher endpoint so the identity block is correct.
const [resolvedName, setResolvedName] = useState(null);
useEffect(() => {
if (!isAuthenticated || !userOrcidId) { setResolvedName(null); return; }
if (userName) { setResolvedName(userName); return; }
let cancelled = false;
searchResearcher(userOrcidId)
.then((bundle) => { if (!cancelled) setResolvedName(bundle.researcher?.name ?? null); })
.catch(() => {});
return () => { cancelled = true; };
}, [isAuthenticated, userOrcidId, userName]);
const displayName = resolvedName ?? userName;
const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState("");
@@ -29,9 +46,11 @@ export function LandingPage() {
const [loginLoading, setLoginLoading] = useState(false);
// Group search state
const [groupInput, setGroupInput] = useState("");
const [groupTags, setGroupTags] = useState([]);
const [groupRawInput, setGroupRawInput] = useState("");
const [groupError, setGroupError] = useState("");
const [groupLoading, setGroupLoading] = useState(false);
const groupInputRef = useRef(null);
// Cleanup refs for popup polling interval
const popupRef = useRef(null);
@@ -126,28 +145,60 @@ export function LandingPage() {
}
}
function parseGroupOrcids(raw) {
return raw
.split(/[\s,\n]+/)
.map((s) => s.trim())
.filter(Boolean);
/**
* Splits a raw string on comma/space/newline separators, promotes valid
* ORCIDs to tags (deduplicating against existing ones), and returns any
* leftover invalid tokens joined by a space so the user can correct them.
*/
function commitRawInput(raw) {
const parts = raw.split(/[\s,\n]+/).map((s) => s.trim()).filter(Boolean);
const valid = parts.filter(isValidOrcid);
const invalid = parts.filter((p) => !isValidOrcid(p));
if (valid.length > 0) {
setGroupTags((prev) => [...new Set([...prev, ...valid])]);
if (groupError) setGroupError("");
}
return invalid.join(" ");
}
function handleGroupTagKeyDown(event) {
const { key } = event;
if (key === "Enter" || key === "," || key === " ") {
event.preventDefault();
const leftover = commitRawInput(groupRawInput);
setGroupRawInput(leftover);
} else if (key === "Backspace" && groupRawInput === "" && groupTags.length > 0) {
setGroupTags((prev) => prev.slice(0, -1));
}
}
function handleGroupRawChange(event) {
setGroupRawInput(event.target.value);
if (groupError) setGroupError("");
}
function handleGroupPaste(event) {
event.preventDefault();
const pasted = event.clipboardData.getData("text");
const combined = groupRawInput ? `${groupRawInput} ${pasted}` : pasted;
const leftover = commitRawInput(combined);
setGroupRawInput(leftover);
}
function removeGroupTag(tag) {
setGroupTags((prev) => prev.filter((t) => t !== tag));
if (groupError) setGroupError("");
}
async function handleGroupSearch() {
const ids = parseGroupOrcids(groupInput);
if (ids.length === 0) {
setGroupError("Introduce al menos un ORCID iD.");
return;
}
const invalid = ids.filter((id) => !isValidOrcid(id));
if (invalid.length > 0) {
setGroupError(`ORCID iDs con formato incorrecto: ${invalid.join(", ")}`);
if (groupTags.length === 0) {
setGroupError("Introduce al menos un ORCID iD válido.");
return;
}
setGroupError("");
setGroupLoading(true);
try {
navigate("/group", { state: { orcidIds: ids } });
navigate("/group", { state: { orcidIds: groupTags } });
} finally {
setGroupLoading(false);
}
@@ -157,32 +208,20 @@ export function LandingPage() {
if (event.key === "Enter") handleValidate();
}
function handleGroupKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleGroupSearch();
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" />
<main className="flex flex-1 flex-col items-center px-4 py-12">
<main className="flex flex-1 flex-col items-center px-4 pb-12 pt-16">
<div className="w-full max-w-7xl">
{/* ── Hero ── */}
<div className="mb-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
<DocumentIcon size={32} className="text-white" />
</div>
<h1 className="mb-3 text-[32px] font-bold tracking-tight text-ink-primary md:text-[40px]">
Tu producción científica,{" "}
<span className="text-brand-primary">siempre al día.</span>
<h1 className="mb-3 text-[36px] font-bold tracking-tight text-ink-primary md:text-[46px]">
Tus publicaciones, listas para depositar.
</h1>
<p className="mx-auto max-w-xl text-[16px] leading-relaxed text-ink-secondary">
Sincroniza tu perfil ORCID y deposita tus publicaciones
automáticamente vía SWORD.
Conecta tu ORCID y descárgalas en XML cuando quieras.
</p>
</div>
@@ -190,26 +229,45 @@ export function LandingPage() {
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
{/* ── Left: individual search + login ── */}
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<div className="flex flex-col rounded-2xl border border-surface-border/30 bg-surface-primary p-8 shadow-sm">
{isAuthenticated ? (
<div className="flex items-center justify-between rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
<span className="font-medium">Sesión activa</span>
<span className="text-xs text-green-600">
Verás publicaciones nuevas marcadas en el dashboard
</span>
</div>
) : (
<button
type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3.5 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
<Link
to={userOrcidId ? `/dashboard/${userOrcidId}` : "/"}
className="flex w-full items-center gap-3 rounded-xl p-2 -mx-2 transition-colors hover:bg-surface-secondary"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
{getInitials(displayName ?? userOrcidId ?? "?")}
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-ink-primary">
{displayName ?? "Mi Perfil"}
</p>
<div className="mt-0.5 inline-flex items-center gap-1.5 text-sm text-ink-secondary">
<OrcidLogo size={14} />
<span className="font-mono">{userOrcidId ?? "—"}</span>
</div>
<p className="mt-1 text-xs text-ink-tertiary">
Verás publicaciones nuevas marcadas en el dashboard
</p>
</div>
</Link>
) : (
<>
<button
type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-6 py-4 text-[16px] font-semibold tracking-wide text-orcid-green-dark shadow-sm transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<p className="mt-2.5 text-center text-sm text-ink-tertiary">
Actualizamos tus publicaciones automáticamente cada mes.
</p>
</>
)}
<div className="my-6 flex items-center gap-3">
@@ -272,10 +330,10 @@ export function LandingPage() {
</div>
{/* ── Right: group search ── */}
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<div className="flex flex-col rounded-2xl border border-surface-border/20 bg-surface-secondary p-8 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<UsersIcon size={18} className="text-brand-accent" />
<h2 className="text-[15px] font-semibold text-ink-primary">
<UsersIcon size={16} className="text-ink-tertiary" />
<h2 className="text-[14px] font-semibold text-ink-secondary">
Búsqueda grupal de investigadores
</h2>
</div>
@@ -283,55 +341,79 @@ export function LandingPage() {
Pega varios ORCID iDs separados por comas, espacios o saltos de
línea para buscar y comparar varios investigadores a la vez.
</p>
<textarea
rows={5}
placeholder={"0000-0002-1825-0097\n0000-0001-5000-0007\n0000-0003-4321-9876"}
value={groupInput}
onChange={(e) => {
setGroupInput(e.target.value);
if (groupError) setGroupError("");
}}
onKeyDown={handleGroupKeyDown}
className={`w-full flex-1 resize-none rounded-lg border px-3.5 py-3 font-mono text-[13px] text-ink-primary outline-none transition-colors ${
{/* ── Tag input area ── */}
<div
role="textbox"
aria-multiline="true"
aria-label="ORCID iDs"
onClick={() => groupInputRef.current?.focus()}
className={`flex min-h-[120px] cursor-text flex-wrap content-start gap-1.5 overflow-y-auto rounded-lg border px-3 py-2.5 transition-colors ${
groupError
? "border-border-danger"
: "border-surface-border-strong focus:border-brand-accent"
: "border-surface-border-strong focus-within:border-brand-accent"
}`}
/>
>
{groupTags.map((tag) => (
<span
key={tag}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#b8d4ea] bg-[#deeef9] py-0.5 pl-1.5 pr-1 font-mono text-[11.5px] font-medium text-[#1a4a6b] transition-colors hover:bg-[#cce3f4]"
>
<OrcidLogo size={12} />
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeGroupTag(tag); }}
aria-label={`Eliminar ${tag}`}
className="ml-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full text-[#1a4a6b]/50 transition-colors hover:bg-[#1a4a6b]/15 hover:text-[#1a4a6b]"
>
×
</button>
</span>
))}
<input
ref={groupInputRef}
type="text"
value={groupRawInput}
onChange={handleGroupRawChange}
onKeyDown={handleGroupTagKeyDown}
onPaste={handleGroupPaste}
placeholder={
groupTags.length === 0
? "Pega o escribe ORCID iDs separados por comas, espacios o saltos de línea"
: ""
}
className="min-w-[200px] flex-1 bg-transparent font-mono text-[13px] text-ink-primary outline-none placeholder:text-ink-tertiary/60"
/>
</div>
{groupError && (
<p className="mt-1 text-xs text-ink-danger">{groupError}</p>
)}
<button
type="button"
onClick={handleGroupSearch}
disabled={groupLoading || !groupInput.trim()}
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors ${
groupInput.trim()
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
disabled={groupLoading || groupTags.length === 0}
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
groupTags.length > 0
? "bg-brand-primary text-white hover:bg-brand-primary-hover"
: "border border-surface-border bg-surface-secondary text-ink-tertiary"
}`}
>
{groupLoading && <Spinner size={14} />}
<UsersIcon size={14} />
{groupLoading ? "Preparando..." : "Buscar investigadores"}
{groupLoading
? "Preparando..."
: groupTags.length > 1
? `Buscar ${groupTags.length} investigadores`
: "Buscar investigadores"}
</button>
</div>
</div>
{/* ── Info chips ── */}
<div className="mt-8 flex flex-wrap justify-center gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
<span
key={label}
className="rounded-full border border-surface-border/60 bg-surface-secondary px-3.5 py-1.5 text-xs text-ink-tertiary"
>
{label}
</span>
))}
</div>
</div>
</main>
<Footer />
</div>
);
}