Merge branch 'style/responsive-design' into 'main'

feat(ui): mejorar estilos y estructura de componentes en el dashboard

See merge request fjmimbre/orcid_system!3
This commit is contained in:
alexis
2026-06-02 10:44:56 +00:00
9 changed files with 346 additions and 217 deletions
@@ -49,19 +49,20 @@ export function ExportDropdown({
} }
return ( return (
<div className="flex flex-wrap items-center justify-end gap-2 sm:flex-nowrap"> <div className="mx-auto flex w-full max-w-[440px] flex-col items-stretch gap-2 sm:mx-0 sm:w-auto sm:max-w-none sm:flex-row sm:items-center sm:justify-end">
<SwordProfileSelect <SwordProfileSelect
id="dashboard-export-destination" id="dashboard-export-destination"
value={exportDestination} value={exportDestination}
onChange={onExportDestinationChange} onChange={onExportDestinationChange}
includeZip includeZip
className="w-full"
/> />
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={isBusy || nothingToDownload} disabled={isBusy || nothingToDownload}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70" className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto"
> >
{isBusy ? ( {isBusy ? (
<Spinner size={15} /> <Spinner size={15} />
@@ -258,20 +258,21 @@ export function PublicationsTable({
)} )}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center">
<button <button
type="button" type="button"
onClick={() => setFiltersOpen((o) => !o)} onClick={() => setFiltersOpen((o) => !o)}
aria-expanded={filtersOpen} aria-expanded={filtersOpen}
aria-controls="pubs-advanced-filters" aria-controls="pubs-advanced-filters"
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors ${ className={`order-2 inline-flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors sm:order-1 sm:w-auto sm:justify-start ${
filtersOpen || hasYearFilter filtersOpen || hasYearFilter
? "border-brand-accent/50 bg-brand-accent/10 text-brand-accent" ? "border-brand-accent/50 bg-brand-accent/10 text-brand-accent"
: "border-surface-border-strong bg-surface-secondary text-ink-secondary hover:bg-surface-primary" : "border-surface-border-strong bg-surface-secondary text-ink-secondary hover:bg-surface-primary"
}`} }`}
> >
<FilterIcon /> <FilterIcon />
Filtros <span className="sm:hidden">Filtros</span>
<span className="hidden sm:inline">Filtros avanzados</span>
{hasYearFilter && ( {hasYearFilter && (
<span <span
className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-brand-accent" className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-brand-accent"
@@ -282,7 +283,7 @@ export function PublicationsTable({
className={`transition-transform ${filtersOpen ? "rotate-180" : ""}`} className={`transition-transform ${filtersOpen ? "rotate-180" : ""}`}
/> />
</button> </button>
<div className="relative"> <div className="relative order-1 w-full sm:order-2 sm:w-auto">
<input <input
type="text" type="text"
placeholder="Filtrar publicaciones..." placeholder="Filtrar publicaciones..."
@@ -291,7 +292,7 @@ export function PublicationsTable({
setFilter(e.target.value); setFilter(e.target.value);
setPage(1); setPage(1);
}} }}
className="w-[220px] rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent" className="w-full rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent sm:w-[220px]"
/> />
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70"> <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
<SearchIcon /> <SearchIcon />
@@ -303,49 +304,80 @@ export function PublicationsTable({
{filtersOpen && ( {filtersOpen && (
<div <div
id="pubs-advanced-filters" id="pubs-advanced-filters"
className="flex flex-wrap items-end gap-4 border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3" className="border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3"
> >
<div className="flex flex-col gap-1"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<label <p className="text-xs font-medium text-ink-secondary">
htmlFor="year-from" Filtrar por año de publicación
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary" </p>
> {yearFilterSummary && (
Desde año <span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-2 py-0.5 text-[11px] font-medium text-brand-accent">
</label> {yearFilterSummary}
<CustomSelect </span>
id="year-from" )}
value={yearFrom}
onChange={handleYearFromChange}
disabled={availableYears.length === 0}
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
/>
</div> </div>
<div className="flex flex-col gap-1">
<label <div className="grid grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_auto_1fr_auto]">
htmlFor="year-to" <div className="flex min-w-[140px] flex-1 flex-col gap-1">
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary" <label
> htmlFor="year-from"
Hasta año className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
</label> >
<CustomSelect Desde
id="year-to" </label>
value={yearTo} <CustomSelect
onChange={handleYearToChange} id="year-from"
disabled={availableYears.length === 0} value={yearFrom}
options={availableYears.map((y) => ({ onChange={handleYearFromChange}
value: String(y), disabled={availableYears.length === 0}
label: String(y), options={availableYears.map((y) => ({
}))} value: String(y),
/> label: String(y),
}))}
className="w-full"
/>
</div>
<div className="hidden pb-2 text-center text-sm text-ink-tertiary sm:block">
</div>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<label
htmlFor="year-to"
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
>
Hasta
</label>
<CustomSelect
id="year-to"
value={yearTo}
onChange={handleYearToChange}
disabled={availableYears.length === 0}
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
className="w-full"
/>
</div>
{hasYearFilter && (
<button
type="button"
onClick={clearYearFilter}
className="rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-secondary transition-colors hover:bg-surface-secondary"
>
Limpiar
</button>
)}
</div> </div>
{hasYearFilter && ( {hasYearFilter && (
<button <button
type="button" type="button"
onClick={clearYearFilter} onClick={clearYearFilter}
className="mb-[2px] rounded-md px-2.5 py-1.5 text-xs font-medium text-ink-tertiary transition-colors hover:bg-surface-primary hover:text-ink-primary" className="sr-only"
> >
Limpiar rango Limpiar rango
</button> </button>
@@ -366,123 +398,217 @@ export function PublicationsTable({
) : loading ? ( ) : loading ? (
<LoadingState /> <LoadingState />
) : ( ) : (
<table className="w-full border-collapse"> <>
<thead> <div className="md:hidden">
<tr className="bg-surface-secondary">
<th
scope="col"
className="w-10 border-b border-surface-border/60 px-4 py-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<TriStateCheckbox
checked={pageSelectionStats.allChecked}
indeterminate={pageSelectionStats.anyChecked}
onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones de esta página"
/>
</th>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className={`select-none border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary${col.thClass ? ` ${col.thClass}` : ""}`}
>
<span className="flex cursor-pointer items-center">
{col.label.toUpperCase()}
<SortIcon
active={sortKey === col.key}
direction={sortDir}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <p className="p-8 text-center text-sm text-ink-tertiary">
<td No se encontraron publicaciones con los filtros aplicados.
colSpan={COLUMNS.length + 1} </p>
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con los filtros aplicados.
</td>
</tr>
) : ( ) : (
pageRows.map((pub, i) => { <>
const isSelected = selectedIds.has(pub.id); <div className="border-b border-surface-border/60 bg-surface-secondary px-4 py-2.5">
return ( <TriStateCheckbox
<tr checked={pageSelectionStats.allChecked}
key={pub.id} indeterminate={pageSelectionStats.anyChecked}
className={`transition-colors ${ onChange={toggleCurrentPage}
isSelected ariaLabel="Seleccionar todas las publicaciones de esta página"
? "bg-tag-article-bg/70 hover:bg-tag-article-bg" />
: "hover:bg-surface-secondary/70" </div>
} ${ <div>
i < pageRows.length - 1 {pageRows.map((pub, i) => {
? "border-b border-surface-border/60" const isSelected = selectedIds.has(pub.id);
: "" return (
}`} <article
key={pub.id}
className={`px-4 py-3.5 transition-colors ${
isSelected
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
: "hover:bg-surface-secondary/70"
} ${
i < pageRows.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<div className="mb-2 flex items-start gap-2.5">
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
{pub.title}
</p>
</div>
</div>
</div>
<div className="space-y-1.5 pl-6.5 text-[12px] text-ink-secondary">
<p>
<span className="font-medium text-ink-primary">Revista:</span>{" "}
{pub.journal || "—"}
</p>
<p>
<span className="font-medium text-ink-primary">Año:</span>{" "}
{pub.publication_year ?? "—"}
</p>
<p>
<span className="font-medium text-ink-primary">DOI:</span>{" "}
{pub.doi ? (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="break-all font-mono text-[11px] text-brand-accent hover:underline"
>
{pub.doi}
</a>
) : (
<span className="font-mono text-[11px] text-ink-tertiary">
</span>
)}
</p>
<div>
<Badge type={pub.type} />
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
<table className="hidden w-full border-collapse md:table">
<thead>
<tr className="bg-surface-secondary">
<th
scope="col"
className="w-10 border-b border-surface-border/60 px-4 py-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<TriStateCheckbox
checked={pageSelectionStats.allChecked}
indeterminate={pageSelectionStats.anyChecked}
onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones de esta página"
/>
</th>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className={`select-none border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary${col.thClass ? ` ${col.thClass}` : ""}`}
> >
<td <span className="flex cursor-pointer items-center">
className="w-10 cursor-pointer px-4 py-3.5" {col.label.toUpperCase()}
onClick={(e) => { <SortIcon
e.stopPropagation(); active={sortKey === col.key}
toggleRow(pub.id); direction={sortDir}
}}
>
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/> />
</td> </span>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary"> </th>
<span className="flex flex-wrap items-start gap-1.5"> ))}
{isAuthenticated && pub.downloaded_by_me === false && ( </tr>
<span </thead>
title="No descargada aún por ti" <tbody>
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent" {filtered.length === 0 ? (
<tr>
<td
colSpan={COLUMNS.length + 1}
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con los filtros aplicados.
</td>
</tr>
) : (
pageRows.map((pub, i) => {
const isSelected = selectedIds.has(pub.id);
return (
<tr
key={pub.id}
className={`transition-colors ${
isSelected
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
: "hover:bg-surface-secondary/70"
} ${
i < pageRows.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<td
className="w-10 cursor-pointer px-4 py-3.5"
onClick={(e) => {
e.stopPropagation();
toggleRow(pub.id);
}}
>
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/>
</td>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
<span className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
{pub.title}
</span>
</td>
<td className="px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal || "—"}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
{pub.publication_year ?? "—"}
</td>
<td className="px-4 py-3.5">
{pub.doi ? (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
> >
<SparkleIcon size={9} /> {pub.doi}
Nuevo </a>
) : (
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
</span> </span>
)} )}
{pub.title} </td>
</span> <td className="px-4 py-3.5">
</td> <Badge type={pub.type} />
<td className="px-4 py-3.5 text-[13px] text-ink-secondary"> </td>
{pub.journal || "—"} </tr>
</td> );
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary"> })
{pub.publication_year ?? "—"} )}
</td> </tbody>
<td className="px-4 py-3.5"> </table>
{pub.doi ? ( </>
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
</a>
) : (
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
</span>
)}
</td>
<td className="px-4 py-3.5">
<Badge type={pub.type} />
</td>
</tr>
);
})
)}
</tbody>
</table>
)} )}
</div> </div>
@@ -44,7 +44,7 @@ export function ResearcherCard({ researcher, actions = null }) {
</div> </div>
{actions && ( {actions && (
<div className="ml-auto flex shrink-0 flex-col items-end gap-2.5"> <div className="flex w-full flex-col items-stretch gap-2.5 md:ml-auto md:w-auto md:shrink-0 md:items-end">
{actions} {actions}
</div> </div>
)} )}
@@ -48,28 +48,32 @@ export function SwordProfileSelect({
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className={`flex items-center gap-2 text-sm ${className}`.trim()} className={`flex w-full flex-col items-start gap-1.5 text-sm sm:w-auto sm:flex-row sm:items-center sm:gap-2 ${className}`.trim()}
> >
<span className="whitespace-nowrap text-ink-tertiary">Destino:</span> <span className="w-full whitespace-nowrap text-center text-ink-tertiary sm:w-auto sm:text-left">
<div className="relative"> Destino:
</span>
<div className="relative w-full sm:w-auto sm:flex-none">
<button <button
type="button" type="button"
id={id} id={id}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="inline-flex min-w-44 items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2.5 text-sm font-medium text-ink-primary transition-colors hover:bg-surface-secondary" className="relative inline-flex w-full min-w-44 items-center justify-center rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2.5 pr-10 text-sm font-medium text-ink-primary transition-colors hover:bg-surface-secondary sm:w-auto"
> >
<ExportProfileIcon profile={selected.value} size={20} /> <span className="inline-flex min-w-0 items-center gap-2">
<span className="truncate">{selected.label}</span> <ExportProfileIcon profile={selected.value} size={20} />
<ChevronDownIcon className="ml-auto shrink-0" /> <span className="truncate text-center">{selected.label}</span>
</span>
<ChevronDownIcon className="pointer-events-none absolute right-3 top-1/2 shrink-0 -translate-y-1/2" />
</button> </button>
{open && ( {open && (
<div <div
role="listbox" role="listbox"
aria-labelledby={id} aria-labelledby={id}
className="absolute left-0 top-[calc(100%+6px)] z-50 min-w-80 overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg" className="absolute left-0 top-[calc(100%+6px)] z-50 w-full min-w-0 overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg sm:min-w-80"
> >
{options.map(({ value: optionValue, label, desc }, idx) => ( {options.map(({ value: optionValue, label, desc }, idx) => (
<button <button
@@ -91,7 +95,7 @@ export function SwordProfileSelect({
<div className="text-sm font-medium text-ink-primary"> <div className="text-sm font-medium text-ink-primary">
{label} {label}
</div> </div>
<div className="whitespace-nowrap text-xs text-ink-tertiary"> <div className="text-xs text-ink-tertiary sm:whitespace-nowrap">
{desc} {desc}
</div> </div>
</div> </div>
@@ -5,7 +5,7 @@ import { Spinner } from "../ui/Spinner";
* Primary action button on the dashboard. Swaps icon + colour scheme * Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash). * depending on the sync lifecycle (idle → loading → success flash).
*/ */
export function SyncButton({ onClick, status = "idle" }) { export function SyncButton({ onClick, status = "idle", className = "" }) {
const isLoading = status === "loading"; const isLoading = status === "loading";
const isSuccess = status === "success"; const isSuccess = status === "success";
@@ -20,7 +20,7 @@ export function SyncButton({ onClick, status = "idle" }) {
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={isLoading} disabled={isLoading}
className={`inline-flex items-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette}`} className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()}
> >
{isLoading ? ( {isLoading ? (
<Spinner size={15} /> <Spinner size={15} />
+9 -9
View File
@@ -6,11 +6,11 @@ export default function Footer() {
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Main row */} {/* Main row */}
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-7 lg:flex-row lg:items-start lg:justify-between">
{/* Brand */} {/* Brand */}
<div className="flex flex-col gap-2 lg:max-w-xs"> <div className="flex flex-col gap-2 text-center lg:max-w-xs lg:text-left">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center justify-center gap-2 lg:justify-start">
<span className="text-base font-extrabold tracking-tight text-ink-primary"> <span className="text-base font-extrabold tracking-tight text-ink-primary">
ORCID<span className="text-orcid-green">2</span>SWORD ORCID<span className="text-orcid-green">2</span>SWORD
</span> </span>
@@ -19,16 +19,16 @@ export default function Footer() {
</span> </span>
</div> </div>
<p className="text-sm leading-relaxed text-ink-secondary"> <p className="text-sm leading-relaxed text-ink-secondary">
Sincronización de publicaciones ORCID al repositorio institucional. Extracción y preparación de publicaciones ORCID para repositorios académicos.
</p> </p>
</div> </div>
{/* Compatible con */} {/* Compatible con */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-ink-tertiary"> <span className="text-center text-[10px] font-black uppercase tracking-[0.2em] text-ink-tertiary lg:text-left">
Compatible con Compatible con
</span> </span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5 lg:justify-start">
{technologies.map((tech) => ( {technologies.map((tech) => (
<span <span
key={tech} key={tech}
@@ -41,13 +41,13 @@ export default function Footer() {
</div> </div>
{/* Institutional links */} {/* Institutional links */}
<div className="flex flex-row gap-6 sm:gap-8"> <div className="grid grid-cols-2 gap-2.5 sm:gap-3">
{/* Universidad de Jaén */} {/* Universidad de Jaén */}
<a <a
href="https://www.ujaen.es/" href="https://www.ujaen.es/"
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2.5" className="group flex items-center justify-center gap-2.5 rounded-lg border border-surface-border bg-surface-secondary/30 px-3 py-2 transition-colors hover:bg-surface-secondary/60"
title="Ir a la web oficial de la Universidad de Jaén" title="Ir a la web oficial de la Universidad de Jaén"
> >
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-accent"> <div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-accent">
@@ -65,7 +65,7 @@ export default function Footer() {
<a <a
href="https://github.com/uja-dev-practices/orcid_system" href="https://github.com/uja-dev-practices/orcid_system"
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2.5" className="group flex items-center justify-center gap-2.5 rounded-lg border border-surface-border bg-surface-secondary/30 px-3 py-2 transition-colors hover:bg-surface-secondary/60"
title="Ver repositorio oficial" title="Ver repositorio oficial"
> >
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-primary"> <div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-primary">
+5 -1
View File
@@ -224,7 +224,11 @@ export function DashboardPage() {
researcher={researcher} researcher={researcher}
actions={ actions={
<> <>
<SyncButton onClick={handleSync} status={syncStatus} /> <SyncButton
onClick={handleSync}
status={syncStatus}
className="w-full sm:w-auto"
/>
<ExportDropdown <ExportDropdown
onExport={handleExport} onExport={handleExport}
exportingFormat={exportingFormat} exportingFormat={exportingFormat}
+35 -41
View File
@@ -14,8 +14,13 @@ import {
UsersIcon, UsersIcon,
} from "../components/ui/Icons"; } from "../components/ui/Icons";
import { downloadExport, searchResearchersBulk } from "../services/api"; import { downloadExport, searchResearchersBulk } from "../services/api";
import { DEFAULT_EXPORT_PROFILE, swordXmlFilename } from "../utils/exportProfiles"; import {
import { SwordProfileSelect } from "../components/dashboard/SwordProfileSelect"; DEFAULT_EXPORT_DESTINATION,
DEFAULT_EXPORT_PROFILE,
EXPORT_ZIP_DESTINATION,
swordXmlFilename,
} from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
/** /**
@@ -36,6 +41,9 @@ export function GroupResultsPage() {
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [globalExporting, setGlobalExporting] = useState(null); // format | null const [globalExporting, setGlobalExporting] = useState(null); // format | null
const [globalExportDestination, setGlobalExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE); const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
// Track per-researcher export state (format | null) // Track per-researcher export state (format | null)
@@ -102,6 +110,14 @@ export function GroupResultsPage() {
[results], [results],
); );
function handleGlobalExportDestinationChange(nextDestination) {
setGlobalExportDestination(nextDestination);
// Keep last XML profile for card-level exports.
if (nextDestination !== EXPORT_ZIP_DESTINATION) {
setSwordProfile(nextDestination);
}
}
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) { async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
const ids = isAuthenticated ? allNewIds : allIds; const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) { if (ids.length === 0) {
@@ -193,21 +209,19 @@ export function GroupResultsPage() {
} }
} }
const globalLabel = isAuthenticated
? allNewIds.length > 0
? `Descargar lo nuevo de todos (${allNewIds.length})`
: "Todo descargado"
: `Descargar todo (${allIds.length})`;
const globalDisabled =
Boolean(globalExporting) ||
(isAuthenticated ? allNewIds.length === 0 : allIds.length === 0);
return ( return (
<div className="flex min-h-screen flex-col bg-surface-tertiary"> <div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="group" /> <AppHeader variant="group" />
<main className="flex-1"> <main className="flex-1">
<div className="mx-auto w-full max-w-7xl px-4 py-7"> <div className="mx-auto w-full max-w-7xl px-4 py-7">
<Link
to="/"
className="mb-5 inline-flex items-center gap-1.5 text-sm text-ink-tertiary transition-colors hover:text-ink-primary"
>
<ArrowLeftIcon size={14} />
Volver al inicio
</Link>
{/* Page header */} {/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4"> <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -233,33 +247,15 @@ export function GroupResultsPage() {
{/* Global export buttons */} {/* Global export buttons */}
{!loading && results.length > 0 && ( {!loading && results.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2"> <ExportDropdown
<SwordProfileSelect onExport={handleGlobalExport}
id="group-sword-profile" exportingFormat={globalExporting}
value={swordProfile} selectedCount={0}
onChange={setSwordProfile} isAuthenticated={isAuthenticated}
/> newPublicationsCount={allNewIds.length}
{["xml", "zip"].map((fmt) => ( exportDestination={globalExportDestination}
<button onExportDestinationChange={handleGlobalExportDestinationChange}
key={fmt} />
type="button"
onClick={() => handleGlobalExport(fmt, swordProfile)}
disabled={globalDisabled}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{globalExporting === fmt ? (
<Spinner size={14} />
) : isAuthenticated && allNewIds.length > 0 ? (
<SparkleIcon size={13} className="text-brand-accent" />
) : (
<DownloadIcon size={14} />
)}
{globalExporting === fmt
? `Exportando ${fmt.toUpperCase()}...`
: `${fmt.toUpperCase()} · ${globalLabel}`}
</button>
))}
</div>
)} )}
</div> </div>
@@ -294,7 +290,6 @@ export function GroupResultsPage() {
swordProfile, swordProfile,
) )
} }
swordProfile={swordProfile}
/> />
))} ))}
</div> </div>
@@ -355,7 +350,6 @@ function ResearcherResultCard({
isAuthenticated, isAuthenticated,
exporting, exporting,
onExport, onExport,
swordProfile,
}) { }) {
const researcher = bundle.researcher ?? {}; const researcher = bundle.researcher ?? {};
const publications = bundle.publications ?? []; const publications = bundle.publications ?? [];
+1 -1
View File
@@ -221,7 +221,7 @@ export function LandingPage() {
Tus publicaciones, listas para depositar. Tus publicaciones, listas para depositar.
</h1> </h1>
<p className="mx-auto max-w-xl text-[16px] leading-relaxed text-ink-secondary"> <p className="mx-auto max-w-xl text-[16px] leading-relaxed text-ink-secondary">
Conecta tu ORCID y descárgalas en XML cuando quieras. Conecta tu ORCID y descárgalas cuando quieras.
</p> </p>
</div> </div>