From a5c55d17d23767ef5afbaab5c3d1ea758e6c85a0 Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Mon, 27 Apr 2026 13:45:48 +0200 Subject: [PATCH] gitignore added --- .gitignore | 51 ++ frontend/.env.example | 7 + frontend/package-lock.json | 437 +++++++++++++++--- frontend/package.json | 6 +- frontend/src/App.css | 184 -------- frontend/src/App.jsx | 137 +----- .../components/dashboard/ExportDropdown.jsx | 95 ++++ .../dashboard/PublicationsTable.jsx | 219 +++++++++ .../components/dashboard/ResearcherCard.jsx | 48 ++ .../src/components/dashboard/StatsRow.jsx | 48 ++ .../src/components/dashboard/SyncButton.jsx | 39 ++ frontend/src/components/layout/AppHeader.jsx | 40 ++ frontend/src/components/ui/Badge.jsx | 22 + frontend/src/components/ui/Icons.jsx | 104 +++++ frontend/src/components/ui/OrcidLogo.jsx | 13 + frontend/src/components/ui/Spinner.jsx | 26 ++ frontend/src/index.css | 167 +++---- frontend/src/main.jsx | 18 +- frontend/src/pages/DashboardPage.jsx | 182 ++++++++ frontend/src/pages/LandingPage.jsx | 177 +++++++ frontend/src/services/api.js | 146 ++++++ frontend/src/services/mocks.js | 82 ++++ frontend/src/utils/formatters.js | 28 ++ frontend/src/utils/orcid.js | 22 + frontend/src/utils/publicationTypes.js | 28 ++ frontend/vite.config.js | 3 +- 26 files changed, 1869 insertions(+), 460 deletions(-) create mode 100644 .gitignore create mode 100644 frontend/.env.example delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/components/dashboard/ExportDropdown.jsx create mode 100644 frontend/src/components/dashboard/PublicationsTable.jsx create mode 100644 frontend/src/components/dashboard/ResearcherCard.jsx create mode 100644 frontend/src/components/dashboard/StatsRow.jsx create mode 100644 frontend/src/components/dashboard/SyncButton.jsx create mode 100644 frontend/src/components/layout/AppHeader.jsx create mode 100644 frontend/src/components/ui/Badge.jsx create mode 100644 frontend/src/components/ui/Icons.jsx create mode 100644 frontend/src/components/ui/OrcidLogo.jsx create mode 100644 frontend/src/components/ui/Spinner.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/LandingPage.jsx create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/mocks.js create mode 100644 frontend/src/utils/formatters.js create mode 100644 frontend/src/utils/orcid.js create mode 100644 frontend/src/utils/publicationTypes.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cc1173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# --- GLOBAL --- +.env +*.env +.env.* +!.env.example + +# --- PYTHON BACKEND --- +__pycache__/ +*.pyc +*.pyo +*.pyd +*.sqlite3 +*.db +*.log + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# FastAPI / Uvicorn +*.pid + +# --- NODE FRONTEND --- +node_modules/ +dist/ +build/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Vite cache +.vite/ +vite.config.ts.timestamp* +vite.config.js.timestamp* + +# --- DOCKER --- +# Avoid local volumes or generated files +docker-data/ +postgres_data/ +redis_data/ + +# --- OS / EDITOR --- +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +.cursorrules \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8b9eb8c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Base URL of the FastAPI backend (no trailing slash). +# Example for local dev: http://localhost:8000 +VITE_API_URL=http://localhost:8000 + +# Set to "true" while the backend is not yet implemented. +# All API calls will be served by src/services/mocks.js instead of `fetch`. +VITE_USE_MOCKS=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84ae7c7..1611bc9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,12 @@ "name": "orcid-system", "version": "0.0.0", "dependencies": { + "@tailwindcss/vite": "^4.2.3", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.1", + "sonner": "^2.0.7", + "tailwindcss": "^4.2.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -267,7 +271,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -279,7 +282,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -290,7 +292,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -510,7 +511,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -521,7 +521,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -532,7 +531,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -542,14 +540,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -560,7 +556,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -579,7 +574,6 @@ "version": "0.124.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -592,7 +586,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -609,7 +602,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,7 +618,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,7 +634,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -660,7 +650,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -677,7 +666,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -694,7 +682,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,7 +698,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,7 +714,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -745,7 +730,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -762,7 +746,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -779,7 +762,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -796,7 +778,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -815,7 +796,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -832,7 +812,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -849,11 +828,267 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.3.tgz", + "integrity": "sha512-dhXFXkW2dGvX4r/fi24gyXM0t1mFMrpykQjqrdA4SuavaMagm4SY1u5G2SCJwu1/0x/5RlZJ2VPjP3mKYQfCkA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.3.tgz", + "integrity": "sha512-YyhwSBcxHLS3CU2Mk3dXDuVm8/Ia0+XvfpT8s9YQoICppkUeoobB3hgyGMYbyQ4vn6VgWH9bdv5UnzhTz2NPTQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.3", + "@tailwindcss/oxide-darwin-arm64": "4.2.3", + "@tailwindcss/oxide-darwin-x64": "4.2.3", + "@tailwindcss/oxide-freebsd-x64": "4.2.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.3", + "@tailwindcss/oxide-linux-x64-musl": "4.2.3", + "@tailwindcss/oxide-wasm32-wasi": "4.2.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.3.tgz", + "integrity": "sha512-0Jmt1U/zPqeKp1+fvgI3qMqrV5b/EcFIbE5Dl5KdPl5Ri6e+95nlYNjfB3w8hJBeASI4IQSnIMz0tdVP1AVO4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.3.tgz", + "integrity": "sha512-c+/Etn/nghKBhd9fh2diG+3SEV1VTTPLlqH209yleofi28H87Cy6g1vsd3W3kf6r/dR5g4G4TEwHxo2Ydn6yFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.3.tgz", + "integrity": "sha512-1DrKKsdJTLuLWVdpaLZ0j/g9YbCZyP9xnwSqEvl3gY4ZHdXmX7TwVAHkoWUljOq7JK5zvzIGhrYmfE/2DJ5qaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.3.tgz", + "integrity": "sha512-HE6HHZYF8k7m80eVQ0RBvRGBdvvLvCpHiT38IRH9JSnBlt1T7gDzWoslWjmpXQFuqlRpzkCpbdKJa3NxWMfgVA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.3.tgz", + "integrity": "sha512-Li2wVd2kkKlKkTdpo7ujHSv6kxD1UYMvulAraikyvVf6AKNZ/VHbm8XoSNimZ+dF7SOFaDD2VAT64SK7WKcbjQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.3.tgz", + "integrity": "sha512-otIiImZaHj9MiDK02ItoWxIVcMTZVAX2F1c32bg9y7ecV0AnN5JHDZqIO8LxWsTuig1d+Bjg0cBWn4A9sGJO9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.3.tgz", + "integrity": "sha512-MmIA32rNEOrjh6wnevlR3OjjlCuwgZ4JMJo7Vrhk4Fk56Vxi7EeF7cekSKwvlrnfcn/ERC1LdcG3sFneU8WdoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.3.tgz", + "integrity": "sha512-BiCy1YV0IKO+xbD7gyZnENU4jdwDygeGQjncJoeIE5Kp4UqWHFsKUSJ3pp7vYURrqVzwJX2xD5gQeGnoXp4xPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.3.tgz", + "integrity": "sha512-venvyAu0AMKdr0c1Oz23IJJdZ72zSwKyHrLvqQV1cn49vPAJk3AuVtDkJ1ayk1sYI4M4j8Jv6ZGflpaP0QVSXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.3.tgz", + "integrity": "sha512-e3kColrZZCdtbwIOc07cNQ2zNf1sTPXTYLjjPlsgsaf+ttzAg/hOlDyEgHoOlBGxM88nPxeVaOGe9ThqVzPncg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.3.tgz", + "integrity": "sha512-qpwoUPzfu71cppxOtcz4LXMR1brljS13yOcAAnVHKIL++NJvSQKZBKlP39pVowd+G6Mq34YAbf4CUUYdLWL9gQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.3.tgz", + "integrity": "sha512-dTRIlLRC5lCRHqO5DLb+A18HCvS394axmzqfnRNLptKVw7WuckpUwo1Z87Yw74mesbeIhnQTA2SZbRcIfVlwxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.3.tgz", + "integrity": "sha512-pEvbC/NoOqxvqjy6IgelSakbzwin865CmOxJxmz3CSEbHJ2aF1B2183ALVasN0o6dOGhYfnVJOKKxVoyag+XeA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.3", + "@tailwindcss/oxide": "4.2.3", + "tailwindcss": "4.2.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1130,6 +1365,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1181,7 +1429,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1194,6 +1441,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1426,7 +1686,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1495,7 +1754,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1542,6 +1800,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1636,6 +1900,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1731,7 +2004,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1764,7 +2036,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1785,7 +2056,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1806,7 +2076,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1827,7 +2096,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1848,7 +2116,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1869,7 +2136,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1890,7 +2156,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1911,7 +2176,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1932,7 +2196,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1953,7 +2216,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1974,7 +2236,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2021,6 +2282,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2045,7 +2315,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2161,14 +2430,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2181,7 +2448,6 @@ "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2247,6 +2513,44 @@ "react": "^19.2.5" } }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2261,7 +2565,6 @@ "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.124.0", @@ -2295,7 +2598,6 @@ "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", - "dev": true, "license": "MIT" }, "node_modules/scheduler": { @@ -2314,6 +2616,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2337,11 +2645,20 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2373,11 +2690,29 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz", + "integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2394,7 +2729,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -2456,7 +2790,6 @@ "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/frontend/package.json b/frontend/package.json index 300014f..b0f14e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.2.3", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.1", + "sonner": "^2.0.7", + "tailwindcss": "^4.2.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -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); - } -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b2bf2e8..696d090 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 `` with a `MemoryRouter` if needed. + */ +export default function App() { return ( <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.jsx and save to test HMR -

