diff --git a/.gitignore b/.gitignore
index e57a82f..1cc1173 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ 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 (
<>
-
-
-
-
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) => (
+ | 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()}
+
+
+ |
+ ))}
+
+
+
+ {filtered.length === 0 ? (
+
+ |
+ No se encontraron publicaciones con ese filtro.
+ |
+
+ ) : (
+ filtered.map((pub, i) => (
+
+ |
+ {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()}
+ />
+
+
+
+
+ );
+}
+
+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()],
})
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-