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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user