Compare commits
10 Commits
9edf0306bb
...
e5d5d5d5c4
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d5d5d5c4 | |||
| f2e9b432a6 | |||
| 4b2126b780 | |||
| bc67cc798f | |||
| e08fa17b7b | |||
| 15eeee52d1 | |||
| 58f164b036 | |||
| 2f48dab109 | |||
| 4262520203 | |||
| d58e56aeb1 |
@@ -44,6 +44,9 @@ docker-data/
|
|||||||
postgres_data/
|
postgres_data/
|
||||||
redis_data/
|
redis_data/
|
||||||
|
|
||||||
|
# --- CI / DEPLOY ---
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
# --- ENVIRONMENT VARIABLES ---
|
# --- ENVIRONMENT VARIABLES ---
|
||||||
# Secret files shouldn't be committed
|
# Secret files shouldn't be committed
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ Core capabilities:
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> The stack is local-first with Docker, but includes production-oriented hardening (CORS policy, trusted hosts, security headers, rate limiting, etc.).
|
> The stack is local-first with Docker, but includes production-oriented hardening (CORS policy, trusted hosts, security headers, rate limiting, etc.).
|
||||||
|
|
||||||
|
<img width="1343" height="862" alt="image" src="https://github.com/user-attachments/assets/7e788d71-54b8-47fa-9586-e0e7ba575b92" />
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## . Tech Stack
|
## . Tech Stack
|
||||||
|
|||||||
@@ -64,10 +64,18 @@ def init_db():
|
|||||||
|
|
||||||
def _ensure_columns():
|
def _ensure_columns():
|
||||||
insp = inspect(engine)
|
insp = inspect(engine)
|
||||||
if "publications" in insp.get_table_names():
|
table_names = set(insp.get_table_names())
|
||||||
|
|
||||||
|
if "publications" in table_names:
|
||||||
cols = {c["name"] for c in insp.get_columns("publications")}
|
cols = {c["name"] for c in insp.get_columns("publications")}
|
||||||
if "downloaded" not in cols:
|
if "downloaded" not in cols:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
|
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Per-user download tracking (PublicationDownload model).
|
||||||
|
if "publication_downloads" not in table_names:
|
||||||
|
from app.db.models import PublicationDownload # noqa: F401
|
||||||
|
|
||||||
|
PublicationDownload.__table__.create(bind=engine, checkfirst=True)
|
||||||
|
|||||||
@@ -18,17 +18,23 @@ def require_export_access(
|
|||||||
api_key: str | None = Depends(api_key_header),
|
api_key: str | None = Depends(api_key_header),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
current: Researcher | None = Depends(get_optional_current_researcher),
|
||||||
) -> Researcher | None:
|
) -> Researcher | None:
|
||||||
if api_key is not None:
|
"""
|
||||||
if not is_valid_api_key(api_key):
|
Allow export when the proxy supplies a valid API key and/or the user
|
||||||
raise HTTPException(
|
sends a valid Bearer token. Prefer returning `current` when both are
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
present so per-user download tracking is recorded on export.
|
||||||
detail="Invalid API key",
|
"""
|
||||||
)
|
if api_key is not None and not is_valid_api_key(api_key):
|
||||||
return current
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
|
||||||
if current is not None:
|
if current is not None:
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
if api_key is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or missing API key",
|
detail="Invalid or missing API key",
|
||||||
|
|||||||
+29
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
@@ -13,6 +14,23 @@ import { AuthCallbackPage } from "./pages/AuthCallbackPage";
|
|||||||
* can wrap `<App />` with a `MemoryRouter` if needed.
|
* can wrap `<App />` with a `MemoryRouter` if needed.
|
||||||
*/
|
*/
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 640px)");
|
||||||
|
|
||||||
|
const updateViewport = () => {
|
||||||
|
setIsMobile(mediaQuery.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateViewport();
|
||||||
|
mediaQuery.addEventListener("change", updateViewport);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", updateViewport);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -24,20 +42,24 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="bottom-right"
|
position={isMobile ? "bottom-center" : "bottom-right"}
|
||||||
richColors
|
richColors
|
||||||
closeButton
|
closeButton
|
||||||
|
expand
|
||||||
theme="light"
|
theme="light"
|
||||||
|
visibleToasts={4}
|
||||||
|
offset={20}
|
||||||
|
mobileOffset={{ bottom: 12, left: 12, right: 12 }}
|
||||||
toastOptions={{ duration: 4000 }}
|
toastOptions={{ duration: 4000 }}
|
||||||
style={{
|
style={{
|
||||||
/* SUCCESS — ORCID corporate green */
|
/* SUCCESS — ORCID corporate green */
|
||||||
'--success-bg': '#EAF3DE',
|
"--success-bg": "#EAF3DE",
|
||||||
'--success-border': '#C0DD97',
|
"--success-border": "#C0DD97",
|
||||||
'--success-text': '#3B6D11',
|
"--success-text": "#3B6D11",
|
||||||
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
|
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
|
||||||
'--error-bg': '#F3DDDD',
|
"--error-bg": "#F3DDDD",
|
||||||
'--error-border': '#DD9797',
|
"--error-border": "#DD9797",
|
||||||
'--error-text': '#6E1111',
|
"--error-text": "#6E1111",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
export function ExportDropdown({
|
export function ExportDropdown({
|
||||||
onExport,
|
onExport,
|
||||||
exportingFormat = null,
|
exportingFormat = null,
|
||||||
|
disabled = false,
|
||||||
selectedCount = 0,
|
selectedCount = 0,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
newPublicationsCount = 0,
|
newPublicationsCount = 0,
|
||||||
@@ -25,9 +26,6 @@ export function ExportDropdown({
|
|||||||
const isBusy = Boolean(exportingFormat);
|
const isBusy = Boolean(exportingFormat);
|
||||||
const hasSelection = selectedCount > 0;
|
const hasSelection = selectedCount > 0;
|
||||||
|
|
||||||
const nothingToDownload =
|
|
||||||
isAuthenticated && !hasSelection && newPublicationsCount === 0;
|
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
const { format, profile } = resolveExportFromDestination(exportDestination);
|
const { format, profile } = resolveExportFromDestination(exportDestination);
|
||||||
onExport(format, profile);
|
onExport(format, profile);
|
||||||
@@ -61,7 +59,7 @@ export function ExportDropdown({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isBusy || nothingToDownload}
|
disabled={disabled || isBusy}
|
||||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto"
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70 sm:w-auto"
|
||||||
>
|
>
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export function PublicationsTable({
|
|||||||
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
|
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
|
||||||
>
|
>
|
||||||
<SparkleIcon size={9} />
|
<SparkleIcon size={9} />
|
||||||
Nuevo
|
NUEVO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
|
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
|
||||||
@@ -570,7 +570,7 @@ export function PublicationsTable({
|
|||||||
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
|
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
|
||||||
>
|
>
|
||||||
<SparkleIcon size={9} />
|
<SparkleIcon size={9} />
|
||||||
Nuevo
|
NUEVO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pub.title}
|
{pub.title}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { Spinner } from "../ui/Spinner";
|
|||||||
* Primary action button on the dashboard. Swaps icon + colour scheme
|
* Primary action button on the dashboard. Swaps icon + colour scheme
|
||||||
* depending on the sync lifecycle (idle → loading → success flash).
|
* depending on the sync lifecycle (idle → loading → success flash).
|
||||||
*/
|
*/
|
||||||
export function SyncButton({ onClick, status = "idle", className = "" }) {
|
export function SyncButton({
|
||||||
|
onClick,
|
||||||
|
status = "idle",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}) {
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const isSuccess = status === "success";
|
const isSuccess = status === "success";
|
||||||
|
const isDisabled = disabled || isLoading || isSuccess;
|
||||||
|
|
||||||
const palette = isSuccess
|
const palette = isSuccess
|
||||||
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
|
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
|
||||||
@@ -19,7 +25,7 @@ export function SyncButton({ onClick, status = "idle", className = "" }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={isLoading}
|
disabled={isDisabled}
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()}
|
className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -80,3 +80,94 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sonner toaster UX polish (desktop + mobile). */
|
||||||
|
[data-sonner-toaster] {
|
||||||
|
--normal-width: min(92vw, 560px);
|
||||||
|
--width: min(92vw, 560px);
|
||||||
|
--border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toaster][data-y-position="top"] {
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--color-surface-border-strong);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 34px rgba(16, 24, 40, 0.14),
|
||||||
|
0 3px 10px rgba(16, 24, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-title] {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-description] {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large hit-area + clear visual state for close button. */
|
||||||
|
[data-sonner-toast] [data-close-button] {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-surface-border-strong);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-ink-secondary);
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-close-button]:hover {
|
||||||
|
background: #f8f7f3;
|
||||||
|
color: var(--color-ink-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-close-button]:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-button] {
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
[data-sonner-toaster] {
|
||||||
|
--normal-width: min(90vw, 380px);
|
||||||
|
--width: min(90vw, 380px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] {
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-title] {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-description] {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 44x44 touch target for thumbs on mobile. */
|
||||||
|
[data-sonner-toast] [data-close-button] {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast] [data-button] {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,8 +21,18 @@ import {
|
|||||||
swordXmlFilename,
|
swordXmlFilename,
|
||||||
} from "../utils/exportProfiles";
|
} from "../utils/exportProfiles";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
markPublicationsAsDownloaded,
|
||||||
|
publicationsNeedDownloadFlags,
|
||||||
|
} from "../utils/downloadTracking";
|
||||||
|
|
||||||
const SUCCESS_FLASH_MS = 3000;
|
const SUCCESS_FLASH_MS = 3000;
|
||||||
|
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
|
||||||
|
const SYNC_COOLDOWN_MS = 5000;
|
||||||
|
const SYNC_TOAST_ID = "researcher-sync";
|
||||||
|
/** Minimum gap between export requests (protects backend + avoids toast spam). */
|
||||||
|
const EXPORT_COOLDOWN_MS = 5000;
|
||||||
|
const EXPORT_TOAST_ID = "researcher-export";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Researcher detail page. Owns:
|
* Researcher detail page. Owns:
|
||||||
@@ -42,6 +52,7 @@ export function DashboardPage() {
|
|||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const initialBundleRef = useRef(location.state?.bundle ?? null);
|
const initialBundleRef = useRef(location.state?.bundle ?? null);
|
||||||
|
const consumedInitialBundleRef = useRef(false);
|
||||||
|
|
||||||
const initialBundle = initialBundleRef.current;
|
const initialBundle = initialBundleRef.current;
|
||||||
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
|
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
|
||||||
@@ -52,7 +63,15 @@ export function DashboardPage() {
|
|||||||
const [pubsError, setPubsError] = useState(null);
|
const [pubsError, setPubsError] = useState(null);
|
||||||
|
|
||||||
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
||||||
|
const [syncCooldownActive, setSyncCooldownActive] = useState(false);
|
||||||
|
const syncInFlightRef = useRef(false);
|
||||||
|
const syncCooldownUntilRef = useRef(0);
|
||||||
|
const syncCooldownTimerRef = useRef(null);
|
||||||
const [exportingFormat, setExportingFormat] = useState(null);
|
const [exportingFormat, setExportingFormat] = useState(null);
|
||||||
|
const [exportCooldownActive, setExportCooldownActive] = useState(false);
|
||||||
|
const exportInFlightRef = useRef(false);
|
||||||
|
const exportCooldownUntilRef = useRef(0);
|
||||||
|
const exportCooldownTimerRef = useRef(null);
|
||||||
const [exportDestination, setExportDestination] = useState(
|
const [exportDestination, setExportDestination] = useState(
|
||||||
DEFAULT_EXPORT_DESTINATION,
|
DEFAULT_EXPORT_DESTINATION,
|
||||||
);
|
);
|
||||||
@@ -101,21 +120,80 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isValidOrcid(orcid)) return;
|
if (!isValidOrcid(orcid)) return;
|
||||||
if (initialBundleRef.current) {
|
|
||||||
|
const cachedBundle = initialBundleRef.current;
|
||||||
|
if (cachedBundle && !consumedInitialBundleRef.current) {
|
||||||
|
consumedInitialBundleRef.current = true;
|
||||||
initialBundleRef.current = null;
|
initialBundleRef.current = null;
|
||||||
return;
|
if (
|
||||||
|
!publicationsNeedDownloadFlags(
|
||||||
|
cachedBundle.publications,
|
||||||
|
isAuthenticated,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
loadBundle(ctrl.signal);
|
loadBundle(ctrl.signal);
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [orcid, loadBundle]);
|
}, [orcid, loadBundle, isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (syncCooldownTimerRef.current) {
|
||||||
|
clearTimeout(syncCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
if (exportCooldownTimerRef.current) {
|
||||||
|
clearTimeout(exportCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncDisabled = syncStatus !== "idle" || syncCooldownActive;
|
||||||
|
const exportDisabled = Boolean(exportingFormat) || exportCooldownActive;
|
||||||
|
|
||||||
|
function startSyncCooldown() {
|
||||||
|
syncCooldownUntilRef.current = Date.now() + SYNC_COOLDOWN_MS;
|
||||||
|
setSyncCooldownActive(true);
|
||||||
|
if (syncCooldownTimerRef.current) {
|
||||||
|
clearTimeout(syncCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
syncCooldownTimerRef.current = setTimeout(() => {
|
||||||
|
setSyncCooldownActive(false);
|
||||||
|
syncCooldownTimerRef.current = null;
|
||||||
|
}, SYNC_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startExportCooldown() {
|
||||||
|
exportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
|
||||||
|
setExportCooldownActive(true);
|
||||||
|
if (exportCooldownTimerRef.current) {
|
||||||
|
clearTimeout(exportCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
exportCooldownTimerRef.current = setTimeout(() => {
|
||||||
|
setExportCooldownActive(false);
|
||||||
|
exportCooldownTimerRef.current = null;
|
||||||
|
}, EXPORT_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isValidOrcid(orcid)) {
|
if (!isValidOrcid(orcid)) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSync() {
|
async function handleSync() {
|
||||||
|
if (
|
||||||
|
syncInFlightRef.current ||
|
||||||
|
syncStatus !== "idle" ||
|
||||||
|
Date.now() < syncCooldownUntilRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInFlightRef.current = true;
|
||||||
setSyncStatus("loading");
|
setSyncStatus("loading");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bundle = await syncResearcher(orcid);
|
const bundle = await syncResearcher(orcid);
|
||||||
setResearcher(bundle.researcher);
|
setResearcher(bundle.researcher);
|
||||||
@@ -132,6 +210,7 @@ export function DashboardPage() {
|
|||||||
const { newRecords, updatedRecords, totalRecords } = bundle;
|
const { newRecords, updatedRecords, totalRecords } = bundle;
|
||||||
const hasChanges = newRecords > 0 || updatedRecords > 0;
|
const hasChanges = newRecords > 0 || updatedRecords > 0;
|
||||||
toast.success("Sincronización completada", {
|
toast.success("Sincronización completada", {
|
||||||
|
id: SYNC_TOAST_ID,
|
||||||
description: hasChanges
|
description: hasChanges
|
||||||
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
|
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
|
||||||
: "Sin cambios desde la última sincronización.",
|
: "Sin cambios desde la última sincronización.",
|
||||||
@@ -140,12 +219,25 @@ export function DashboardPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSyncStatus("idle");
|
setSyncStatus("idle");
|
||||||
toast.error("Error al sincronizar con ORCID", {
|
toast.error("Error al sincronizar con ORCID", {
|
||||||
|
id: SYNC_TOAST_ID,
|
||||||
description: err?.message ?? "Inténtalo de nuevo más tarde.",
|
description: err?.message ?? "Inténtalo de nuevo más tarde.",
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
syncInFlightRef.current = false;
|
||||||
|
startSyncCooldown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
|
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
|
||||||
|
if (
|
||||||
|
exportInFlightRef.current ||
|
||||||
|
exportingFormat ||
|
||||||
|
Date.now() < exportCooldownUntilRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportInFlightRef.current = true;
|
||||||
setExportingFormat(format);
|
setExportingFormat(format);
|
||||||
try {
|
try {
|
||||||
let ids;
|
let ids;
|
||||||
@@ -153,15 +245,9 @@ export function DashboardPage() {
|
|||||||
// Manual selection takes priority
|
// Manual selection takes priority
|
||||||
ids = Array.from(selectedIds);
|
ids = Array.from(selectedIds);
|
||||||
} else if (isAuthenticated) {
|
} else if (isAuthenticated) {
|
||||||
// Authenticated → only download publications not yet downloaded by me
|
// Prefer undownloaded; if none left, allow re-downloading the full profile
|
||||||
ids = newPublicationIds;
|
ids =
|
||||||
if (ids.length === 0) {
|
newPublicationIds.length > 0 ? newPublicationIds : undefined;
|
||||||
toast.info("No hay publicaciones nuevas", {
|
|
||||||
description: "Ya has descargado todas las publicaciones de este investigador.",
|
|
||||||
});
|
|
||||||
setExportingFormat(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Anonymous → download everything
|
// Anonymous → download everything
|
||||||
ids = undefined;
|
ids = undefined;
|
||||||
@@ -190,19 +276,34 @@ export function DashboardPage() {
|
|||||||
if (selectedIds.size > 0) {
|
if (selectedIds.size > 0) {
|
||||||
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
|
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
|
||||||
} else if (isAuthenticated) {
|
} else if (isAuthenticated) {
|
||||||
scope = `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`;
|
scope =
|
||||||
|
newPublicationIds.length > 0
|
||||||
|
? `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`
|
||||||
|
: "todo el investigador";
|
||||||
} else {
|
} else {
|
||||||
scope = "todo el investigador";
|
scope = "todo el investigador";
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const downloadedIds =
|
||||||
|
ids?.length > 0 ? ids : publications.map((p) => p.id);
|
||||||
|
setPublications((prev) =>
|
||||||
|
markPublicationsAsDownloaded(prev, downloadedIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
|
id: EXPORT_TOAST_ID,
|
||||||
description: scope,
|
description: scope,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
||||||
|
id: EXPORT_TOAST_ID,
|
||||||
description: err?.message ?? "No se pudo generar el fichero.",
|
description: err?.message ?? "No se pudo generar el fichero.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setExportingFormat(null);
|
setExportingFormat(null);
|
||||||
|
exportInFlightRef.current = false;
|
||||||
|
startExportCooldown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +328,13 @@ export function DashboardPage() {
|
|||||||
<SyncButton
|
<SyncButton
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
status={syncStatus}
|
status={syncStatus}
|
||||||
|
disabled={syncDisabled}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
<ExportDropdown
|
<ExportDropdown
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
exportingFormat={exportingFormat}
|
exportingFormat={exportingFormat}
|
||||||
|
disabled={exportDisabled}
|
||||||
selectedCount={selectedIds.size}
|
selectedCount={selectedIds.size}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
newPublicationsCount={newPublicationIds.length}
|
newPublicationsCount={newPublicationIds.length}
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ import { downloadExport, searchResearchersBulk } from "../services/api";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_EXPORT_DESTINATION,
|
DEFAULT_EXPORT_DESTINATION,
|
||||||
DEFAULT_EXPORT_PROFILE,
|
DEFAULT_EXPORT_PROFILE,
|
||||||
EXPORT_ZIP_DESTINATION,
|
resolveExportFromDestination,
|
||||||
swordXmlFilename,
|
swordXmlFilename,
|
||||||
} from "../utils/exportProfiles";
|
} from "../utils/exportProfiles";
|
||||||
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
|
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { markGroupResultsAsDownloaded } from "../utils/downloadTracking";
|
||||||
|
|
||||||
|
const EXPORT_COOLDOWN_MS = 5000;
|
||||||
|
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group results view: shows one summary card per researcher, plus a global
|
* Group results view: shows one summary card per researcher, plus a global
|
||||||
@@ -44,11 +48,23 @@ export function GroupResultsPage() {
|
|||||||
const [globalExportDestination, setGlobalExportDestination] = useState(
|
const [globalExportDestination, setGlobalExportDestination] = useState(
|
||||||
DEFAULT_EXPORT_DESTINATION,
|
DEFAULT_EXPORT_DESTINATION,
|
||||||
);
|
);
|
||||||
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
|
|
||||||
|
|
||||||
// Track per-researcher export state (format | null)
|
// Track per-researcher export state (format | null)
|
||||||
const [cardExporting, setCardExporting] = useState({});
|
const [cardExporting, setCardExporting] = useState({});
|
||||||
|
|
||||||
|
const [globalExportCooldownActive, setGlobalExportCooldownActive] =
|
||||||
|
useState(false);
|
||||||
|
const globalExportInFlightRef = useRef(false);
|
||||||
|
const globalExportCooldownUntilRef = useRef(0);
|
||||||
|
const globalExportCooldownTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const [cardExportCooldownActive, setCardExportCooldownActive] = useState(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const cardExportInFlightRef = useRef(new Set());
|
||||||
|
const cardExportCooldownUntilRef = useRef({});
|
||||||
|
const cardExportCooldownTimerRef = useRef({});
|
||||||
|
|
||||||
const abortRef = useRef(null);
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,7 +109,55 @@ export function GroupResultsPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [orcidIds, navigate]);
|
}, [orcidIds, navigate, isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cardTimersObj = cardExportCooldownTimerRef.current;
|
||||||
|
return () => {
|
||||||
|
const globalTimer = globalExportCooldownTimerRef.current;
|
||||||
|
if (globalTimer) {
|
||||||
|
clearTimeout(globalTimer);
|
||||||
|
}
|
||||||
|
for (const t of Object.values(cardTimersObj)) {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function startGlobalExportCooldown() {
|
||||||
|
globalExportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
|
||||||
|
setGlobalExportCooldownActive(true);
|
||||||
|
|
||||||
|
if (globalExportCooldownTimerRef.current) {
|
||||||
|
clearTimeout(globalExportCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
globalExportCooldownTimerRef.current = setTimeout(() => {
|
||||||
|
setGlobalExportCooldownActive(false);
|
||||||
|
globalExportCooldownUntilRef.current = 0;
|
||||||
|
globalExportCooldownTimerRef.current = null;
|
||||||
|
}, EXPORT_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCardExportCooldown(orcidId) {
|
||||||
|
const until = Date.now() + EXPORT_COOLDOWN_MS;
|
||||||
|
cardExportCooldownUntilRef.current[orcidId] = until;
|
||||||
|
setCardExportCooldownActive((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[orcidId]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (cardExportCooldownTimerRef.current[orcidId]) {
|
||||||
|
clearTimeout(cardExportCooldownTimerRef.current[orcidId]);
|
||||||
|
}
|
||||||
|
cardExportCooldownTimerRef.current[orcidId] = setTimeout(() => {
|
||||||
|
setCardExportCooldownActive((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[orcidId]: false,
|
||||||
|
}));
|
||||||
|
cardExportCooldownUntilRef.current[orcidId] = 0;
|
||||||
|
cardExportCooldownTimerRef.current[orcidId] = null;
|
||||||
|
}, EXPORT_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// All new publication IDs across all loaded researchers
|
// All new publication IDs across all loaded researchers
|
||||||
const allNewIds = useMemo(() => {
|
const allNewIds = useMemo(() => {
|
||||||
@@ -110,25 +174,26 @@ export function GroupResultsPage() {
|
|||||||
[results],
|
[results],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleGlobalExportDestinationChange(nextDestination) {
|
|
||||||
setGlobalExportDestination(nextDestination);
|
|
||||||
// Keep last XML profile for card-level exports.
|
|
||||||
if (nextDestination !== EXPORT_ZIP_DESTINATION) {
|
|
||||||
setSwordProfile(nextDestination);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
|
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
|
||||||
const ids = isAuthenticated ? allNewIds : allIds;
|
if (
|
||||||
if (ids.length === 0) {
|
globalExportInFlightRef.current ||
|
||||||
toast.info(
|
Date.now() < globalExportCooldownUntilRef.current
|
||||||
isAuthenticated
|
) {
|
||||||
? "No hay publicaciones nuevas"
|
|
||||||
: "No hay publicaciones para exportar",
|
|
||||||
{ description: "No se encontraron publicaciones en los investigadores cargados." },
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ids =
|
||||||
|
isAuthenticated && allNewIds.length > 0 ? allNewIds : allIds;
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.info("No hay publicaciones para exportar", {
|
||||||
|
id: GLOBAL_EXPORT_TOAST_ID,
|
||||||
|
description:
|
||||||
|
"No se encontraron publicaciones en los investigadores cargados.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalExportInFlightRef.current = true;
|
||||||
setGlobalExporting(format);
|
setGlobalExporting(format);
|
||||||
try {
|
try {
|
||||||
// For bulk export we send all IDs together, passing a placeholder orcid
|
// For bulk export we send all IDs together, passing a placeholder orcid
|
||||||
@@ -150,15 +215,23 @@ export function GroupResultsPage() {
|
|||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
|
id: GLOBAL_EXPORT_TOAST_ID,
|
||||||
description: `${ids.length} publicaciones exportadas.`,
|
description: `${ids.length} publicaciones exportadas.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
||||||
|
id: GLOBAL_EXPORT_TOAST_ID,
|
||||||
description: err?.message ?? "No se pudo generar el fichero.",
|
description: err?.message ?? "No se pudo generar el fichero.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setGlobalExporting(null);
|
setGlobalExporting(null);
|
||||||
|
globalExportInFlightRef.current = false;
|
||||||
|
startGlobalExportCooldown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +242,23 @@ export function GroupResultsPage() {
|
|||||||
totalIds,
|
totalIds,
|
||||||
profile = DEFAULT_EXPORT_PROFILE,
|
profile = DEFAULT_EXPORT_PROFILE,
|
||||||
) {
|
) {
|
||||||
const ids = isAuthenticated ? newIds : totalIds;
|
if (cardExportInFlightRef.current.has(orcidId)) {
|
||||||
if (ids.length === 0) {
|
|
||||||
toast.info("No hay publicaciones para exportar");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const until = cardExportCooldownUntilRef.current[orcidId] ?? 0;
|
||||||
|
if (now < until) return;
|
||||||
|
|
||||||
|
const ids =
|
||||||
|
isAuthenticated && newIds.length > 0 ? newIds : totalIds;
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.info("No hay publicaciones para exportar", {
|
||||||
|
id: `group-export-card-${orcidId}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardExportInFlightRef.current.add(orcidId);
|
||||||
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
|
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
|
||||||
try {
|
try {
|
||||||
const { blob } = await downloadExport(orcidId, format, {
|
const { blob } = await downloadExport(orcidId, format, {
|
||||||
@@ -193,11 +278,17 @@ export function GroupResultsPage() {
|
|||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
|
id: `group-export-card-${orcidId}`,
|
||||||
description: `${ids.length} publicaciones de ${orcidId}.`,
|
description: `${ids.length} publicaciones de ${orcidId}.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
toast.error(`Error al exportar ${format.toUpperCase()}`, {
|
||||||
|
id: `group-export-card-${orcidId}`,
|
||||||
description: err?.message ?? "No se pudo generar el fichero.",
|
description: err?.message ?? "No se pudo generar el fichero.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -206,6 +297,8 @@ export function GroupResultsPage() {
|
|||||||
delete next[orcidId];
|
delete next[orcidId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
cardExportInFlightRef.current.delete(orcidId);
|
||||||
|
startCardExportCooldown(orcidId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +343,14 @@ export function GroupResultsPage() {
|
|||||||
<ExportDropdown
|
<ExportDropdown
|
||||||
onExport={handleGlobalExport}
|
onExport={handleGlobalExport}
|
||||||
exportingFormat={globalExporting}
|
exportingFormat={globalExporting}
|
||||||
|
disabled={
|
||||||
|
Boolean(globalExporting) || globalExportCooldownActive
|
||||||
|
}
|
||||||
selectedCount={0}
|
selectedCount={0}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
newPublicationsCount={allNewIds.length}
|
newPublicationsCount={allNewIds.length}
|
||||||
exportDestination={globalExportDestination}
|
exportDestination={globalExportDestination}
|
||||||
onExportDestinationChange={handleGlobalExportDestinationChange}
|
onExportDestinationChange={setGlobalExportDestination}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,15 +377,22 @@ export function GroupResultsPage() {
|
|||||||
bundle={bundle}
|
bundle={bundle}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
|
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
|
||||||
onExport={(fmt, newIds, totalIds) =>
|
exportCooldownActive={
|
||||||
|
cardExportCooldownActive[bundle.researcher?.orcid_id] ??
|
||||||
|
false
|
||||||
|
}
|
||||||
|
onExport={(newIds, totalIds) => {
|
||||||
|
const { format, profile } = resolveExportFromDestination(
|
||||||
|
globalExportDestination,
|
||||||
|
);
|
||||||
handleCardExport(
|
handleCardExport(
|
||||||
bundle.researcher?.orcid_id,
|
bundle.researcher?.orcid_id,
|
||||||
fmt,
|
format,
|
||||||
newIds,
|
newIds,
|
||||||
totalIds,
|
totalIds,
|
||||||
swordProfile,
|
profile ?? DEFAULT_EXPORT_PROFILE,
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -349,6 +452,7 @@ function ResearcherResultCard({
|
|||||||
bundle,
|
bundle,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
exporting,
|
exporting,
|
||||||
|
exportCooldownActive,
|
||||||
onExport,
|
onExport,
|
||||||
}) {
|
}) {
|
||||||
const researcher = bundle.researcher ?? {};
|
const researcher = bundle.researcher ?? {};
|
||||||
@@ -419,92 +523,61 @@ function ResearcherResultCard({
|
|||||||
>
|
>
|
||||||
Ver detalle
|
Ver detalle
|
||||||
</Link>
|
</Link>
|
||||||
<ExportFormatMenu
|
<CardExportButton
|
||||||
onExport={(fmt) => onExport(fmt, newIds, allPubIds)}
|
onClick={() => onExport(newIds, allPubIds)}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
hasNew={hasNew}
|
hasNew={hasNew}
|
||||||
newCount={newCount}
|
newCount={newCount}
|
||||||
totalCount={totalRecords}
|
totalCount={totalRecords}
|
||||||
|
exportCooldownActive={exportCooldownActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────── Inline export format picker ─────────────────── */
|
/* ─────────────────────── Per-card download button ────────────────────── */
|
||||||
|
|
||||||
function ExportFormatMenu({
|
function CardExportButton({
|
||||||
onExport,
|
onClick,
|
||||||
exporting,
|
exporting,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
hasNew,
|
hasNew,
|
||||||
newCount,
|
newCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
exportCooldownActive,
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isBusy = Boolean(exporting);
|
const isBusy = Boolean(exporting);
|
||||||
const disabled =
|
const disabled = isBusy || exportCooldownActive;
|
||||||
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
|
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
if (isBusy) {
|
if (isBusy) {
|
||||||
label = `Exportando ${exporting.toUpperCase()}...`;
|
label = `Descargando ${exporting.toUpperCase()}...`;
|
||||||
|
} else if (exportCooldownActive) {
|
||||||
|
label = "Espera un momento...";
|
||||||
} else if (isAuthenticated) {
|
} else if (isAuthenticated) {
|
||||||
label = hasNew ? `Nuevo (${newCount})` : "Descargado";
|
label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
|
||||||
} else {
|
} else {
|
||||||
label = `Todo (${totalCount})`;
|
label = `Descargar (${totalCount})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={onClick}
|
||||||
onClick={() => setOpen((o) => !o)}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
className="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60 sm:flex-none"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
>
|
||||||
>
|
{isBusy ? (
|
||||||
{isBusy ? (
|
<Spinner size={13} />
|
||||||
<Spinner size={13} />
|
) : hasNew ? (
|
||||||
) : hasNew ? (
|
<SparkleIcon size={11} className="text-brand-accent" />
|
||||||
<SparkleIcon size={11} className="text-brand-accent" />
|
) : (
|
||||||
) : (
|
<DownloadIcon size={13} />
|
||||||
<DownloadIcon size={13} />
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 top-[calc(100%+4px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
|
|
||||||
{["xml", "zip"].map((fmt, idx) => (
|
|
||||||
<button
|
|
||||||
key={fmt}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
onExport(fmt);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-secondary ${
|
|
||||||
idx === 0 ? "border-b border-surface-border/60" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DownloadIcon size={13} />
|
|
||||||
{fmt.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{label}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1038/s41567-025-xxxx",
|
doi: "10.1038/s41567-025-xxxx",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2025-09-01T10:00:00Z",
|
last_modified: "2025-09-01T10:00:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-2",
|
id: "uuid-2",
|
||||||
@@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1000/jdr.2024.12",
|
doi: "10.1000/jdr.2024.12",
|
||||||
type: "review",
|
type: "review",
|
||||||
last_modified: "2024-11-12T09:00:00Z",
|
last_modified: "2024-11-12T09:00:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-3",
|
id: "uuid-3",
|
||||||
@@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1007/s11192-024-04801-z",
|
doi: "10.1007/s11192-024-04801-z",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2024-06-20T15:30:00Z",
|
last_modified: "2024-06-20T15:30:00Z",
|
||||||
|
downloaded_by_me: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-4",
|
id: "uuid-4",
|
||||||
@@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1145/3587-dl.2023.09",
|
doi: "10.1145/3587-dl.2023.09",
|
||||||
type: "conference-paper",
|
type: "conference-paper",
|
||||||
last_modified: "2023-10-05T11:45:00Z",
|
last_modified: "2023-10-05T11:45:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-5",
|
id: "uuid-5",
|
||||||
@@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1016/j.ijls.2023.03.011",
|
doi: "10.1016/j.ijls.2023.03.011",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2023-04-18T08:15:00Z",
|
last_modified: "2023-04-18T08:15:00Z",
|
||||||
|
downloaded_by_me: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for per-user "new publication" (not yet downloaded) tracking.
|
||||||
|
* Backend sets `downloaded_by_me` when a Bearer token is present; these
|
||||||
|
* utilities keep the dashboard in sync after login and export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True when an authenticated user needs a refetch to obtain download flags. */
|
||||||
|
export function publicationsNeedDownloadFlags(publications, isAuthenticated) {
|
||||||
|
if (!isAuthenticated || !publications?.length) return false;
|
||||||
|
return publications.some((p) => p.downloaded_by_me == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the given publication IDs as downloaded in local state. */
|
||||||
|
export function markPublicationsAsDownloaded(publications, downloadedIds) {
|
||||||
|
if (!downloadedIds?.length || !publications?.length) return publications;
|
||||||
|
const ids = new Set(downloadedIds);
|
||||||
|
return publications.map((p) =>
|
||||||
|
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply download flags across group-search result bundles. */
|
||||||
|
export function markGroupResultsAsDownloaded(results, downloadedIds) {
|
||||||
|
if (!downloadedIds?.length || !results?.length) return results;
|
||||||
|
const ids = new Set(downloadedIds);
|
||||||
|
return results.map((bundle) => ({
|
||||||
|
...bundle,
|
||||||
|
publications: (bundle.publications ?? []).map((p) =>
|
||||||
|
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user