feat: first version of the interface, integrate Tailwind CSS and update routing

- Add Tailwind CSS dependencies and configure Vite to use Tailwind
- Implement routing with React Router for Landing and Dashboard pages
- Remove unused App.css file and refactor App component to utilize new structure
- Update global styles in index.css to incorporate Tailwind's utility classes
This commit is contained in:
Alexis
2026-04-23 09:49:38 +02:00
parent 4627d160e8
commit a07bd3146e
26 changed files with 1819 additions and 460 deletions
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronDownIcon,
DownloadIcon,
} from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
const FORMATS = [
{
format: "xml",
icon: "📄",
label: "SWORD XML",
desc: "Metadatos en formato Atom",
},
{
format: "zip",
icon: "📦",
label: "Paquete ZIP",
desc: "XML + ficheros adjuntos",
},
];
/**
* SWORD export dropdown. Delegates the actual download to `onExport(format)`
* so it can be wired up either to the real API or to a mock layer from the
* parent page.
*
* `exportingFormat` (optional) lets the parent keep the button in a loading
* state between clicks (e.g. while waiting for the backend blob).
*/
export function ExportDropdown({ onExport, exportingFormat = null }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
function handleClick(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exportingFormat);
function handlePick(format) {
setOpen(false);
onExport(format);
}
return (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={isBusy}
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"
>
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
{isBusy
? `Exportando ${exportingFormat.toUpperCase()}...`
: "Exportar SWORD"}
{!isBusy && <ChevronDownIcon />}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[210px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{FORMATS.map(({ format, icon, label, desc }, idx) => (
<button
key={format}
type="button"
onClick={() => handlePick(format)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-secondary ${
idx < FORMATS.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<span className="text-xl" aria-hidden>
{icon}
</span>
<div>
<div className="text-sm font-medium text-ink-primary">
{label}
</div>
<div className="text-xs text-ink-tertiary">{desc}</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,219 @@
import { useMemo, useState } from "react";
import { AlertIcon, SearchIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge";
const COLUMNS = [
{ key: "title", label: "Título" },
{ key: "journal", label: "Revista / Fuente" },
{ key: "publication_year", label: "Año" },
{ key: "doi", label: "DOI" },
{ key: "type", label: "Tipo" },
];
function SortIcon({ active, direction }) {
const path =
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={`ml-1 ${active ? "opacity-100" : "opacity-30"}`}
aria-hidden
>
<path d={path} fill="currentColor" />
</svg>
);
}
function sortPublications(rows, key, direction) {
const sorted = [...rows].sort((a, b) => {
const va = a[key];
const vb = b[key];
const cmp =
typeof va === "string" ? va.localeCompare(vb) : (va ?? 0) - (vb ?? 0);
return direction === "asc" ? cmp : -cmp;
});
return sorted;
}
/**
* Publications table. Owns only UI-state (filter + sort). Data, loading and
* error states are driven by the parent page so retries and toasts can be
* handled in one place.
*/
export function PublicationsTable({
publications,
loading = false,
error = null,
onRetry,
}) {
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year");
const [sortDir, setSortDir] = useState("desc");
const filtered = useMemo(() => {
const needle = filter.trim().toLowerCase();
const rows = needle
? publications.filter(
(p) =>
p.title.toLowerCase().includes(needle) ||
p.journal.toLowerCase().includes(needle) ||
String(p.publication_year).includes(needle),
)
: publications;
return sortPublications(rows, sortKey, sortDir);
}, [publications, filter, sortKey, sortDir]);
function toggleSort(key) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
}
return (
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-surface-border/60 px-5 py-4">
<div>
<h3 className="text-base font-medium text-ink-primary">
Publicaciones
</h3>
<p className="mt-0.5 text-xs text-ink-tertiary">
{filtered.length} de {publications.length} resultados
</p>
</div>
<div className="relative">
<input
type="text"
placeholder="Filtrar publicaciones..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
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"
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
<SearchIcon />
</span>
</div>
</div>
{/* Body */}
<div className="overflow-x-auto">
{error ? (
<ErrorState error={error} onRetry={onRetry} />
) : loading ? (
<LoadingState />
) : (
<table className="w-full min-w-[640px] border-collapse">
<thead>
<tr className="bg-surface-secondary">
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className="select-none whitespace-nowrap border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary"
>
<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 ? (
<tr>
<td
colSpan={COLUMNS.length}
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con ese filtro.
</td>
</tr>
) : (
filtered.map((pub, i) => (
<tr
key={pub.id}
className={`transition-colors hover:bg-surface-secondary/70 ${
i < filtered.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
{pub.title}
</td>
<td className="whitespace-nowrap 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">
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
</a>
</td>
<td className="px-4 py-3.5">
<Badge type={pub.type} />
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
</section>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
<Spinner size={22} />
<p className="text-sm">Cargando publicaciones</p>
</div>
);
}
function ErrorState({ error, onRetry }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<span className="text-ink-danger">
<AlertIcon size={28} />
</span>
<div>
<p className="text-sm font-medium text-ink-primary">
No se pudieron cargar las publicaciones
</p>
<p className="mt-1 text-xs text-ink-tertiary">
{error?.message ?? "Error desconocido."}
</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-primary-hover"
>
Reintentar
</button>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { ClockIcon } from "../ui/Icons";
import { OrcidLogo } from "../ui/OrcidLogo";
import { formatDate, getInitials } from "../../utils/formatters";
/**
* Header card with avatar + researcher identity + "last sync" timestamp.
* Accepts an optional `actions` slot so the page can inject the Sync /
* Export buttons without coupling this component to API logic.
*/
export function ResearcherCard({ researcher, actions = null }) {
return (
<section className="mb-5 flex flex-wrap items-start gap-5 rounded-2xl border border-surface-border/60 bg-surface-primary px-7 py-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-primary text-xl font-semibold text-white">
{getInitials(researcher.name)}
</div>
<div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{researcher.name}
</h2>
<div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5">
<OrcidLogo />
<span className="font-mono text-[13px] text-ink-secondary">
{researcher.orcid_id}
</span>
</div>
<span className="text-surface-border-strong">·</span>
<span className="text-[13px] text-ink-secondary">
{researcher.affiliation}
</span>
</div>
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
<ClockIcon />
<span className="text-xs">
Última sincronización: {formatDate(researcher.last_sync_at)}
</span>
</div>
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2.5">
{actions}
</div>
)}
</section>
);
}
@@ -0,0 +1,48 @@
/**
* Derives the summary stats (totals + per-type counts) from the raw
* publications list. Returns a Tailwind class per card so the accent colour
* matches the palette used by `Badge`.
*/
function buildStats(publications) {
const total = publications.length;
const count = (type) => publications.filter((p) => p.type === type).length;
return [
{ label: "Publicaciones", value: total, valueClass: "text-brand-primary" },
{
label: "Artículos",
value: count("journal-article"),
valueClass: "text-tag-article-text",
},
{
label: "Revisiones",
value: count("review"),
valueClass: "text-tag-review-text",
},
{
label: "Conferencias",
value: count("conference-paper"),
valueClass: "text-tag-conference-text",
},
];
}
export function StatsRow({ publications }) {
const stats = buildStats(publications);
return (
<section className="mb-5 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
{stats.map(({ label, value, valueClass }) => (
<div
key={label}
className="rounded-xl border border-surface-border/60 bg-surface-primary px-5 py-4"
>
<div className="mb-1.5 text-xs tracking-wide text-ink-secondary">
{label}
</div>
<div className={`text-[26px] font-semibold ${valueClass}`}>
{value}
</div>
</div>
))}
</section>
);
}
@@ -0,0 +1,39 @@
import { CheckIcon, RefreshIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
/**
* Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash).
*/
export function SyncButton({ onClick, status = "idle" }) {
const isLoading = status === "loading";
const isSuccess = status === "success";
const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
: isLoading
? "bg-surface-secondary text-ink-secondary border border-surface-border"
: "bg-brand-primary text-white border border-transparent hover:bg-brand-primary-hover";
return (
<button
type="button"
onClick={onClick}
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}`}
>
{isLoading ? (
<Spinner size={15} />
) : isSuccess ? (
<CheckIcon />
) : (
<RefreshIcon />
)}
{isLoading
? "Sincronizando..."
: isSuccess
? "Sincronizado"
: "Sincronizar ahora"}
</button>
);
}