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