gitignore added

This commit is contained in:
Mireya Cueto Garrido
2026-04-27 13:45:48 +02:00
parent 96f01c0126
commit a5c55d17d2
26 changed files with 1869 additions and 460 deletions
+7
View File
@@ -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
+385 -52
View File
@@ -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",
+5 -1
View File
@@ -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",
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+23 -114
View File
@@ -1,121 +1,30 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
function App() {
const [count, setCount] = useState(0)
import { LandingPage } from "./pages/LandingPage";
import { DashboardPage } from "./pages/DashboardPage";
/**
* App shell. Declares the top-level routes and mounts the global
* notification portal (sonner). Router itself lives in `main.jsx` so tests
* can wrap `<App />` with a `MemoryRouter` if needed.
*/
export default function App() {
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard/:orcid" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
<Toaster
position="top-right"
richColors
closeButton
theme="light"
toastOptions={{ duration: 4000 }}
/>
</>
)
);
}
export default App
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronDownIcon,
DownloadIcon,
} from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
const FORMATS = [
{
format: "xml",
icon: "📄",
label: "SWORD XML",
desc: "Metadatos en formato Atom",
},
{
format: "zip",
icon: "📦",
label: "Paquete ZIP",
desc: "XML + ficheros adjuntos",
},
];
/**
* SWORD export dropdown. Delegates the actual download to `onExport(format)`
* so it can be wired up either to the real API or to a mock layer from the
* parent page.
*
* `exportingFormat` (optional) lets the parent keep the button in a loading
* state between clicks (e.g. while waiting for the backend blob).
*/
export function ExportDropdown({ onExport, exportingFormat = null }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
function handleClick(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exportingFormat);
function handlePick(format) {
setOpen(false);
onExport(format);
}
return (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={isBusy}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70"
>
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
{isBusy
? `Exportando ${exportingFormat.toUpperCase()}...`
: "Exportar SWORD"}
{!isBusy && <ChevronDownIcon />}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[210px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{FORMATS.map(({ format, icon, label, desc }, idx) => (
<button
key={format}
type="button"
onClick={() => handlePick(format)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-secondary ${
idx < FORMATS.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<span className="text-xl" aria-hidden>
{icon}
</span>
<div>
<div className="text-sm font-medium text-ink-primary">
{label}
</div>
<div className="text-xs text-ink-tertiary">{desc}</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,219 @@
import { useMemo, useState } from "react";
import { AlertIcon, SearchIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge";
const COLUMNS = [
{ key: "title", label: "Título" },
{ key: "journal", label: "Revista / Fuente" },
{ key: "publication_year", label: "Año" },
{ key: "doi", label: "DOI" },
{ key: "type", label: "Tipo" },
];
function SortIcon({ active, direction }) {
const path =
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={`ml-1 ${active ? "opacity-100" : "opacity-30"}`}
aria-hidden
>
<path d={path} fill="currentColor" />
</svg>
);
}
function sortPublications(rows, key, direction) {
const sorted = [...rows].sort((a, b) => {
const va = a[key];
const vb = b[key];
const cmp =
typeof va === "string" ? va.localeCompare(vb) : (va ?? 0) - (vb ?? 0);
return direction === "asc" ? cmp : -cmp;
});
return sorted;
}
/**
* Publications table. Owns only UI-state (filter + sort). Data, loading and
* error states are driven by the parent page so retries and toasts can be
* handled in one place.
*/
export function PublicationsTable({
publications,
loading = false,
error = null,
onRetry,
}) {
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year");
const [sortDir, setSortDir] = useState("desc");
const filtered = useMemo(() => {
const needle = filter.trim().toLowerCase();
const rows = needle
? publications.filter(
(p) =>
p.title.toLowerCase().includes(needle) ||
p.journal.toLowerCase().includes(needle) ||
String(p.publication_year).includes(needle),
)
: publications;
return sortPublications(rows, sortKey, sortDir);
}, [publications, filter, sortKey, sortDir]);
function toggleSort(key) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
}
return (
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-surface-border/60 px-5 py-4">
<div>
<h3 className="text-base font-medium text-ink-primary">
Publicaciones
</h3>
<p className="mt-0.5 text-xs text-ink-tertiary">
{filtered.length} de {publications.length} resultados
</p>
</div>
<div className="relative">
<input
type="text"
placeholder="Filtrar publicaciones..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-[220px] rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent"
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
<SearchIcon />
</span>
</div>
</div>
{/* Body */}
<div className="overflow-x-auto">
{error ? (
<ErrorState error={error} onRetry={onRetry} />
) : loading ? (
<LoadingState />
) : (
<table className="w-full min-w-[640px] border-collapse">
<thead>
<tr className="bg-surface-secondary">
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className="select-none whitespace-nowrap border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary"
>
<span className="flex cursor-pointer items-center">
{col.label.toUpperCase()}
<SortIcon
active={sortKey === col.key}
direction={sortDir}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={COLUMNS.length}
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con ese filtro.
</td>
</tr>
) : (
filtered.map((pub, i) => (
<tr
key={pub.id}
className={`transition-colors hover:bg-surface-secondary/70 ${
i < filtered.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
{pub.title}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
{pub.publication_year}
</td>
<td className="px-4 py-3.5">
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
</a>
</td>
<td className="px-4 py-3.5">
<Badge type={pub.type} />
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
</section>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
<Spinner size={22} />
<p className="text-sm">Cargando publicaciones</p>
</div>
);
}
function ErrorState({ error, onRetry }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<span className="text-ink-danger">
<AlertIcon size={28} />
</span>
<div>
<p className="text-sm font-medium text-ink-primary">
No se pudieron cargar las publicaciones
</p>
<p className="mt-1 text-xs text-ink-tertiary">
{error?.message ?? "Error desconocido."}
</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-primary-hover"
>
Reintentar
</button>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { ClockIcon } from "../ui/Icons";
import { OrcidLogo } from "../ui/OrcidLogo";
import { formatDate, getInitials } from "../../utils/formatters";
/**
* Header card with avatar + researcher identity + "last sync" timestamp.
* Accepts an optional `actions` slot so the page can inject the Sync /
* Export buttons without coupling this component to API logic.
*/
export function ResearcherCard({ researcher, actions = null }) {
return (
<section className="mb-5 flex flex-wrap items-start gap-5 rounded-2xl border border-surface-border/60 bg-surface-primary px-7 py-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-primary text-xl font-semibold text-white">
{getInitials(researcher.name)}
</div>
<div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{researcher.name}
</h2>
<div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5">
<OrcidLogo />
<span className="font-mono text-[13px] text-ink-secondary">
{researcher.orcid_id}
</span>
</div>
<span className="text-surface-border-strong">·</span>
<span className="text-[13px] text-ink-secondary">
{researcher.affiliation}
</span>
</div>
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
<ClockIcon />
<span className="text-xs">
Última sincronización: {formatDate(researcher.last_sync_at)}
</span>
</div>
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2.5">
{actions}
</div>
)}
</section>
);
}
@@ -0,0 +1,48 @@
/**
* Derives the summary stats (totals + per-type counts) from the raw
* publications list. Returns a Tailwind class per card so the accent colour
* matches the palette used by `Badge`.
*/
function buildStats(publications) {
const total = publications.length;
const count = (type) => publications.filter((p) => p.type === type).length;
return [
{ label: "Publicaciones", value: total, valueClass: "text-brand-primary" },
{
label: "Artículos",
value: count("journal-article"),
valueClass: "text-tag-article-text",
},
{
label: "Revisiones",
value: count("review"),
valueClass: "text-tag-review-text",
},
{
label: "Conferencias",
value: count("conference-paper"),
valueClass: "text-tag-conference-text",
},
];
}
export function StatsRow({ publications }) {
const stats = buildStats(publications);
return (
<section className="mb-5 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
{stats.map(({ label, value, valueClass }) => (
<div
key={label}
className="rounded-xl border border-surface-border/60 bg-surface-primary px-5 py-4"
>
<div className="mb-1.5 text-xs tracking-wide text-ink-secondary">
{label}
</div>
<div className={`text-[26px] font-semibold ${valueClass}`}>
{value}
</div>
</div>
))}
</section>
);
}
@@ -0,0 +1,39 @@
import { CheckIcon, RefreshIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
/**
* Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash).
*/
export function SyncButton({ onClick, status = "idle" }) {
const isLoading = status === "loading";
const isSuccess = status === "success";
const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
: isLoading
? "bg-surface-secondary text-ink-secondary border border-surface-border"
: "bg-brand-primary text-white border border-transparent hover:bg-brand-primary-hover";
return (
<button
type="button"
onClick={onClick}
disabled={isLoading}
className={`inline-flex items-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette}`}
>
{isLoading ? (
<Spinner size={15} />
) : isSuccess ? (
<CheckIcon />
) : (
<RefreshIcon />
)}
{isLoading
? "Sincronizando..."
: isSuccess
? "Sincronizado"
: "Sincronizar ahora"}
</button>
);
}
@@ -0,0 +1,40 @@
import { Link } from "react-router-dom";
import { ArrowLeftIcon, LayersIcon } from "../ui/Icons";
/**
* Institutional navy header used across all views.
*
* Variants:
* - `landing` → logo + full product name (centered brand title).
* - `dashboard`→ back button to `/` + discrete product label on the right.
*/
export function AppHeader({ variant = "landing" }) {
if (variant === "dashboard") {
return (
<header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white">
<Link
to="/"
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<ArrowLeftIcon />
Inicio
</Link>
<div className="flex-1" />
<span className="text-[13px] text-white/60">
Sistema ORCID · SWORD
</span>
</header>
);
}
return (
<header className="flex items-center gap-3 bg-brand-primary px-8 py-3.5">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white/15 text-white">
<LayersIcon />
</div>
<span className="text-sm font-medium tracking-wide text-white">
Sistema de Integración ORCID · SWORD
</span>
</header>
);
}
+22
View File
@@ -0,0 +1,22 @@
import {
DEFAULT_BADGE_CLASSES,
TYPE_BADGE_CLASSES,
TYPE_LABELS,
} from "../../utils/publicationTypes";
/**
* Pill-style badge that colour-codes a publication type (article, review, …).
* Falls back to the neutral palette for unknown types.
*/
export function Badge({ type }) {
const label = TYPE_LABELS[type] ?? type;
const classes = TYPE_BADGE_CLASSES[type] ?? DEFAULT_BADGE_CLASSES;
return (
<span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium tracking-wide ${classes}`}
>
{label}
</span>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Centralised collection of inline SVG icons used across the app. Keeping
* them here avoids pulling a full icon library for ~10 glyphs while still
* letting consumers style them via `className` (stroke inherits from
* `currentColor`).
*/
const base = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
};
export function DocumentIcon({ size = 36, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M9 12h6M9 16h6M9 8h2" />
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
<path d="M13 2v5a2 2 0 002 2h5" />
</svg>
);
}
export function LayersIcon({ size = 18, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M12 3L2 8l10 5 10-5-10-5zM2 13l10 5 10-5M2 18l10 5 10-5" />
</svg>
);
}
export function ArrowLeftIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
);
}
export function ClockIcon({ size = 13, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.5} className={className}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 3" />
</svg>
);
}
export function CheckIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M5 13l4 4L19 7" />
</svg>
);
}
export function RefreshIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
);
}
export function DownloadIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
}
export function ChevronDownIcon({ size = 12, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function SearchIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
);
}
export function AlertIcon({ size = 16, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Official ORCID iD glyph.
*/
export function OrcidLogo({ size = 18, className = "" }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} className={className} xmlns="http://www.w3.org/2000/svg" aria-label="ORCID iD" role="img">
<path d="M256,128c0,70.7-57.3,128-128,128C57.3,256,0,198.7,0,128C0,57.3,57.3,0,128,0C198.7,0,256,57.3,256,128z" fill="#a6ce39"/>
<path d="M86.3,186.2H70.9V79.1h15.4v48.4V186.2z" fill="#fff"/>
<path d="M108.9,79.1h41.6c39.6,0,57,28.3,57,53.6c0,27.5-21.5,53.6-56.8,53.6h-41.8V79.1z M124.3,172.4h24.5c34.9,0,42.9-26.5,42.9-39.7c0-21.5-13.7-39.7-43.7-39.7h-23.7V172.4z" fill="#fff"/>
<path d="M88.7,56.8c0,5.5-4.5,10.1-10.1,10.1c-5.6,0-10.1-4.6-10.1-10.1c0-5.6,4.5-10.1,10.1-10.1C84.2,46.7,88.7,51.3,88.7,56.8z" fill="#fff"/>
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Small inline spinner. Uses Tailwind's `animate-spin` utility, so no custom
* keyframes are required. Inherits colour from its parent via `currentColor`.
*/
export function Spinner({ size = 16, className = "" }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={`animate-spin ${className}`}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="40 20"
strokeLinecap="round"
/>
</svg>
);
}
+66 -101
View File
@@ -1,111 +1,76 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@import "tailwindcss";
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
/* ───────────────────────────────────────────────────────────────────────────
* Design tokens — Institutional palette (ORCID · UJA)
* ─────────────────────────────────────────────────────────────────────── */
@theme {
/* Brand (institutional navy) */
--color-brand-primary: #0B3D6B;
--color-brand-primary-hover: #0a345c;
--color-brand-accent: #185FA5;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* ORCID brand */
--color-orcid-green: #A6CE39;
--color-orcid-green-dark: #2A3D00;
--color-orcid-green-soft: #EAF3DE;
--color-orcid-green-border: #C0DD97;
--color-orcid-green-text: #3B6D11;
@media (max-width: 1024px) {
font-size: 16px;
}
/* Surfaces (clean neutrals) */
--color-surface-primary: #FFFFFF;
--color-surface-secondary: #F6F5F0;
--color-surface-tertiary: #FAF9F5;
--color-surface-border: #E4E2D8;
--color-surface-border-strong: #CDCAB9;
/* Text */
--color-ink-primary: #1F1F1C;
--color-ink-secondary: #55534B;
--color-ink-tertiary: #8B887C;
--color-ink-danger: #B42318;
--color-border-danger: #F97066;
/* Type colours (publication badges) */
--color-tag-article-bg: #EBF5FF;
--color-tag-article-text: #1A5FA8;
--color-tag-article-border: #B5D4F4;
--color-tag-review-bg: #F0FFF4;
--color-tag-review-text: #1A6B3A;
--color-tag-review-border: #9FE1CB;
--color-tag-conference-bg: #FFF8E6;
--color-tag-conference-text: #7A4A00;
--color-tag-conference-border: #FAC775;
--color-tag-book-bg: #F5F0FF;
--color-tag-book-text: #4B30A8;
--color-tag-book-border: #C5BCEE;
--color-tag-dataset-bg: #FFF0F5;
--color-tag-dataset-text: #8B2252;
--color-tag-dataset-border: #F4C0D1;
--color-tag-default-bg: #F1EFE8;
--color-tag-default-text: #5F5E5A;
--color-tag-default-border: #D3D1C7;
/* Fonts */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
font-family: var(--font-sans);
color: var(--color-ink-primary);
background: var(--color-surface-tertiary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+11 -7
View File
@@ -1,10 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById('root')).render(
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
);
+182
View File
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from "react";
import { useParams, Navigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { ResearcherCard } from "../components/dashboard/ResearcherCard";
import { StatsRow } from "../components/dashboard/StatsRow";
import { PublicationsTable } from "../components/dashboard/PublicationsTable";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { SyncButton } from "../components/dashboard/SyncButton";
import {
downloadExport,
getExportUrl,
getPublications,
syncResearcher,
validateOrcid,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
const SUCCESS_FLASH_MS = 3000;
/**
* Researcher detail page. Owns:
* - Initial researcher lookup (validate + publications fetch on mount).
* - Sync workflow (POST + refresh + success toast).
* - Export workflow (download blob + success/error toast).
*/
export function DashboardPage() {
const { orcid } = useParams();
const [researcher, setResearcher] = useState(null);
const [publications, setPublications] = useState([]);
const [pubsLoading, setPubsLoading] = useState(true);
const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [exportingFormat, setExportingFormat] = useState(null);
const loadResearcher = useCallback(
async (signal) => {
try {
const data = await validateOrcid(orcid, { signal });
if (!signal?.aborted) setResearcher(data);
} catch (err) {
if (signal?.aborted) return;
toast.error("No se pudo cargar el investigador", {
description: err?.message ?? "Error desconocido.",
});
}
},
[orcid],
);
const loadPublications = useCallback(
async (signal) => {
setPubsLoading(true);
setPubsError(null);
try {
const data = await getPublications(orcid, { signal });
if (!signal?.aborted) setPublications(data);
} catch (err) {
if (signal?.aborted) return;
setPubsError(err);
} finally {
if (!signal?.aborted) setPubsLoading(false);
}
},
[orcid],
);
useEffect(() => {
if (!isValidOrcid(orcid)) return;
const ctrl = new AbortController();
loadResearcher(ctrl.signal);
loadPublications(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadResearcher, loadPublications]);
if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />;
}
async function handleSync() {
setSyncStatus("loading");
try {
const updated = await syncResearcher(orcid);
if (updated) setResearcher(updated);
await loadPublications();
setSyncStatus("success");
toast.success("Sincronización completada", {
description: "Las publicaciones se han actualizado desde ORCID.",
});
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) {
setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", {
description: err?.message ?? "Inténtalo de nuevo más tarde.",
});
}
}
async function handleExport(format) {
setExportingFormat(format);
try {
const { blob, url } = await downloadExport(orcid, format);
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-${orcid}.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: url ?? getExportUrl(orcid, format),
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setExportingFormat(null);
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" />
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton onClick={handleSync} status={syncStatus} />
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
/>
</>
}
/>
) : (
<ResearcherSkeleton />
)}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadPublications()}
/>
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
<span className="text-xs text-ink-tertiary">
Datos obtenidos vía ORCID Public API v3.0
</span>
<div className="flex gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => (
<span key={t} className="text-xs text-ink-tertiary">
{t}
</span>
))}
</div>
</footer>
</div>
</div>
);
}
function ResearcherSkeleton() {
return (
<div className="mb-5 h-[120px] animate-pulse rounded-2xl border border-surface-border/60 bg-surface-primary" />
);
}
export default DashboardPage;
+177
View File
@@ -0,0 +1,177 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { DocumentIcon } from "../components/ui/Icons";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import { Spinner } from "../components/ui/Spinner";
import { formatOrcidInput, isValidOrcid } from "../utils/orcid";
import { validateOrcid } from "../services/api";
/**
* Entry view: OAuth button + manual ORCID iD entry.
* Navigates to `/dashboard/:orcid` after a successful `validateOrcid` call.
*/
export function LandingPage() {
const navigate = useNavigate();
const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState("");
const [validating, setValidating] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
function handleOrcidChange(event) {
setOrcidInput(formatOrcidInput(event.target.value));
if (error) setError("");
}
async function handleValidate() {
if (!isValidOrcid(orcidInput)) {
setError(
"Formato inválido. El ORCID iD debe tener la estructura: 0000-0002-1234-5678",
);
return;
}
setValidating(true);
try {
await validateOrcid(orcidInput);
navigate(`/dashboard/${orcidInput}`);
} catch (err) {
toast.error("No se pudo validar el ORCID iD", {
description: err?.message ?? "Inténtalo de nuevo en unos segundos.",
});
} finally {
setValidating(false);
}
}
async function handleOrcidLogin() {
setOauthLoading(true);
try {
// Real implementation will redirect to ORCID OAuth (handled by backend).
// For now we emulate the flow locally with a known sample ORCID.
await new Promise((r) => setTimeout(r, 800));
navigate(`/dashboard/0000-0002-1234-5678`);
} catch (err) {
toast.error("No se pudo iniciar sesión con ORCID", {
description: err?.message ?? "Inténtalo de nuevo.",
});
} finally {
setOauthLoading(false);
}
}
function handleKeyDown(event) {
if (event.key === "Enter") handleValidate();
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" />
<main className="flex flex-1 items-center justify-center p-12 sm:p-6">
<div className="w-full max-w-[520px]">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-[72px] w-[72px] items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
<DocumentIcon size={36} className="text-white" />
</div>
<h1 className="mb-2 text-[28px] font-semibold tracking-tight text-ink-primary">
Repositorio Institucional
</h1>
<p className="text-[15px] leading-relaxed text-ink-secondary">
Conecta tu perfil ORCID y deposita tus publicaciones
automáticamente en el repositorio institucional vía protocolo
SWORD.
</p>
</div>
{/* Main card */}
<div className="rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<button
type="button"
onClick={handleOrcidLogin}
disabled={oauthLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{oauthLoading ? <Spinner size={17} /> : <OrcidLogo />}
{oauthLoading
? "Redirigiendo a ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-surface-border" />
<span className="text-xs tracking-widest text-ink-tertiary">
O INTRODUCE TU ORCID iD
</span>
<div className="h-px flex-1 bg-surface-border" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-ink-secondary">
ORCID iD
</label>
<div className="flex gap-2.5">
<div className="relative flex-1">
<input
type="text"
inputMode="numeric"
placeholder="0000-0002-1234-5678"
value={orcidInput}
onChange={handleOrcidChange}
onKeyDown={handleKeyDown}
maxLength={19}
className={`w-full rounded-lg py-2.5 pl-10 pr-3.5 font-mono text-[15px] tracking-wider text-ink-primary outline-none transition-colors ${
error
? "border border-border-danger"
: "border border-surface-border-strong focus:border-brand-accent"
}`}
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<OrcidLogo />
</span>
</div>
<button
type="button"
onClick={handleValidate}
disabled={validating || !orcidInput}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
orcidInput
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
>
{validating && <Spinner size={14} />}
{validating ? "Validando..." : "Buscar"}
</button>
</div>
{error && (
<p className="mt-2 text-xs leading-relaxed text-ink-danger">
{error}
</p>
)}
<p className="mt-2 text-xs text-ink-tertiary">
Formato: 16 dígitos separados con guiones (ej.
0000-0002-1234-5678)
</p>
</div>
</div>
{/* Info chips */}
<div className="mt-6 flex flex-wrap justify-center gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
<span
key={label}
className="rounded-full border border-surface-border/60 bg-surface-secondary px-3 py-1 text-xs text-ink-tertiary"
>
{label}
</span>
))}
</div>
</div>
</main>
</div>
);
}
export default LandingPage;
+146
View File
@@ -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 };
}
+82
View File
@@ -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 };
}
+28
View File
@@ -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();
}
+22
View File
@@ -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);
}
+28
View File
@@ -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";
+2 -1
View File
@@ -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()],
})