-
- -
+ + } /> + } /> + } /> + -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
+ - ) + ); } - -export default App diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx new file mode 100644 index 0000000..cccf24b --- /dev/null +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -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 ( +
+ + + {open && ( +
+ {FORMATS.map(({ format, icon, label, desc }, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx new file mode 100644 index 0000000..dce58e4 --- /dev/null +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -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 ( + + + + ); +} + +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 ( +
+ {/* Toolbar */} +
+
+

+ Publicaciones +

+

+ {filtered.length} de {publications.length} resultados +

+
+
+ 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" + /> + + + +
+
+ + {/* Body */} +
+ {error ? ( + + ) : loading ? ( + + ) : ( + + + + {COLUMNS.map((col) => ( + + ))} + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((pub, i) => ( + + + + + + + + )) + )} + +
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" + > + + {col.label.toUpperCase()} + + +
+ No se encontraron publicaciones con ese filtro. +
+ {pub.title} + + {pub.journal} + + {pub.publication_year} + + + {pub.doi} + + + +
+ )} +
+
+ ); +} + +function LoadingState() { + return ( +
+ +

Cargando publicaciones…

+
+ ); +} + +function ErrorState({ error, onRetry }) { + return ( +
+ + + +
+

+ No se pudieron cargar las publicaciones +

+

+ {error?.message ?? "Error desconocido."} +

+
+ {onRetry && ( + + )} +
+ ); +} diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx new file mode 100644 index 0000000..88ef7ee --- /dev/null +++ b/frontend/src/components/dashboard/ResearcherCard.jsx @@ -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 ( +
+
+ {getInitials(researcher.name)} +
+ +
+

+ {researcher.name} +

+
+
+ + + {researcher.orcid_id} + +
+ Β· + + {researcher.affiliation} + +
+
+ + + Última sincronización: {formatDate(researcher.last_sync_at)} + +
+
+ + {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/StatsRow.jsx b/frontend/src/components/dashboard/StatsRow.jsx new file mode 100644 index 0000000..bef575a --- /dev/null +++ b/frontend/src/components/dashboard/StatsRow.jsx @@ -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 ( +
+ {stats.map(({ label, value, valueClass }) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/SyncButton.jsx b/frontend/src/components/dashboard/SyncButton.jsx new file mode 100644 index 0000000..70a6469 --- /dev/null +++ b/frontend/src/components/dashboard/SyncButton.jsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx new file mode 100644 index 0000000..acac7ff --- /dev/null +++ b/frontend/src/components/layout/AppHeader.jsx @@ -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 ( +
+ + + Inicio + +
+ + Sistema ORCID Β· SWORD + +
+ ); + } + + return ( +
+
+ +
+ + Sistema de IntegraciΓ³n ORCID Β· SWORD + +
+ ); +} diff --git a/frontend/src/components/ui/Badge.jsx b/frontend/src/components/ui/Badge.jsx new file mode 100644 index 0000000..c3c854f --- /dev/null +++ b/frontend/src/components/ui/Badge.jsx @@ -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 ( + + {label} + + ); +} diff --git a/frontend/src/components/ui/Icons.jsx b/frontend/src/components/ui/Icons.jsx new file mode 100644 index 0000000..67ec57b --- /dev/null +++ b/frontend/src/components/ui/Icons.jsx @@ -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 ( + + + + + + ); +} + +export function LayersIcon({ size = 18, className = "" }) { + return ( + + + + ); +} + +export function ArrowLeftIcon({ size = 14, className = "" }) { + return ( + + + + ); +} + +export function ClockIcon({ size = 13, className = "" }) { + return ( + + + + + ); +} + +export function CheckIcon({ size = 15, className = "" }) { + return ( + + + + ); +} + +export function RefreshIcon({ size = 15, className = "" }) { + return ( + + + + + ); +} + +export function DownloadIcon({ size = 15, className = "" }) { + return ( + + + + ); +} + +export function ChevronDownIcon({ size = 12, className = "" }) { + return ( + + + + ); +} + +export function SearchIcon({ size = 14, className = "" }) { + return ( + + + + + ); +} + +export function AlertIcon({ size = 16, className = "" }) { + return ( + + + + + ); +} diff --git a/frontend/src/components/ui/OrcidLogo.jsx b/frontend/src/components/ui/OrcidLogo.jsx new file mode 100644 index 0000000..6d44f66 --- /dev/null +++ b/frontend/src/components/ui/OrcidLogo.jsx @@ -0,0 +1,13 @@ +/** + * Official ORCID iD glyph. + */ +export function OrcidLogo({ size = 18, className = "" }) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/ui/Spinner.jsx b/frontend/src/components/ui/Spinner.jsx new file mode 100644 index 0000000..01c7328 --- /dev/null +++ b/frontend/src/components/ui/Spinner.jsx @@ -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 ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 2c84af0..32818c8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b9a1a6d..d9e87bc 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - + + + , -) +); diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..fa8d3a5 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ; + } + + 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 ( +
+ + +
+ {researcher ? ( + + + + + } + /> + ) : ( + + )} + + + + loadPublications()} + /> + +
+ + Datos obtenidos vΓ­a ORCID Public API v3.0 + +
+ {["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => ( + + {t} + + ))} +
+
+
+
+ ); +} + +function ResearcherSkeleton() { + return ( +
+ ); +} + +export default DashboardPage; diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx new file mode 100644 index 0000000..62dfdac --- /dev/null +++ b/frontend/src/pages/LandingPage.jsx @@ -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 ( +
+ + +
+
+
+
+ +
+

+ Repositorio Institucional +

+

+ Conecta tu perfil ORCID y deposita tus publicaciones + automΓ‘ticamente en el repositorio institucional vΓ­a protocolo + SWORD. +

+
+ + {/* Main card */} +
+ + +
+
+ + O INTRODUCE TU ORCID iD + +
+
+ +
+ +
+
+ + + + +
+ +
+ {error && ( +

+ {error} +

+ )} +

+ Formato: 16 dΓ­gitos separados con guiones (ej. + 0000-0002-1234-5678) +

+
+
+ + {/* Info chips */} +
+ {["ORCID OAuth 2.0", "SWORD v2", "DSpace Β· EPrints"].map((label) => ( + + {label} + + ))} +
+
+
+
+ ); +} + +export default LandingPage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..7fcd1f0 --- /dev/null +++ b/frontend/src/services/api.js @@ -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 }; +} diff --git a/frontend/src/services/mocks.js b/frontend/src/services/mocks.js new file mode 100644 index 0000000..85bc092 --- /dev/null +++ b/frontend/src/services/mocks.js @@ -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 }; +} diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js new file mode 100644 index 0000000..1092d88 --- /dev/null +++ b/frontend/src/utils/formatters.js @@ -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(); +} diff --git a/frontend/src/utils/orcid.js b/frontend/src/utils/orcid.js new file mode 100644 index 0000000..9550008 --- /dev/null +++ b/frontend/src/utils/orcid.js @@ -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); +} diff --git a/frontend/src/utils/publicationTypes.js b/frontend/src/utils/publicationTypes.js new file mode 100644 index 0000000..4646c19 --- /dev/null +++ b/frontend/src/utils/publicationTypes.js @@ -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"; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8b0f57b..c4069b7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], })