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
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+23 -114
View File
@@ -1,121 +1,30 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
function App() {
const [count, setCount] = useState(0)
import { LandingPage } from "./pages/LandingPage";
import { DashboardPage } from "./pages/DashboardPage";
/**
* App shell. Declares the top-level routes and mounts the global
* notification portal (sonner). Router itself lives in `main.jsx` so tests
* can wrap `<App />` with a `MemoryRouter` if needed.
*/
export default function App() {
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard/:orcid" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
<Toaster
position="top-right"
richColors
closeButton
theme="light"
toastOptions={{ duration: 4000 }}
/>
</>
)
);
}
export default App
@@ -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>
);
}
@@ -0,0 +1,40 @@
import { Link } from "react-router-dom";
import { ArrowLeftIcon, LayersIcon } from "../ui/Icons";
/**
* Institutional navy header used across all views.
*
* Variants:
* - `landing` → logo + full product name (centered brand title).
* - `dashboard`→ back button to `/` + discrete product label on the right.
*/
export function AppHeader({ variant = "landing" }) {
if (variant === "dashboard") {
return (
<header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white">
<Link
to="/"
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<ArrowLeftIcon />
Inicio
</Link>
<div className="flex-1" />
<span className="text-[13px] text-white/60">
Sistema ORCID · SWORD
</span>
</header>
);
}
return (
<header className="flex items-center gap-3 bg-brand-primary px-8 py-3.5">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white/15 text-white">
<LayersIcon />
</div>
<span className="text-sm font-medium tracking-wide text-white">
Sistema de Integración ORCID · SWORD
</span>
</header>
);
}
+22
View File
@@ -0,0 +1,22 @@
import {
DEFAULT_BADGE_CLASSES,
TYPE_BADGE_CLASSES,
TYPE_LABELS,
} from "../../utils/publicationTypes";
/**
* Pill-style badge that colour-codes a publication type (article, review, …).
* Falls back to the neutral palette for unknown types.
*/
export function Badge({ type }) {
const label = TYPE_LABELS[type] ?? type;
const classes = TYPE_BADGE_CLASSES[type] ?? DEFAULT_BADGE_CLASSES;
return (
<span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium tracking-wide ${classes}`}
>
{label}
</span>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Centralised collection of inline SVG icons used across the app. Keeping
* them here avoids pulling a full icon library for ~10 glyphs while still
* letting consumers style them via `className` (stroke inherits from
* `currentColor`).
*/
const base = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
};
export function DocumentIcon({ size = 36, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M9 12h6M9 16h6M9 8h2" />
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
<path d="M13 2v5a2 2 0 002 2h5" />
</svg>
);
}
export function LayersIcon({ size = 18, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M12 3L2 8l10 5 10-5-10-5zM2 13l10 5 10-5M2 18l10 5 10-5" />
</svg>
);
}
export function ArrowLeftIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
);
}
export function ClockIcon({ size = 13, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.5} className={className}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 3" />
</svg>
);
}
export function CheckIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M5 13l4 4L19 7" />
</svg>
);
}
export function RefreshIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
);
}
export function DownloadIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
}
export function ChevronDownIcon({ size = 12, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function SearchIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
);
}
export function AlertIcon({ size = 16, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Official ORCID iD glyph.
*/
export function OrcidLogo({ size = 18, className = "" }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} className={className} xmlns="http://www.w3.org/2000/svg" aria-label="ORCID iD" role="img">
<path d="M256,128c0,70.7-57.3,128-128,128C57.3,256,0,198.7,0,128C0,57.3,57.3,0,128,0C198.7,0,256,57.3,256,128z" fill="#a6ce39"/>
<path d="M86.3,186.2H70.9V79.1h15.4v48.4V186.2z" fill="#fff"/>
<path d="M108.9,79.1h41.6c39.6,0,57,28.3,57,53.6c0,27.5-21.5,53.6-56.8,53.6h-41.8V79.1z M124.3,172.4h24.5c34.9,0,42.9-26.5,42.9-39.7c0-21.5-13.7-39.7-43.7-39.7h-23.7V172.4z" fill="#fff"/>
<path d="M88.7,56.8c0,5.5-4.5,10.1-10.1,10.1c-5.6,0-10.1-4.6-10.1-10.1c0-5.6,4.5-10.1,10.1-10.1C84.2,46.7,88.7,51.3,88.7,56.8z" fill="#fff"/>
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Small inline spinner. Uses Tailwind's `animate-spin` utility, so no custom
* keyframes are required. Inherits colour from its parent via `currentColor`.
*/
export function Spinner({ size = 16, className = "" }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={`animate-spin ${className}`}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="40 20"
strokeLinecap="round"
/>
</svg>
);
}
+66 -101
View File
@@ -1,111 +1,76 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@import "tailwindcss";
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
/* ───────────────────────────────────────────────────────────────────────────
* Design tokens — Institutional palette (ORCID · UJA)
* ─────────────────────────────────────────────────────────────────────── */
@theme {
/* Brand (institutional navy) */
--color-brand-primary: #0B3D6B;
--color-brand-primary-hover: #0a345c;
--color-brand-accent: #185FA5;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* ORCID brand */
--color-orcid-green: #A6CE39;
--color-orcid-green-dark: #2A3D00;
--color-orcid-green-soft: #EAF3DE;
--color-orcid-green-border: #C0DD97;
--color-orcid-green-text: #3B6D11;
@media (max-width: 1024px) {
font-size: 16px;
}
/* Surfaces (clean neutrals) */
--color-surface-primary: #FFFFFF;
--color-surface-secondary: #F6F5F0;
--color-surface-tertiary: #FAF9F5;
--color-surface-border: #E4E2D8;
--color-surface-border-strong: #CDCAB9;
/* Text */
--color-ink-primary: #1F1F1C;
--color-ink-secondary: #55534B;
--color-ink-tertiary: #8B887C;
--color-ink-danger: #B42318;
--color-border-danger: #F97066;
/* Type colours (publication badges) */
--color-tag-article-bg: #EBF5FF;
--color-tag-article-text: #1A5FA8;
--color-tag-article-border: #B5D4F4;
--color-tag-review-bg: #F0FFF4;
--color-tag-review-text: #1A6B3A;
--color-tag-review-border: #9FE1CB;
--color-tag-conference-bg: #FFF8E6;
--color-tag-conference-text: #7A4A00;
--color-tag-conference-border: #FAC775;
--color-tag-book-bg: #F5F0FF;
--color-tag-book-text: #4B30A8;
--color-tag-book-border: #C5BCEE;
--color-tag-dataset-bg: #FFF0F5;
--color-tag-dataset-text: #8B2252;
--color-tag-dataset-border: #F4C0D1;
--color-tag-default-bg: #F1EFE8;
--color-tag-default-text: #5F5E5A;
--color-tag-default-border: #D3D1C7;
/* Fonts */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
font-family: var(--font-sans);
color: var(--color-ink-primary);
background: var(--color-surface-tertiary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+11 -7
View File
@@ -1,10 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById('root')).render(
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
);
+182
View File
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from "react";
import { useParams, Navigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { ResearcherCard } from "../components/dashboard/ResearcherCard";
import { StatsRow } from "../components/dashboard/StatsRow";
import { PublicationsTable } from "../components/dashboard/PublicationsTable";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { SyncButton } from "../components/dashboard/SyncButton";
import {
downloadExport,
getExportUrl,
getPublications,
syncResearcher,
validateOrcid,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
const SUCCESS_FLASH_MS = 3000;
/**
* Researcher detail page. Owns:
* - Initial researcher lookup (validate + publications fetch on mount).
* - Sync workflow (POST + refresh + success toast).
* - Export workflow (download blob + success/error toast).
*/
export function DashboardPage() {
const { orcid } = useParams();
const [researcher, setResearcher] = useState(null);
const [publications, setPublications] = useState([]);
const [pubsLoading, setPubsLoading] = useState(true);
const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [exportingFormat, setExportingFormat] = useState(null);
const loadResearcher = useCallback(
async (signal) => {
try {
const data = await validateOrcid(orcid, { signal });
if (!signal?.aborted) setResearcher(data);
} catch (err) {
if (signal?.aborted) return;
toast.error("No se pudo cargar el investigador", {
description: err?.message ?? "Error desconocido.",
});
}
},
[orcid],
);
const loadPublications = useCallback(
async (signal) => {
setPubsLoading(true);
setPubsError(null);
try {
const data = await getPublications(orcid, { signal });
if (!signal?.aborted) setPublications(data);
} catch (err) {
if (signal?.aborted) return;
setPubsError(err);
} finally {
if (!signal?.aborted) setPubsLoading(false);
}
},
[orcid],
);
useEffect(() => {
if (!isValidOrcid(orcid)) return;
const ctrl = new AbortController();
loadResearcher(ctrl.signal);
loadPublications(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadResearcher, loadPublications]);
if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />;
}
async function handleSync() {
setSyncStatus("loading");
try {
const updated = await syncResearcher(orcid);
if (updated) setResearcher(updated);
await loadPublications();
setSyncStatus("success");
toast.success("Sincronización completada", {
description: "Las publicaciones se han actualizado desde ORCID.",
});
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) {
setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", {
description: err?.message ?? "Inténtalo de nuevo más tarde.",
});
}
}
async function handleExport(format) {
setExportingFormat(format);
try {
const { blob, url } = await downloadExport(orcid, format);
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-${orcid}.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: url ?? getExportUrl(orcid, format),
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setExportingFormat(null);
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" />
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton onClick={handleSync} status={syncStatus} />
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
/>
</>
}
/>
) : (
<ResearcherSkeleton />
)}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadPublications()}
/>
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
<span className="text-xs text-ink-tertiary">
Datos obtenidos vía ORCID Public API v3.0
</span>
<div className="flex gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => (
<span key={t} className="text-xs text-ink-tertiary">
{t}
</span>
))}
</div>
</footer>
</div>
</div>
);
}
function ResearcherSkeleton() {
return (
<div className="mb-5 h-[120px] animate-pulse rounded-2xl border border-surface-border/60 bg-surface-primary" />
);
}
export default DashboardPage;
+177
View File
@@ -0,0 +1,177 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { DocumentIcon } from "../components/ui/Icons";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import { Spinner } from "../components/ui/Spinner";
import { formatOrcidInput, isValidOrcid } from "../utils/orcid";
import { validateOrcid } from "../services/api";
/**
* Entry view: OAuth button + manual ORCID iD entry.
* Navigates to `/dashboard/:orcid` after a successful `validateOrcid` call.
*/
export function LandingPage() {
const navigate = useNavigate();
const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState("");
const [validating, setValidating] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
function handleOrcidChange(event) {
setOrcidInput(formatOrcidInput(event.target.value));
if (error) setError("");
}
async function handleValidate() {
if (!isValidOrcid(orcidInput)) {
setError(
"Formato inválido. El ORCID iD debe tener la estructura: 0000-0002-1234-5678",
);
return;
}
setValidating(true);
try {
await validateOrcid(orcidInput);
navigate(`/dashboard/${orcidInput}`);
} catch (err) {
toast.error("No se pudo validar el ORCID iD", {
description: err?.message ?? "Inténtalo de nuevo en unos segundos.",
});
} finally {
setValidating(false);
}
}
async function handleOrcidLogin() {
setOauthLoading(true);
try {
// Real implementation will redirect to ORCID OAuth (handled by backend).
// For now we emulate the flow locally with a known sample ORCID.
await new Promise((r) => setTimeout(r, 800));
navigate(`/dashboard/0000-0002-1234-5678`);
} catch (err) {
toast.error("No se pudo iniciar sesión con ORCID", {
description: err?.message ?? "Inténtalo de nuevo.",
});
} finally {
setOauthLoading(false);
}
}
function handleKeyDown(event) {
if (event.key === "Enter") handleValidate();
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" />
<main className="flex flex-1 items-center justify-center p-12 sm:p-6">
<div className="w-full max-w-[520px]">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-[72px] w-[72px] items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
<DocumentIcon size={36} className="text-white" />
</div>
<h1 className="mb-2 text-[28px] font-semibold tracking-tight text-ink-primary">
Repositorio Institucional
</h1>
<p className="text-[15px] leading-relaxed text-ink-secondary">
Conecta tu perfil ORCID y deposita tus publicaciones
automáticamente en el repositorio institucional vía protocolo
SWORD.
</p>
</div>
{/* Main card */}
<div className="rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<button
type="button"
onClick={handleOrcidLogin}
disabled={oauthLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{oauthLoading ? <Spinner size={17} /> : <OrcidLogo />}
{oauthLoading
? "Redirigiendo a ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-surface-border" />
<span className="text-xs tracking-widest text-ink-tertiary">
O INTRODUCE TU ORCID iD
</span>
<div className="h-px flex-1 bg-surface-border" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-ink-secondary">
ORCID iD
</label>
<div className="flex gap-2.5">
<div className="relative flex-1">
<input
type="text"
inputMode="numeric"
placeholder="0000-0002-1234-5678"
value={orcidInput}
onChange={handleOrcidChange}
onKeyDown={handleKeyDown}
maxLength={19}
className={`w-full rounded-lg py-2.5 pl-10 pr-3.5 font-mono text-[15px] tracking-wider text-ink-primary outline-none transition-colors ${
error
? "border border-border-danger"
: "border border-surface-border-strong focus:border-brand-accent"
}`}
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<OrcidLogo />
</span>
</div>
<button
type="button"
onClick={handleValidate}
disabled={validating || !orcidInput}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
orcidInput
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
>
{validating && <Spinner size={14} />}
{validating ? "Validando..." : "Buscar"}
</button>
</div>
{error && (
<p className="mt-2 text-xs leading-relaxed text-ink-danger">
{error}
</p>
)}
<p className="mt-2 text-xs text-ink-tertiary">
Formato: 16 dígitos separados con guiones (ej.
0000-0002-1234-5678)
</p>
</div>
</div>
{/* Info chips */}
<div className="mt-6 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 py-1 text-xs text-ink-tertiary"
>
{label}
</span>
))}
</div>
</div>
</main>
</div>
);
}
export default LandingPage;
+146
View File
@@ -0,0 +1,146 @@
/**
* Thin API client for the FastAPI backend.
*
* Every call returns parsed JSON (or a Blob for file downloads) and throws
* an `ApiError` on non-2xx responses so callers can decide how to surface it
* (toast, inline error, retry, etc.).
*
* The base URL is injected at build time via `VITE_API_URL`
* (see `.env.example`). During development, leaving it blank falls back to
* same-origin requests, which plays well with a Vite proxy.
*/
import {
mockExport,
mockGetPublications,
mockSyncResearcher,
mockValidateOrcid,
} from "./mocks";
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
/**
* When the backend is not available yet, set `VITE_USE_MOCKS=true` in your
* `.env.local` to route every call through `mocks.js`. In production this
* flag MUST be unset.
*/
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
export class ApiError extends Error {
constructor(message, { status, payload } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
async function request(path, { method = "GET", body, signal, headers } = {}) {
const url = `${BASE_URL}${path}`;
const init = {
method,
signal,
headers: {
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
};
if (body !== undefined) init.body = JSON.stringify(body);
let response;
try {
response = await fetch(url, init);
} catch (cause) {
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
});
}
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* response had no JSON body */
}
const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
throw new ApiError(typeof detail === "string" ? detail : "Error de API", {
status: response.status,
payload,
});
}
if (response.status === 204) return null;
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) return response.json();
return response;
}
/* ───────────────────────────── Endpoints ─────────────────────────────── */
/** POST /api/orcid/validate — validates an ORCID iD and returns the researcher. */
export function validateOrcid(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockValidateOrcid(orcidId);
return request("/api/orcid/validate", {
method: "POST",
body: { orcid_id: orcidId },
signal,
});
}
/** GET /api/researchers/{orcid}/publications — lists ORCID works. */
export function getPublications(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockGetPublications(orcidId);
return request(
`/api/researchers/${encodeURIComponent(orcidId)}/publications`,
{ signal },
);
}
/** POST /api/researchers/{orcid}/sync — triggers ORCID re-harvest. */
export function syncResearcher(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockSyncResearcher(orcidId);
return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
method: "POST",
signal,
});
}
/**
* Builds the public export URL so links/anchors can download files directly
* without going through `fetch`. Used by the export dropdown.
*/
export function getExportUrl(orcidId, format) {
return `${BASE_URL}/api/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
}
/**
* Downloads an export as a Blob (useful when we want to trigger a
* programmatic file download). Falls back to `ApiError` on failure.
*/
export async function downloadExport(orcidId, format, { signal } = {}) {
if (USE_MOCKS) {
await mockExport(format);
return { blob: null, url: getExportUrl(orcidId, format) };
}
const url = getExportUrl(orcidId, format);
let response;
try {
response = await fetch(url, { signal });
} catch (cause) {
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
});
}
if (!response.ok) {
throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, {
status: response.status,
});
}
const blob = await response.blob();
return { blob, url };
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Temporary in-memory fixtures used while the FastAPI backend is still being
* built by the backend team. Once the real endpoints are live, the
* `useMockApi` flag in `api.js` callers can be flipped off and this file
* can be deleted.
*/
export const MOCK_RESEARCHER = {
orcid_id: "0000-0002-1234-5678",
name: "Dra. María García",
affiliation: "Universidad Complutense de Madrid",
last_sync_at: "2026-04-15T10:30:00Z",
};
export const MOCK_PUBLICATIONS = [
{
id: "uuid-1",
title: "Machine Learning in Quantum Computing",
journal: "Nature Physics",
publication_year: 2025,
doi: "10.1038/s41567-025-xxxx",
type: "journal-article",
},
{
id: "uuid-2",
title:
"A review of SWORD protocol integrations in institutional repositories",
journal: "Journal of Digital Repositories",
publication_year: 2024,
doi: "10.1000/jdr.2024.12",
type: "review",
},
{
id: "uuid-3",
title: "Open Access Policies and Compliance in European Universities",
journal: "Scientometrics",
publication_year: 2024,
doi: "10.1007/s11192-024-04801-z",
type: "journal-article",
},
{
id: "uuid-4",
title: "Automated Metadata Harvesting via OAI-PMH",
journal: "Digital Libraries Conference Proceedings",
publication_year: 2023,
doi: "10.1145/3587-dl.2023.09",
type: "conference-paper",
},
{
id: "uuid-5",
title: "Interoperability Standards for Research Information Systems",
journal: "International Journal of Library Science",
publication_year: 2023,
doi: "10.1016/j.ijls.2023.03.011",
type: "journal-article",
},
];
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
export async function mockValidateOrcid(orcidId) {
await delay(700);
return { ...MOCK_RESEARCHER, orcid_id: orcidId };
}
export async function mockGetPublications(/* orcidId */) {
await delay(600);
return MOCK_PUBLICATIONS;
}
export async function mockSyncResearcher(orcidId) {
await delay(1800);
return {
...MOCK_RESEARCHER,
orcid_id: orcidId,
last_sync_at: new Date().toISOString(),
};
}
export async function mockExport(format) {
await delay(1200);
return { format };
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Locale-aware full date + time formatter (used in dashboard headers).
*/
export function formatDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("es-ES", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Builds researcher initials (max 2 chars) from a full name.
*/
export function getInitials(name = "") {
return name
.trim()
.split(/\s+/)
.map((w) => w[0] ?? "")
.slice(0, 2)
.join("")
.toUpperCase();
}
+22
View File
@@ -0,0 +1,22 @@
/**
* ORCID iD regex (16 digits, hyphen every 4, last char may be 'X' checksum).
* @see https://support.orcid.org/hc/en-us/articles/360006897674
*/
export const ORCID_REGEX = /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/;
/**
* Auto-formats a raw user input into the canonical ORCID layout
* `0000-0000-0000-000X`, keeping digits + final 'X' only.
*/
export function formatOrcidInput(raw) {
const digits = raw.replace(/[^0-9X]/gi, "").toUpperCase();
const parts = [];
for (let i = 0; i < digits.length && i < 16; i += 4) {
parts.push(digits.slice(i, i + 4));
}
return parts.join("-");
}
export function isValidOrcid(value) {
return ORCID_REGEX.test(value);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Publication type catalogue — labels + Tailwind class sets per variant.
* Keeping Tailwind classes (instead of inline styles) here lets the Badge
* component stay declarative while still covering every ORCID work-type.
*/
export const TYPE_LABELS = {
"journal-article": "Artículo",
review: "Revisión",
"conference-paper": "Conferencia",
"book-chapter": "Cap. Libro",
dataset: "Dataset",
};
export const TYPE_BADGE_CLASSES = {
"journal-article":
"bg-tag-article-bg text-tag-article-text border border-tag-article-border",
review:
"bg-tag-review-bg text-tag-review-text border border-tag-review-border",
"conference-paper":
"bg-tag-conference-bg text-tag-conference-text border border-tag-conference-border",
"book-chapter":
"bg-tag-book-bg text-tag-book-text border border-tag-book-border",
dataset:
"bg-tag-dataset-bg text-tag-dataset-text border border-tag-dataset-border",
};
export const DEFAULT_BADGE_CLASSES =
"bg-tag-default-bg text-tag-default-text border border-tag-default-border";