diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b33ec3a..16bdd0f 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -13,8 +13,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 20.x
- cache: 'npm'
- cache-dependency-path: '**/package-lock.json'
+ cache: "npm"
+ cache-dependency-path: "**/package-lock.json"
- name: Install dependencies
run: npm install --legacy-peer-deps
diff --git a/package-lock.json b/package-lock.json
index 462bb70..6745c0c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
+ "@sanity/client": "^7.12.1",
"@sanity/image-url": "^1.0.2",
"@sanity/vision": "^3.25.0",
"@splinetool/react-spline": "^2.2.6",
@@ -33,6 +34,7 @@
"@vercel/speed-insights": "^1.0.10",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
+ "dotenv": "^17.2.3",
"embla-carousel-autoplay": "^8.0.0-rc19",
"embla-carousel-react": "^8.0.0-rc19",
"framer-motion": "^10.18.0",
@@ -4459,17 +4461,18 @@
}
},
"node_modules/@sanity/client": {
- "version": "6.15.11",
- "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.15.11.tgz",
- "integrity": "sha512-+dAEEKy6LrugjbssVnmef1ZoXOodiEj+0jMzqpygHmw0+uuyeb0jCvAnDdguF1thY/n3H1x+N1edt0CvVrD7Qg==",
+ "version": "7.12.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-7.12.1.tgz",
+ "integrity": "sha512-AG4vW21+myuoQAWETF+zRtzeokruJfparVUT18DHb81lzabyb+d90+Be5Bo44P8leWDACPrJqmw9ES2KuotWnw==",
+ "license": "MIT",
"dependencies": {
- "@sanity/eventsource": "^5.0.0",
- "@vercel/stega": "0.1.0",
- "get-it": "^8.4.18",
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.9",
+ "nanoid": "^3.3.11",
"rxjs": "^7.0.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=20"
}
},
"node_modules/@sanity/codegen": {
@@ -4525,12 +4528,13 @@
}
},
"node_modules/@sanity/eventsource": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@sanity/eventsource/-/eventsource-5.0.1.tgz",
- "integrity": "sha512-BFdRPTqVI76Nh18teu8850lV8DETdtJilFAlmQq/BdoXo88BSWBSTkIIi+H6AW1O9Nd7uT+9VRBqKuL2HKrYlA==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sanity/eventsource/-/eventsource-5.0.2.tgz",
+ "integrity": "sha512-/B9PMkUvAlUrpRq0y+NzXgRv5lYCLxZNsBJD2WXVnqZYOfByL9oQBV7KiTaARuObp5hcQYuPfOAVjgXe3hrixA==",
+ "license": "MIT",
"dependencies": {
- "@types/event-source-polyfill": "1.0.2",
- "@types/eventsource": "1.1.12",
+ "@types/event-source-polyfill": "1.0.5",
+ "@types/eventsource": "1.1.15",
"event-source-polyfill": "1.0.31",
"eventsource": "2.0.2"
}
@@ -4645,6 +4649,20 @@
"node": ">=18"
}
},
+ "node_modules/@sanity/migrate/node_modules/@sanity/client": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.29.1.tgz",
+ "integrity": "sha512-BQRCMeDlBxwnMbFtB61HUxFf9aSb4HNVrpfrC7IFVqFf4cwcc3o5H8/nlrL9U3cDFedbe4W0AXt1mQzwbY/ljw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.7",
+ "rxjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/@sanity/migrate/node_modules/arrify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
@@ -4842,6 +4860,20 @@
"@types/react": "^18.0.25"
}
},
+ "node_modules/@sanity/types/node_modules/@sanity/client": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.29.1.tgz",
+ "integrity": "sha512-BQRCMeDlBxwnMbFtB61HUxFf9aSb4HNVrpfrC7IFVqFf4cwcc3o5H8/nlrL9U3cDFedbe4W0AXt1mQzwbY/ljw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.7",
+ "rxjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/@sanity/ui": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@sanity/ui/-/ui-2.1.2.tgz",
@@ -4902,6 +4934,20 @@
"node": ">=18"
}
},
+ "node_modules/@sanity/util/node_modules/@sanity/client": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.29.1.tgz",
+ "integrity": "sha512-BQRCMeDlBxwnMbFtB61HUxFf9aSb4HNVrpfrC7IFVqFf4cwcc3o5H8/nlrL9U3cDFedbe4W0AXt1mQzwbY/ljw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.7",
+ "rxjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/@sanity/uuid": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@sanity/uuid/-/uuid-3.0.2.tgz",
@@ -5497,14 +5543,25 @@
"dev": true
},
"node_modules/@types/event-source-polyfill": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.2.tgz",
- "integrity": "sha512-qE5zrFd73BRs5oSjVys6g/5GboqOMbzLRTUFPAhfULvvvbRAOXw9m4Wk+p1BtoZm4JgW7TljGGfVabBqvi3eig=="
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz",
+ "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==",
+ "license": "MIT"
},
"node_modules/@types/eventsource": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.12.tgz",
- "integrity": "sha512-KlVguyxdoO8VkAhOMwOemK+NhFAg0gOwJHgimrWJUgM6LrdVW2nLa+d47WVWQcs8feRn0eeP+5yUDmDfzLBjRA=="
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz",
+ "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/follow-redirects": {
+ "version": "1.14.4",
+ "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz",
+ "integrity": "sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
},
"node_modules/@types/glob": {
"version": "7.2.0",
@@ -8255,6 +8312,18 @@
"node": ">=8"
}
},
+ "node_modules/dotenv": {
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
@@ -9127,15 +9196,16 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
- "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
+ "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -9325,25 +9395,45 @@
}
},
"node_modules/get-it": {
- "version": "8.4.19",
- "resolved": "https://registry.npmjs.org/get-it/-/get-it-8.4.19.tgz",
- "integrity": "sha512-8OQeTNDfRJXQ6RIslyJukFhFBaLH2mdyS4puhX3q435Xx8z/zuv8XX2L7k5/nNWp95Hyoa9kyVs6gYKZiU2M1w==",
+ "version": "8.6.10",
+ "resolved": "https://registry.npmjs.org/get-it/-/get-it-8.6.10.tgz",
+ "integrity": "sha512-27StIK860ZVp2bhsG/aTWpcoA4OrFxtMqBbesa5sR23m5OxfVQYCnpm2rPQeo3gs5qsUk0FdkISLgXRJ4HynNw==",
+ "license": "MIT",
"dependencies": {
- "debug": "^4.3.4",
+ "@types/follow-redirects": "^1.14.4",
"decompress-response": "^7.0.0",
- "follow-redirects": "^1.15.6",
- "into-stream": "^6.0.0",
- "is-plain-object": "^5.0.0",
+ "follow-redirects": "^1.15.9",
"is-retry-allowed": "^2.2.0",
- "is-stream": "^2.0.1",
- "parse-headers": "^2.0.5",
- "progress-stream": "^2.0.0",
+ "through2": "^4.0.2",
"tunnel-agent": "^0.6.0"
},
"engines": {
"node": ">=14.0.0"
}
},
+ "node_modules/get-it/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/get-it/node_modules/through2": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
+ "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "3"
+ }
+ },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -9894,21 +9984,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/into-stream": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz",
- "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==",
- "dependencies": {
- "from2": "^2.3.0",
- "p-is-promise": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -11323,15 +11398,16 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -11427,6 +11503,20 @@
"styled-components": "^5.2 || ^6.0"
}
},
+ "node_modules/next-sanity/node_modules/@sanity/client": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.29.1.tgz",
+ "integrity": "sha512-BQRCMeDlBxwnMbFtB61HUxFf9aSb4HNVrpfrC7IFVqFf4cwcc3o5H8/nlrL9U3cDFedbe4W0AXt1mQzwbY/ljw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.7",
+ "rxjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/next-themes": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",
@@ -11981,14 +12071,6 @@
"node": ">=8"
}
},
- "node_modules/p-is-promise": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz",
- "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -12099,11 +12181,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/parse-headers": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz",
- "integrity": "sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA=="
- },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -12806,15 +12883,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/progress-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/progress-stream/-/progress-stream-2.0.0.tgz",
- "integrity": "sha512-xJwOWR46jcXUq6EH9yYyqp+I52skPySOeHfkxOZ2IY1AiBi/sFJhbhAKHoV3OTw/omQ45KTio9215dRJ2Yxd3Q==",
- "dependencies": {
- "speedometer": "~1.0.0",
- "through2": "~2.0.3"
- }
- },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -13888,6 +13956,20 @@
"node": ">=18"
}
},
+ "node_modules/sanity/node_modules/@sanity/client": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-6.29.1.tgz",
+ "integrity": "sha512-BQRCMeDlBxwnMbFtB61HUxFf9aSb4HNVrpfrC7IFVqFf4cwcc3o5H8/nlrL9U3cDFedbe4W0AXt1mQzwbY/ljw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sanity/eventsource": "^5.0.2",
+ "get-it": "^8.6.7",
+ "rxjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/sanity/node_modules/framer-motion": {
"version": "11.0.8",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.0.8.tgz",
@@ -14391,11 +14473,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/speedometer": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/speedometer/-/speedometer-1.0.0.tgz",
- "integrity": "sha512-lgxErLl/7A5+vgIIXsh9MbeukOaCb2axgQ+bKCdIE+ibNT4XNYGNCR1qFEGq6F+YDASXK3Fh/c5FgtZchFolxw=="
- },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
diff --git a/package.json b/package.json
index be550ae..aec1788 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
+ "@sanity/client": "^7.12.1",
"@sanity/image-url": "^1.0.2",
"@sanity/vision": "^3.25.0",
"@splinetool/react-spline": "^2.2.6",
@@ -88,6 +89,7 @@
"@vercel/speed-insights": "^1.0.10",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
+ "dotenv": "^17.2.3",
"embla-carousel-autoplay": "^8.0.0-rc19",
"embla-carousel-react": "^8.0.0-rc19",
"framer-motion": "^10.18.0",
diff --git a/public/sponsors/FracktalWorks.png b/public/sponsors/FracktalWorks.png
new file mode 100644
index 0000000..b27b394
Binary files /dev/null and b/public/sponsors/FracktalWorks.png differ
diff --git a/public/sponsors/OnlyScrews.png b/public/sponsors/OnlyScrews.png
new file mode 100644
index 0000000..faa81d2
Binary files /dev/null and b/public/sponsors/OnlyScrews.png differ
diff --git a/scripts/migrate-team-year.js b/scripts/migrate-team-year.js
new file mode 100644
index 0000000..d9b0b64
--- /dev/null
+++ b/scripts/migrate-team-year.js
@@ -0,0 +1,82 @@
+const { createClient } = require("@sanity/client");
+require("dotenv").config({ path: ".env.local" });
+
+const SANITY_PROJECT_ID = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
+const SANITY_DATASET = process.env.NEXT_PUBLIC_SANITY_DATASET || "production";
+const SANITY_TOKEN = process.env.NEXT_PUBLIC_SANITY_TOKEN;
+const DRY_RUN = process.env.DRY_RUN !== "false";
+
+if (!SANITY_PROJECT_ID) {
+ console.error("SANITY_PROJECT_ID not set in .env.local");
+ process.exit(1);
+}
+
+const client = createClient({
+ projectId: SANITY_PROJECT_ID,
+ dataset: SANITY_DATASET,
+ apiVersion: "2024-01-01",
+ token: SANITY_TOKEN,
+ useCdn: false,
+});
+
+async function run() {
+ console.log("Sanity teamMember.year migration");
+ console.log("DRY_RUN:", DRY_RUN);
+
+ const docs = await client.fetch(`*[_type == "teamMember" && defined(year)]{_id, _rev, year}`);
+ console.log("Found", docs.length, "documents with year field");
+
+ for (const doc of docs) {
+ const raw = doc.year;
+ if (!raw) continue;
+
+ if (typeof raw === "string" && /^\d{4}$/.test(raw)) {
+ console.log(doc._id, "already year string:", raw);
+ continue;
+ }
+
+ let yearString = null;
+
+ if (typeof raw === "string") {
+ const m = raw.match(/^(\d{4})/);
+ if (m) yearString = m[1];
+ }
+
+ if (!yearString) {
+ try {
+ const d = new Date(raw);
+ if (!isNaN(d.getTime())) yearString = String(d.getUTCFullYear());
+ } catch (e) {
+ // ignore
+ }
+ }
+
+ if (!yearString) {
+ console.log(doc._id, "could not determine year from:", raw);
+ continue;
+ }
+
+ console.log(doc._id, "->", yearString, DRY_RUN ? "(dry run)" : "(apply)");
+
+ if (!DRY_RUN) {
+ if (!SANITY_TOKEN) {
+ console.error("SANITY_TOKEN required to apply changes. Set it in .env.local");
+ process.exit(1);
+ }
+
+ try {
+ await client.patch(doc._id).set({ year: yearString }).commit({ autoGenerateArrayKeys: true });
+ console.log("Patched", doc._id);
+ } catch (err) {
+ console.error("Failed to patch", doc._id, err.message || err);
+ }
+ }
+ }
+
+ console.log("Migration finished");
+}
+
+run().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/src/app/robocup/page.tsx b/src/app/robocup/page.tsx
index 6e87bd7..22eec2e 100644
--- a/src/app/robocup/page.tsx
+++ b/src/app/robocup/page.tsx
@@ -39,9 +39,9 @@ export default function Page(): React.ReactElement {
diff --git a/src/app/team/alumni/page.tsx b/src/app/team/alumni/page.tsx
new file mode 100644
index 0000000..4752ac4
--- /dev/null
+++ b/src/app/team/alumni/page.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import Footer from "@/components/Footer";
+import Navbar from "@/components/Navbar";
+import AlumniTeamData from "@/components/Team/AlumniTeamData";
+
+export default function AlumniTeamPage(): React.ReactElement {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/app/team/current/page.tsx b/src/app/team/current/page.tsx
new file mode 100644
index 0000000..f8ccc87
--- /dev/null
+++ b/src/app/team/current/page.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import Footer from "@/components/Footer";
+import Navbar from "@/components/Navbar";
+import CurrentTeamData from "@/components/Team/CurrentTeamData";
+
+export default function CurrentTeamPage(): React.ReactElement {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/Gallery/GalleryImage.tsx b/src/components/Gallery/GalleryImage.tsx
index 015657d..55420c9 100644
--- a/src/components/Gallery/GalleryImage.tsx
+++ b/src/components/Gallery/GalleryImage.tsx
@@ -4,65 +4,40 @@ import { motion } from "framer-motion";
import Image from "next/image";
import React, { useState } from "react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
import { GalleryItem } from "@/lib/models";
import { fadeIn } from "@/lib/motion";
export default function GalleryImage({ item }: { item: GalleryItem }): React.JSX.Element {
const [loading, setLoading] = useState(true);
+
return (
-
-
+
+ setLoading(false)}
+ />
+
- setLoading(false)}
- />
-
+ initial={{ opacity: 0, y: 20 }}
+ whileHover={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.3, duration: 0.5 }}
+ viewport={{ once: false }}
+ className="flex h-full w-full flex-col items-center justify-center gap-2">
+ {item.title}
-
-
-
- {item.title}
-
-
-
-
-
-
+
+
);
}
diff --git a/src/components/Gallery/GalleryView.tsx b/src/components/Gallery/GalleryView.tsx
index b3c4b4c..bb92b41 100644
--- a/src/components/Gallery/GalleryView.tsx
+++ b/src/components/Gallery/GalleryView.tsx
@@ -1,9 +1,10 @@
"use client";
import { motion } from "framer-motion";
-import React from "react";
+import React, { useState } from "react";
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
+import { Lightbox } from "@/components/ui/lightbox";
import useGallery from "@/lib/hooks/useGallery";
import { fadeIn, textVariant } from "@/lib/motion";
@@ -12,6 +13,22 @@ import GalleryImage from "./GalleryImage";
export function GalleryView(): React.JSX.Element {
const gallery = useGallery();
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const handleImageClick = (index: number): void => {
+ setCurrentIndex(index);
+ setLightboxOpen(true);
+ };
+
+ const handleNext = (): void => {
+ setCurrentIndex((prev) => (prev + 1 < gallery.length ? prev + 1 : prev));
+ };
+
+ const handlePrev = (): void => {
+ setCurrentIndex((prev) => (prev - 1 >= 0 ? prev - 1 : prev));
+ };
+
return (
{gallery.map((item, idx) => (
-
+ handleImageClick(idx)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e): void => {
+ if (e.key === "Enter") {
+ handleImageClick(idx);
+ }
+ }}>
+
+
))}
)}
+
+
setLightboxOpen(false)}
+ onNext={handleNext}
+ onPrev={handlePrev}
+ />
);
}
diff --git a/src/components/Home/About.tsx b/src/components/Home/About.tsx
index 0c16626..7d532b2 100644
--- a/src/components/Home/About.tsx
+++ b/src/components/Home/About.tsx
@@ -28,13 +28,14 @@ export function About(): React.JSX.Element {
whileInView="show"
viewport={{ once: true }}
className="max-w-[700px] text-justify tracking-tight text-gray-600 dark:text-gray-400 md:text-lg/relaxed lg:text-base/relaxed xl:text-lg/relaxed">
- Team RoboManipal is the official Robotics team of MAHE, Manipal established in 2009. We are
+ Team RoboManipal is the official Robotics team of MAHE, Manipal established in 2010. We are
a multidisciplinary team of 40+ undergraduate robotics enthusiasts who endeavour to achieve
innovation through robotic technologies and spread knowledge about its diverse applications.
- The legacy of 13 years to have won laurels at many levels is a testament to our enthusiasm.
- We participate in prestigious competitions like ABU ROBOCON - Asia’s biggest Robotic
- Competition. Robotreks and Robowars, the most exciting events of TechTatva which attract
- teams from all over India, are organised by RoboManipal.
+ The legacy of 15 years to have won laurels at many levels is a testament to our enthusiasm.
+ We participate in prestigious competitions such as RoboCup - the world's largest robotics
+ competition - Technoxian, and ABU ROBOCON - Asia's biggest robotics competition. We also
+ conduct robotics research across diverse domains, developing innovative projects and
+ publishing papers.
diff --git a/src/components/Home/Faq.tsx b/src/components/Home/Faq.tsx
index d0b08ba..91f6be8 100644
--- a/src/components/Home/Faq.tsx
+++ b/src/components/Home/Faq.tsx
@@ -65,11 +65,15 @@ export function FAQ(): React.JSX.Element {
What are the recent achievements of the team ?
- 1. We received All India rank 21 in ABU ROBOCON
- 2. 2nd runner up in Technoxian World Cup
- 3. Qualified for all the nationals of ABU ROBOCON
- 4. AIR 9 in ABU ROBOCON in 2016
- 5. AIR 2 in the World Robotics Olympiad in 2018
+ 1. AIR 1 in Ather Byte Battles 2.0 in 2025
+ 2. Second Runner-Up at Robonautica, IISc Rhapsody 3.0 in 2025
+ 3. Runners up in Tech Solstice 2025 at MIT-Bengaluru
+ 4. 1st place at Tech-TED Project Presentation in 2025
+ 5. Second runners up in Technoxian World Cup 2024
+ 6. Group Leaders in ABU ROBOCON Nationals 2024
+ 7. Received All India rank 21 in ABU ROBOCON'23
+ 8. AIR 2 in the World Robotics Olympiad in 2018
+ 9. AIR 9 in ABU ROBOCON in 2016
@@ -77,17 +81,24 @@ export function FAQ(): React.JSX.Element {
What are the research projects that team is curently working on?
- 1. ARNAV: A biomimetic hand project.
- 2. Crab bot: A semi-autonomous robot designed for social purposes, utilizing swarm robotics
- mechanisms.
- 3. PRAYAS: A 6-DOF serial manipulator project.
- 4. PEEKER: A surveillance robot equipped with a 3-DOF camera.
- 5. Swerve drive: An omnidirectional drive-train in which all wheels are independently
- steered and driven.
- 6. RMMD (RoboManipal Motor Driver): An in-house-built motor driver used to efficiently
- control motor speed and direction, featuring built-in reverse current protection.
- 7. Tachometer: A fully in-house-built tachometer, including a microcontrolling unit, printed
- circuit board, and LED screen.
+ 1. KARMA: A 6-DOF robotic arm mounted on a mobile base, capable of autonomous navigation and
+ performing tasks such as object detection, picking, and placing. Built for real-world task
+ execution using advanced motion planning and intelligent control.
+ 2. Angulator: A compact, real-time orientation tracking device that provides precise yaw,
+ pitch, and roll data using an nRF module with ESB wireless communication. Designed for
+ multi-robot motion analysis with ultra-low latency and easy mounting.
+ 3. Swerve Drive: An omnidirectional drive-train in which all wheels are independently
+ steered and driven, enabling highly agile and precise movement for competition robots.{" "}
+
+ 4. RMMD V2 (RoboManipal Motor Driver): Developed the second iteration of an in-house motor
+ driver, fully integrated as a standalone ROS node for direct robot communication. Optimized
+ the power stage to significantly reduce power consumption compared to the validated V1
+ prototype, while retaining reverse-current protection.
+ 5. Tachometer: A fully in-house-built tachometer featuring a microcontroller unit, custom
+ PCB, and LED display for accurate RPM measurement and system monitoring.
+ 6. FarmBot: It is an autonomous field robot that combines computer vision, soil sensing, and
+ AI-based disease detection to give Indian farmers real-time, actionable insights for
+ reducing crop loss and promoting sustainable farming.
diff --git a/src/components/Home/Sponsors.tsx b/src/components/Home/Sponsors.tsx
index 3ff4c86..3c7e872 100644
--- a/src/components/Home/Sponsors.tsx
+++ b/src/components/Home/Sponsors.tsx
@@ -4,7 +4,8 @@ import Autoplay from "embla-carousel-autoplay";
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
-import React, { useRef } from "react";
+import { useTheme } from "next-themes";
+import React, { useEffect, useRef, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
@@ -16,6 +17,14 @@ import SectionWrapper from "../wrappers/SectionWrapper";
export function Sponsors(): React.JSX.Element {
const sponsors = useSponsors();
const player = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
+ const { resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ const LIGHT_OVERRIDES: Record = {
+ OnlyScrews: "/sponsors/OnlyScrews.png",
+ "Fracktal Works": "/sponsors/FracktalWorks.png",
+ };
return (
<>
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 46f913a..195b573 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { ChevronDown, Info, Users } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -21,6 +22,7 @@ import { navLinks } from "@/lib/constants";
import { cn } from "@/lib/utils";
import ThemeToggleButton from "./ThemeToggle";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
import { buttonVariants } from "./ui/button";
export default function Navbar(): React.JSX.Element {
@@ -28,6 +30,7 @@ export default function Navbar(): React.JSX.Element {
const [logo, setLogo] = React.useState("/logo_light.png");
const { resolvedTheme } = useTheme();
const navBarRef = useRef(null);
+ const [sheetOpen, setSheetOpen] = React.useState(false);
React.useEffect(() => {
const handleScroll = (): void => {
if (navBarRef.current) {
@@ -65,15 +68,22 @@ export default function Navbar(): React.JSX.Element {
- About Us
+ About Us
- {navLinks.map((link) => (
-
-
- {link.label}
-
-
- ))}
+ {navLinks.map((link) => {
+ if (link.label === "Team") {
+ return (
+
+ );
+ }
+ return (
+
+
+ {link.label}
+
+
+ );
+ })}
-
+
-
-
+
+
Menu
-
- {navLinks.map((link) => (
-
-
-
{link.label}
-
- ))}
+
+
+
+ svg]:hidden"
+ )}>
+
+
+ About Us
+
+
+
+
+ setSheetOpen(false)}>
+ About Us
+
+ setSheetOpen(false)}>
+ Sponsors
+
+ setSheetOpen(false)}>
+ Contact
+
+
+
+
+
+
+ {navLinks.map((link): React.ReactNode => {
+ if (link.label === "Team") {
+ return (
+
+
+ svg]:hidden"
+ )}>
+
+
+ Team
+
+
+
+
+ setSheetOpen(false)}>
+ Current Team
+
+ setSheetOpen(false)}>
+ Alumni
+
+
+
+
+
+ );
+ }
+ return (
+
setSheetOpen(false)}>
+
+
{link.label}
+
+ );
+ })}
+
+
diff --git a/src/components/Team/AlumniDatePicker.tsx b/src/components/Team/AlumniDatePicker.tsx
new file mode 100644
index 0000000..66937d6
--- /dev/null
+++ b/src/components/Team/AlumniDatePicker.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { format } from "date-fns";
+import { Calendar as CalendarIcon, Check } from "lucide-react";
+import { useTheme } from "next-themes";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+
+import { ShineBorder } from "../ui/shine-border";
+
+export function AlumniDatePicker({
+ date,
+ setDate,
+ availableYears = [],
+}: {
+ date: Date | undefined;
+ setDate: React.Dispatch
>;
+ availableYears?: number[];
+ minDate?: Date | undefined;
+ maxDate?: Date | undefined;
+}): React.ReactElement {
+ const { resolvedTheme } = useTheme();
+ const selectedYear = date?.getFullYear();
+
+ function handleSelect(year: number): void {
+ const newDate = new Date(date ?? new Date());
+ newDate.setFullYear(year);
+ setDate(newDate);
+ }
+
+ return (
+
+
+
+
+
+ {date ? format(date, "PPP") : Pick a year }
+
+
+
+
+
+
+ {availableYears.map((y): React.ReactElement => {
+ const isActive = y === selectedYear;
+ return (
+
+ handleSelect(y)}
+ className={cn(
+ "flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm transition-colors",
+ isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"
+ )}>
+ {`${y}-${(y + 1).toString().slice(-2)}`}
+ {isActive && }
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/Team/AlumniTeamData.tsx b/src/components/Team/AlumniTeamData.tsx
new file mode 100644
index 0000000..61a586a
--- /dev/null
+++ b/src/components/Team/AlumniTeamData.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import React, { useEffect, useMemo } from "react";
+
+import TeamCategory from "@/components/Team/TeamCategory";
+import useMembers from "@/lib/hooks/useMembers";
+
+import { AlumniDatePicker } from "./AlumniDatePicker";
+
+function AlumniTeamData(): React.ReactElement {
+ const members = useMembers();
+ const [date, setDate] = React.useState(undefined);
+ const [displayYear, setDisplayYear] = React.useState(undefined);
+ const [alumniYears, setAlumniYears] = React.useState([]);
+ const [minDate, maxDate] = useMemo<[Date | undefined, Date | undefined]>(() => {
+ if (Object.keys(members).length === 0) return [undefined, undefined];
+
+ const currentYear = new Date().getFullYear();
+ const alumniMembers = Object.entries(members).filter(([year]) => parseInt(year) <= currentYear - 1);
+
+ if (alumniMembers.length === 0) return [undefined, undefined];
+
+ const dates = alumniMembers.flatMap(([, yearMembers]) => yearMembers.map((member) => member.year));
+ const minDate = dates.reduce((min, date) => (date < min ? date : min), dates[0]);
+ const maxDate = dates.reduce((max, date) => (date > max ? date : max), dates[0]);
+ return [minDate, maxDate];
+ }, [members]);
+
+ useEffect(() => {
+ const currentYear = new Date().getFullYear();
+ const collected = Object.keys(members)
+ .map((year) => parseInt(year))
+ .filter((year) => year <= currentYear - 1)
+ .sort((a, b) => b - a);
+ setAlumniYears(collected);
+ }, [members]);
+
+ useEffect(() => {
+ if (!Array.isArray(alumniYears) || alumniYears.length === 0) return;
+ if (typeof window === "undefined") return;
+ const nav =
+ typeof window.performance.getEntriesByType === "function"
+ ? window.performance.getEntriesByType("navigation")
+ : [];
+ const isReload =
+ Array.isArray(nav) && nav.length > 0 && (nav[0] as PerformanceNavigationTiming).type === "reload";
+ if (!isReload) return;
+ const saved = window.localStorage.getItem("alumniYear");
+ if (!saved) return;
+ const y = parseInt(saved);
+ if (Number.isNaN(y)) return;
+ if (!alumniYears.includes(y)) return;
+ const d = new Date();
+ d.setFullYear(y);
+ setDate(d);
+ setDisplayYear(y);
+ }, [alumniYears]);
+
+ useEffect(() => {
+ if (!maxDate || date) return;
+ setDate(maxDate);
+ setDisplayYear(maxDate.getFullYear());
+ }, [maxDate, date]);
+
+ useEffect(() => {
+ if (!date) return;
+ const y = date.getFullYear();
+ const yearMembers = members[String(y)];
+ if (Array.isArray(yearMembers) && y <= new Date().getFullYear() - 1) {
+ setDisplayYear(y);
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem("alumniYear", String(y));
+ }
+ return;
+ }
+ if (!Array.isArray(alumniYears) || alumniYears.length === 0) return;
+ const sorted = [...alumniYears].sort((a, b) => a - b);
+ let nearest: number | undefined = undefined;
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (const candidate of sorted) {
+ const dist = Math.abs(candidate - y);
+ if (dist < bestDist || (dist === bestDist && (nearest === undefined || candidate > nearest))) {
+ bestDist = dist;
+ nearest = candidate;
+ }
+ }
+ if (nearest !== undefined) setDisplayYear(nearest);
+ }, [date, members, alumniYears]);
+
+ return (
+
+
+ {displayYear !== undefined && Array.isArray(members[String(displayYear)]) && (
+
+ )}
+
+ {minDate && maxDate && (
+
+ )}
+
+ );
+}
+
+export default AlumniTeamData;
diff --git a/src/components/Team/CurrentTeamData.tsx b/src/components/Team/CurrentTeamData.tsx
new file mode 100644
index 0000000..2a289cc
--- /dev/null
+++ b/src/components/Team/CurrentTeamData.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import React, { useEffect } from "react";
+
+import TeamCategory from "@/components/Team/TeamCategory";
+import useMembers from "@/lib/hooks/useMembers";
+import { TeamMember } from "@/lib/models";
+
+function CurrentTeamData(): React.ReactElement {
+ const members = useMembers();
+ const [currentTeamMembers, setCurrentTeamMembers] = React.useState([]);
+
+ useEffect(() => {
+ const currentYear = new Date().getFullYear();
+ const yearMembers = members[String(currentYear)];
+ setCurrentTeamMembers(Array.isArray(yearMembers) ? yearMembers : []);
+ }, [members]);
+
+ return (
+
+
+ {currentTeamMembers.length > 0 && }
+
+
+ );
+}
+
+export default CurrentTeamData;
diff --git a/src/components/Team/TeamCategory.tsx b/src/components/Team/TeamCategory.tsx
index 5a389e4..b6dca23 100644
--- a/src/components/Team/TeamCategory.tsx
+++ b/src/components/Team/TeamCategory.tsx
@@ -10,15 +10,25 @@ import { textVariant } from "@/lib/motion";
import TeamCard from "./TeamCard";
const SubSystemSection = ({ subsystem, details }: { subsystem: string; details: TeamMember[] }): React.JSX.Element => {
- const members = useMemo(
- () =>
- details.filter(
- (member) =>
- member.subsystem === subsystem &&
- (member.role.toLowerCase() === "member" || member.role.toLowerCase() == "senior member")
- ),
- [details, subsystem]
- );
+ const members = useMemo(() => {
+ const filtered = details.filter((member) => {
+ const role = (member.role || "").toLowerCase();
+ return (
+ Boolean(member.subsystem) &&
+ member.subsystem === subsystem &&
+ (role === "member" || role === "senior member")
+ );
+ });
+ return filtered.sort((a, b) => {
+ const roleA = (a.role || "").toLowerCase();
+ const roleB = (b.role || "").toLowerCase();
+ if (roleA === "senior member" && roleB !== "senior member") return -1;
+ if (roleA !== "senior member" && roleB === "senior member") return 1;
+ return 0;
+ });
+ }, [details, subsystem]);
+ const subKey = (subsystem || "").toLowerCase();
+ const subText = subsystemText[subKey];
return (
<>
{members.length === 0 ? null : (
@@ -32,16 +42,16 @@ const SubSystemSection = ({ subsystem, details }: { subsystem: string; details:
{subsystem}
-
- "{subsystemText[subsystem.toLowerCase()].split("\n")[0]}"
-
-
- {
- subsystemText[subsystem.toLowerCase()].split("\n")[
- subsystemText[subsystem.toLowerCase()].split("\n").length - 1
- ]
- }
-
+ {subText && (
+ <>
+
+ "{subText.split("\n")[0]}"
+
+
+ {subText.split("\n")[subText.split("\n").length - 1]}
+
+ >
+ )}
{members.map((member) => (
@@ -57,13 +67,24 @@ const SubSystemSection = ({ subsystem, details }: { subsystem: string; details:
const HeadDetails = ({ details }: { details: TeamMember[] }): React.JSX.Element => {
const managers = useMemo(
() =>
- details.filter(
- (member) =>
- member.role.toLowerCase().includes("team") || member.role.toLowerCase().includes("tech lead")
- ),
+ details.filter((member) => {
+ const role = (member.role || "").toLowerCase();
+ return role.includes("team") || role.includes("tech lead");
+ }),
[details]
);
- const heads = useMemo(() => details.filter((member) => member.role.toLowerCase().includes("head")), [details]);
+ const heads = useMemo(
+ () =>
+ details.filter((member) => {
+ const role = (member.role || "").toLowerCase();
+ return role.includes("head") || role === "founder";
+ }),
+ [details]
+ );
+
+ if (managers.length === 0 && heads.length === 0) {
+ return <>>;
+ }
return (
<>
@@ -105,7 +126,7 @@ export default function TeamCategory({ details }: { details: TeamMember[] }): Re
const subsystems = useMemo(() => {
const subsystems = new Set
();
details.forEach((member) => {
- subsystems.add(member.subsystem);
+ if (member.subsystem) subsystems.add(member.subsystem);
});
return Array.from(subsystems);
}, [details]);
diff --git a/src/components/Work/WorkCard.tsx b/src/components/Work/WorkCard.tsx
index c5ae43d..0c192a1 100644
--- a/src/components/Work/WorkCard.tsx
+++ b/src/components/Work/WorkCard.tsx
@@ -10,113 +10,79 @@ import { LuCalendarClock, LuClock } from "react-icons/lu";
import { RiExternalLinkLine } from "react-icons/ri";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
import { Work } from "@/lib/models";
import { Badge } from "../ui/badge";
import { Separator } from "../ui/separator";
+import { WorkDetailModal } from "./WorkDetailModal";
export default function WorkCard({ work }: { work: Work }): React.JSX.Element {
const [loading, setLoading] = useState(true);
+ const [modalOpen, setModalOpen] = useState(false);
return (
-
-
-
-
-
- setLoading(false)}
- />
-
-
-
-
-
- {work.title}
-
- {moment(work.date).fromNow()}
-
- {work.status === "Completed" ? (
-
- ) : work.status === "Upcoming" ? (
-
- ) : (
-
- )}
- {work.status}
-
-
-
-
-
-
- {work.description}
-
-
-
-
-
-
- {work.title}
-
-
- {moment(work.date).fromNow()}
-
-
-
-
- {work.description}
-
-
-
-
-
- {work.status === "Completed" ? (
-
- ) : work.status === "Upcoming" ? (
-
- ) : (
-
+ <>
+
+
+ setModalOpen(true)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e): void => {
+ if (e.key === "Enter") {
+ setModalOpen(true);
+ }
+ }}>
+ setLoading(false)}
+ />
+
+
+ {work.title}
+
+
+ {moment(work.date).fromNow()}
+
+
+
+
+ {work.description}
+
+
+
+
+
+ {work.status === "Completed" ? (
+
+ ) : work.status === "Upcoming" ? (
+
+ ) : (
+
+ )}
+ {work.status}
+
+ {work.link && (
+
+
+
)}
- {work.status}
-
- {work.link && (
-
-
-
- )}
-
-
-
+
+
+
+
+ setModalOpen(false)} />
+ >
);
}
diff --git a/src/components/Work/WorkData.tsx b/src/components/Work/WorkData.tsx
index 2a29ca5..db58a69 100644
--- a/src/components/Work/WorkData.tsx
+++ b/src/components/Work/WorkData.tsx
@@ -47,14 +47,11 @@ export function WorkData(): React.ReactElement {
+ className="my-16 flex flex-wrap items-center justify-center gap-16"
+ style={{ maxWidth: "calc(3 * 20rem + 2 * 4rem)" }}>
{status[key].map((work) => (
))}
- {/* spacer to ensure last card fully visible on all viewports */}
-
))}
diff --git a/src/components/Work/WorkDetailModal.tsx b/src/components/Work/WorkDetailModal.tsx
new file mode 100644
index 0000000..3fdf957
--- /dev/null
+++ b/src/components/Work/WorkDetailModal.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { X } from "lucide-react";
+import moment from "moment";
+import Image from "next/image";
+import Link from "next/link";
+import React from "react";
+import { GoVerified } from "react-icons/go";
+import { LuCalendarClock, LuClock } from "react-icons/lu";
+import { RiExternalLinkLine } from "react-icons/ri";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Work } from "@/lib/models";
+
+interface WorkDetailModalProps {
+ work: Work;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function WorkDetailModal({ work, isOpen, onClose }: WorkDetailModalProps): React.ReactElement | null {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
e.stopPropagation()}>
+
+
{work.title}
+
+ {moment(work.date).fromNow()}
+
+ {work.status === "Completed" ? (
+
+ ) : work.status === "Upcoming" ? (
+
+ ) : (
+
+ )}
+ {work.status}
+
+
+
+
+
+
+
+
+
+
Description
+
+ {work.description}
+
+
+
+ {work.link && (
+
+
+
+ View Project
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/lightbox.tsx b/src/components/ui/lightbox.tsx
new file mode 100644
index 0000000..b3c9d9b
--- /dev/null
+++ b/src/components/ui/lightbox.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { ChevronLeft, ChevronRight, X } from "lucide-react";
+import Image from "next/image";
+import React from "react";
+
+import { GalleryItem } from "@/lib/models";
+import { cn } from "@/lib/utils";
+
+import { Button } from "./button";
+
+interface LightboxProps {
+ isOpen: boolean;
+ items: GalleryItem[];
+ currentIndex: number;
+ onClose: () => void;
+ onNext: () => void;
+ onPrev: () => void;
+}
+
+export function Lightbox({
+ isOpen,
+ items,
+ currentIndex,
+ onClose,
+ onNext,
+ onPrev,
+}: LightboxProps): React.ReactElement | null {
+ if (!isOpen || !items[currentIndex]) return null;
+
+ const currentItem = items[currentIndex];
+
+ return (
+
+
+
+
+
e.stopPropagation()}>
+
+
+
{currentItem.title}
+
+ {currentIndex + 1} / {items.length}
+
+
+ {items.length > 1 && (
+ <>
+
{
+ e.stopPropagation();
+ onPrev();
+ }}
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "absolute left-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/20",
+ currentIndex === 0 && "cursor-not-allowed opacity-50"
+ )}
+ disabled={currentIndex === 0}
+ aria-label="Previous image">
+
+
+
{
+ e.stopPropagation();
+ onNext();
+ }}
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/20",
+ currentIndex === items.length - 1 && "cursor-not-allowed opacity-50"
+ )}
+ disabled={currentIndex === items.length - 1}
+ aria-label="Next image">
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+function LightboxKeyboard({
+ onClose,
+ onNext,
+ onPrev,
+}: {
+ onClose: () => void;
+ onNext: () => void;
+ onPrev: () => void;
+}): null {
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") onClose();
+ if (e.key === "ArrowRight") onNext();
+ if (e.key === "ArrowLeft") onPrev();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return (): void => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose, onNext, onPrev]);
+
+ return null;
+}
diff --git a/src/components/ui/scroll-to-top.tsx b/src/components/ui/scroll-to-top.tsx
index 24bd763..51ebd39 100644
--- a/src/components/ui/scroll-to-top.tsx
+++ b/src/components/ui/scroll-to-top.tsx
@@ -59,7 +59,7 @@ export function ScrollToTop(): React.ReactElement {
return (
{
@@ -12,7 +12,7 @@ export default function useMembers(): Record {
useEffect(() => {
async function fetchMembers(): Promise {
const query = `*[_type == "teamMember"] | order(year desc)`;
- const results = await client.fetch(query);
+ const results = await previewClient.fetch(query);
results.forEach((result) => {
result.image = urlForImage(result.image as Image);
result.year = new Date(result.year);
diff --git a/src/lib/sanity/env.ts b/src/lib/sanity/env.ts
index c15a90c..9466875 100644
--- a/src/lib/sanity/env.ts
+++ b/src/lib/sanity/env.ts
@@ -10,7 +10,13 @@ export const projectId = assertValue(
"Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID"
);
-export const useCdn = true;
+export const useCdn =
+ process.env.NEXT_PUBLIC_SANITY_USE_CDN !== undefined
+ ? process.env.NEXT_PUBLIC_SANITY_USE_CDN === "true"
+ : process.env.NODE_ENV === "production";
+
+export const previewDrafts = process.env.NEXT_PUBLIC_SANITY_PREVIEW === "true";
+export const token = process.env.NEXT_PUBLIC_SANITY_TOKEN;
function assertValue(v: T | undefined, errorMessage: string): T {
if (v === undefined) {
diff --git a/src/lib/sanity/lib/client.ts b/src/lib/sanity/lib/client.ts
index 09c4821..6b5a2b2 100644
--- a/src/lib/sanity/lib/client.ts
+++ b/src/lib/sanity/lib/client.ts
@@ -1,6 +1,6 @@
import { createClient } from "next-sanity";
-import { apiVersion, dataset, projectId, useCdn } from "../env";
+import { apiVersion, dataset, previewDrafts, projectId, token, useCdn } from "../env";
export const client = createClient({
apiVersion,
@@ -8,3 +8,14 @@ export const client = createClient({
projectId,
useCdn,
});
+
+export const previewClient = previewDrafts
+ ? createClient({
+ apiVersion,
+ dataset,
+ projectId,
+ useCdn: false,
+ perspective: "previewDrafts",
+ token,
+ })
+ : client;
diff --git a/src/lib/schemas/TeamMember.ts b/src/lib/schemas/TeamMember.ts
index 850fb77..84e532c 100644
--- a/src/lib/schemas/TeamMember.ts
+++ b/src/lib/schemas/TeamMember.ts
@@ -1,3 +1,12 @@
+const currentYear = new Date().getFullYear();
+const startYear = 2010;
+const years = Array.from({ length: currentYear - startYear + 1 })
+ .map((_, i) => {
+ const y = startYear + i;
+ return { title: String(y), value: String(y) };
+ })
+ .reverse();
+
export default {
name: "teamMember",
title: "Team Member",
@@ -12,9 +21,9 @@ export default {
{
name: "year",
title: "Year",
- type: "date",
+ type: "string",
options: {
- calendarTodayLabel: "Today",
+ list: years,
},
},
{
@@ -67,6 +76,7 @@ export default {
{ title: "Team Leader", value: "Team Leader" },
{ title: "Senior Member", value: "Senior Member" },
{ title: "Tech Lead", value: "Tech Lead" },
+ { title: "Founder", value: "Founder" },
],
},
},