feat: add blocks showcase and restructure app

This commit is contained in:
Anmoldeep Singh
2026-03-16 17:20:51 +05:30
parent 94628a5143
commit d7d8dbe3ab
108 changed files with 4298 additions and 648 deletions

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -23,7 +23,6 @@
<img src="public/banner.png" alt="mapcn banner" />
</p>
## Features
- 🎨 **Theme-aware** — Automatically adapts to light/dark mode

View File

@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""

435
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^2.15.4",
"shiki": "^3.20.0",
"tailwind-merge": "^2.6.0"
},
@@ -29,6 +30,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"shadcn": "^3.6.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
@@ -455,6 +457,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -5566,6 +5577,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -7222,9 +7296,129 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7314,6 +7508,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@@ -7496,6 +7696,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -8268,6 +8478,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -8405,6 +8621,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -9248,6 +9473,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@@ -9912,7 +10146,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -10368,6 +10601,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -10422,7 +10661,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -11049,7 +11287,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -11618,6 +11855,102 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-hermes": "*",
"@prettier/plugin-oxc": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-hermes": {
"optional": true
},
"@prettier/plugin-oxc": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-multiline-arrays": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
}
}
},
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -11662,7 +11995,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -11945,7 +12277,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-remove-scroll": {
@@ -11995,6 +12326,21 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -12017,6 +12363,22 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -12034,6 +12396,44 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -13179,7 +13579,6 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
@@ -13848,6 +14247,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@@ -21,6 +21,7 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^2.15.4",
"shiki": "^3.20.0",
"tailwind-merge": "^2.6.0"
},
@@ -31,6 +32,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"shadcn": "^3.6.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",

View File

@@ -0,0 +1,49 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "analytics-map",
"title": "Analytics Map",
"description": "Real-time analytics overview with a world map, breakdown cards, and device stats.",
"dependencies": [
"recharts",
"lucide-react"
],
"registryDependencies": [
"map",
"card",
"chart"
],
"files": [
{
"path": "src/registry/blocks/analytics-map/page.tsx",
"content": "\"use client\";\n\nimport {\n Map,\n MapControls,\n MapMarker,\n MarkerContent,\n MarkerTooltip,\n} from \"@/registry/map\";\nimport { OverviewCard } from \"./components/overview-card\";\nimport { BreakdownCard } from \"./components/breakdown-card\";\nimport {\n locations,\n visitedPagesRows,\n countriesRows,\n referrersRows,\n browsersRows,\n} from \"./data\";\n\nconst MAP_HEIGHT = \"38rem\";\n\nexport function AnalyticsMapBlock() {\n return (\n <div\n className=\"bg-background relative min-h-screen\"\n style={{ \"--map-height\": MAP_HEIGHT } as React.CSSProperties}\n >\n <div className=\"relative h-(--map-height)\">\n <Map\n center={[-2, 16]}\n zoom={1.5}\n scrollZoom={false}\n renderWorldCopies={true}\n >\n <MapControls showFullscreen />\n {locations.map((location) => (\n <MapMarker\n key={location.city}\n longitude={location.lng}\n latitude={location.lat}\n >\n <MarkerContent>\n <div\n className=\"rounded-full bg-blue-500/70\"\n style={{\n width: location.size * 3,\n height: location.size * 3,\n }}\n />\n </MarkerContent>\n <MarkerTooltip\n offset={20}\n className=\"bg-background text-foreground border\"\n >\n <p className=\"text-muted-foreground font-medium\">\n {location.city}\n </p>\n <p className=\"mt-1\">\n <span className=\"font-medium tabular-nums\">\n {location.size}\n </span>{\" \"}\n active users\n </p>\n </MarkerTooltip>\n </MapMarker>\n ))}\n </Map>\n <div\n className=\"via-background/30 to-background pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-linear-to-b from-transparent\"\n aria-hidden\n />\n <OverviewCard />\n </div>\n\n <div className=\"grid gap-4 p-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4\">\n <BreakdownCard title=\"Visited pages\" rows={visitedPagesRows} />\n <BreakdownCard title=\"Referrers\" rows={referrersRows} />\n <BreakdownCard title=\"Countries\" rows={countriesRows} />\n <BreakdownCard title=\"Browsers\" rows={browsersRows} />\n </div>\n </div>\n );\n}\n",
"type": "registry:page",
"target": "app/analytics/page.tsx"
},
{
"path": "src/registry/blocks/analytics-map/data.ts",
"content": "import { type ChartConfig } from \"@/components/ui/chart\";\n\nexport interface LocationPoint {\n city: string;\n lng: number;\n lat: number;\n size: number;\n}\n\nexport interface BreakdownRow {\n label: string;\n value: number;\n}\n\nexport const locations: LocationPoint[] = [\n { city: \"San Francisco\", lng: -122.4194, lat: 37.7749, size: 16 },\n { city: \"New York\", lng: -74.006, lat: 40.7128, size: 15 },\n { city: \"Toronto\", lng: -79.3832, lat: 43.6532, size: 11 },\n { city: \"Mexico City\", lng: -99.1332, lat: 19.4326, size: 10 },\n { city: \"Sao Paulo\", lng: -46.6333, lat: -23.5505, size: 12 },\n { city: \"Buenos Aires\", lng: -58.3816, lat: -34.6037, size: 9 },\n { city: \"London\", lng: -0.1276, lat: 51.5074, size: 14 },\n { city: \"Berlin\", lng: 13.405, lat: 52.52, size: 11 },\n { city: \"Paris\", lng: 2.3522, lat: 48.8566, size: 13 },\n { city: \"Madrid\", lng: -3.7038, lat: 40.4168, size: 10 },\n { city: \"Cairo\", lng: 31.2357, lat: 30.0444, size: 9 },\n { city: \"Lagos\", lng: 3.3792, lat: 6.5244, size: 10 },\n { city: \"Mumbai\", lng: 72.8777, lat: 19.076, size: 13 },\n { city: \"Dubai\", lng: 55.2708, lat: 25.2048, size: 11 },\n { city: \"Seoul\", lng: 126.978, lat: 37.5665, size: 12 },\n { city: \"Singapore\", lng: 103.8198, lat: 1.3521, size: 10 },\n { city: \"Tokyo\", lng: 139.6917, lat: 35.6895, size: 12 },\n { city: \"Sydney\", lng: 151.2093, lat: -33.8688, size: 9 },\n { city: \"Auckland\", lng: 174.7633, lat: -36.8485, size: 8 },\n];\n\nexport const usersPerDay = [\n { day: \"Mon\", users: 320 },\n { day: \"Tue\", users: 410 },\n { day: \"Wed\", users: 560 },\n { day: \"Thu\", users: 640 },\n { day: \"Fri\", users: 780 },\n { day: \"Sat\", users: 690 },\n { day: \"Sun\", users: 720 },\n];\n\nexport const usersPerDayChartConfig = {\n users: {\n label: \"Users\",\n color: \"var(--color-blue-500)\",\n },\n} satisfies ChartConfig;\n\nexport const deviceCategoryData = [\n { name: \"Desktop\", value: 73.3, fill: \"var(--color-blue-500)\" },\n { name: \"Mobile\", value: 25.0, fill: \"var(--color-blue-400)\" },\n { name: \"Tablet\", value: 1.7, fill: \"var(--color-blue-300)\" },\n];\n\nexport const deviceCategoryChartConfig = {\n desktop: { label: \"Desktop\", color: \"var(--color-blue-500)\" },\n mobile: { label: \"Mobile\", color: \"var(--color-blue-400)\" },\n tablet: { label: \"Tablet\", color: \"var(--color-blue-300)\" },\n} satisfies ChartConfig;\n\nexport const visitedPagesRows: BreakdownRow[] = [\n { label: \"Home\", value: 31 },\n { label: \"Pricing\", value: 23 },\n { label: \"Docs / Basic Map\", value: 18 },\n { label: \"Installation\", value: 12 },\n { label: \"Components\", value: 9 },\n { label: \"Blog\", value: 6 },\n];\n\nexport const countriesRows: BreakdownRow[] = [\n { label: \"United States\", value: 27 },\n { label: \"India\", value: 14 },\n { label: \"United Kingdom\", value: 8 },\n { label: \"Germany\", value: 6 },\n { label: \"Japan\", value: 4 },\n { label: \"Australia\", value: 2 },\n];\n\nexport const referrersRows: BreakdownRow[] = [\n { label: \"google\", value: 38 },\n { label: \"direct\", value: 26 },\n { label: \"github.com\", value: 19 },\n { label: \"x.com\", value: 11 },\n { label: \"ui.shadcn.com\", value: 8 },\n { label: \"other\", value: 5 },\n];\n\nexport const browsersRows: BreakdownRow[] = [\n { label: \"Chrome\", value: 52 },\n { label: \"Safari\", value: 21 },\n { label: \"Firefox\", value: 14 },\n { label: \"Edge\", value: 8 },\n { label: \"Other\", value: 5 },\n];\n",
"type": "registry:component",
"target": "app/analytics/data.ts"
},
{
"path": "src/registry/blocks/analytics-map/components/overview-card.tsx",
"content": "\"use client\";\n\nimport { Card, CardContent, CardHeader } from \"@/components/ui/card\";\nimport { ChartContainer } from \"@/components/ui/chart\";\nimport { TrendingUp } from \"lucide-react\";\nimport { Area, AreaChart, Cell, Pie, PieChart } from \"recharts\";\nimport {\n deviceCategoryChartConfig,\n deviceCategoryData,\n usersPerDay,\n usersPerDayChartConfig,\n} from \"../data\";\n\nfunction MetricChart() {\n return (\n <ChartContainer\n config={usersPerDayChartConfig}\n className=\"aspect-auto h-8 w-full\"\n >\n <AreaChart data={usersPerDay} margin={{ left: 4, right: 4, top: 4 }}>\n <defs>\n <linearGradient id=\"usersGradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop\n offset=\"0%\"\n stopColor=\"var(--color-users)\"\n stopOpacity={0.4}\n />\n <stop\n offset=\"100%\"\n stopColor=\"var(--color-users)\"\n stopOpacity={0}\n />\n </linearGradient>\n </defs>\n\n <Area\n type=\"natural\"\n dataKey=\"users\"\n stroke=\"var(--color-users)\"\n strokeWidth={1.5}\n fill=\"url(#usersGradient)\"\n />\n </AreaChart>\n </ChartContainer>\n );\n}\n\nexport function OverviewCard() {\n return (\n <Card className=\"bg-card/70 absolute top-4 left-4 z-10 w-60 backdrop-blur-sm\">\n <CardHeader>\n <div>\n <p className=\"text-muted-foreground pb-2 text-[10px] tracking-wider uppercase\">\n Users in last 7 days\n </p>\n <p className=\"text-3xl leading-none font-semibold\">3,803</p>\n </div>\n </CardHeader>\n\n <CardContent>\n <MetricChart />\n <div className=\"mt-4 flex items-center gap-1.5 text-xs\">\n <TrendingUp className=\"size-3 text-emerald-500\" />\n <span className=\"font-medium text-emerald-500\">+12.5%</span>\n <span className=\"text-muted-foreground\">vs previous 7 days</span>\n </div>\n\n <div className=\"border-border/60 mt-4 border-t pt-4\">\n <p className=\"text-muted-foreground text-[10px] tracking-wider uppercase\">\n Device category in last 7 days\n </p>\n\n <ChartContainer\n config={deviceCategoryChartConfig}\n className=\"mx-auto mt-3 aspect-square h-32 w-32\"\n >\n <PieChart>\n <Pie\n data={deviceCategoryData}\n dataKey=\"value\"\n nameKey=\"name\"\n innerRadius={32}\n outerRadius={52}\n strokeWidth={2}\n >\n {deviceCategoryData.map((entry) => (\n <Cell key={entry.name} fill={entry.fill} />\n ))}\n </Pie>\n </PieChart>\n </ChartContainer>\n\n <div className=\"mt-3 grid grid-cols-3 gap-2\">\n {deviceCategoryData.map((device) => (\n <div key={device.name} className=\"text-center\">\n <p className=\"text-muted-foreground flex items-center justify-center gap-1.5 text-[10px] tracking-wide uppercase\">\n <span\n className=\"size-2 rounded-full\"\n style={{ backgroundColor: device.fill }}\n />\n {device.name}\n </p>\n <p className=\"text-foreground mt-1 leading-none font-medium tabular-nums\">\n {device.value}%\n </p>\n </div>\n ))}\n </div>\n </div>\n </CardContent>\n </Card>\n );\n}\n",
"type": "registry:component",
"target": "app/analytics/components/overview-card.tsx"
},
{
"path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx",
"content": "\"use client\";\n\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { type BreakdownRow } from \"../data\";\n\ninterface BreakdownCardProps {\n title: string;\n rows: BreakdownRow[];\n}\n\nexport function BreakdownCard({ title, rows }: BreakdownCardProps) {\n const maxRowValue =\n rows.length > 0 ? Math.max(...rows.map((row) => row.value)) : 0;\n\n return (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-sm font-medium\">{title}</CardTitle>\n </CardHeader>\n\n <CardContent>\n <div className=\"text-muted-foreground mb-2 flex items-center justify-between text-[11px] tracking-wider uppercase\">\n <span>{title}</span>\n <span>Visitors</span>\n </div>\n <div className=\"space-y-3\">\n {rows.map((row) => (\n <div key={row.label} className=\"space-y-1.5\">\n <div className=\"flex items-center justify-between text-xs\">\n <span className=\"text-foreground/90 truncate\">{row.label}</span>\n <span className=\"text-foreground font-medium\">{row.value}</span>\n </div>\n <div className=\"bg-muted h-1 rounded-full\">\n <div\n className=\"h-full rounded-full bg-blue-500/85\"\n style={{ width: `${(row.value / maxRowValue) * 100}%` }}\n />\n </div>\n </div>\n ))}\n </div>\n </CardContent>\n </Card>\n );\n}\n",
"type": "registry:component",
"target": "app/analytics/components/breakdown-card.tsx"
}
],
"meta": {
"iframeHeight": "970px"
},
"categories": [
"analytics",
"dashboard"
],
"type": "registry:block"
}

File diff suppressed because one or more lines are too long

24
public/r/heatmap.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -27,6 +27,114 @@
}
}
}
},
{
"name": "analytics-map",
"type": "registry:block",
"title": "Analytics Map",
"description": "Real-time analytics overview with a world map, breakdown cards, and device stats.",
"dependencies": ["recharts", "lucide-react"],
"registryDependencies": ["map", "card", "chart"],
"files": [
{
"path": "src/registry/blocks/analytics-map/page.tsx",
"type": "registry:page",
"target": "app/analytics/page.tsx"
},
{
"path": "src/registry/blocks/analytics-map/data.ts",
"type": "registry:component",
"target": "app/analytics/data.ts"
},
{
"path": "src/registry/blocks/analytics-map/components/overview-card.tsx",
"type": "registry:component",
"target": "app/analytics/components/overview-card.tsx"
},
{
"path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx",
"type": "registry:component",
"target": "app/analytics/components/breakdown-card.tsx"
}
],
"categories": ["analytics", "dashboard"],
"meta": {
"iframeHeight": "970px"
}
},
{
"name": "logistics-network",
"type": "registry:block",
"title": "Logistics Network",
"description": "Domestic logistics map with a sidebar of stats.",
"dependencies": ["lucide-react"],
"registryDependencies": ["map", "card", "badge", "button"],
"files": [
{
"path": "src/registry/blocks/logistics-network/page.tsx",
"type": "registry:page",
"target": "app/logistics/page.tsx"
},
{
"path": "src/registry/blocks/logistics-network/data.ts",
"type": "registry:component",
"target": "app/logistics/data.ts"
},
{
"path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
"type": "registry:component",
"target": "app/logistics/components/map-arcs.tsx"
},
{
"path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
"type": "registry:component",
"target": "app/logistics/components/filter-sidebar.tsx"
},
{
"path": "src/registry/blocks/logistics-network/components/network-map.tsx",
"type": "registry:component",
"target": "app/logistics/components/network-map.tsx"
}
],
"categories": ["logistics", "network"],
"meta": {
"iframeHeight": "800px"
}
},
{
"name": "heatmap",
"type": "registry:block",
"title": "Heatmap",
"description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.",
"dependencies": [],
"registryDependencies": ["map", "card"],
"files": [
{
"path": "src/registry/blocks/heatmap/page.tsx",
"type": "registry:page",
"target": "app/heatmap/page.tsx"
}
],
"categories": ["visualization", "heatmap"]
},
{
"name": "delivery-tracker",
"type": "registry:block",
"title": "Delivery Tracker",
"description": "Live order tracking with route progress, courier position, and order details.",
"dependencies": ["lucide-react"],
"registryDependencies": ["map", "card", "badge", "button"],
"files": [
{
"path": "src/registry/blocks/delivery-tracker/page.tsx",
"type": "registry:page",
"target": "app/delivery/page.tsx"
}
],
"categories": ["tracking", "delivery"],
"meta": {
"iframeHeight": "680px"
}
}
]
}

View File

@@ -27,6 +27,114 @@
}
}
}
},
{
"name": "analytics-map",
"type": "registry:block",
"title": "Analytics Map",
"description": "Real-time analytics overview with a world map, breakdown cards, and device stats.",
"dependencies": ["recharts", "lucide-react"],
"registryDependencies": ["map", "card", "chart"],
"files": [
{
"path": "src/registry/blocks/analytics-map/page.tsx",
"type": "registry:page",
"target": "app/analytics/page.tsx"
},
{
"path": "src/registry/blocks/analytics-map/data.ts",
"type": "registry:component",
"target": "app/analytics/data.ts"
},
{
"path": "src/registry/blocks/analytics-map/components/overview-card.tsx",
"type": "registry:component",
"target": "app/analytics/components/overview-card.tsx"
},
{
"path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx",
"type": "registry:component",
"target": "app/analytics/components/breakdown-card.tsx"
}
],
"categories": ["analytics", "dashboard"],
"meta": {
"iframeHeight": "970px"
}
},
{
"name": "logistics-network",
"type": "registry:block",
"title": "Logistics Network",
"description": "Domestic logistics map with a sidebar of stats.",
"dependencies": ["lucide-react"],
"registryDependencies": ["map", "card", "badge", "button"],
"files": [
{
"path": "src/registry/blocks/logistics-network/page.tsx",
"type": "registry:page",
"target": "app/logistics/page.tsx"
},
{
"path": "src/registry/blocks/logistics-network/data.ts",
"type": "registry:component",
"target": "app/logistics/data.ts"
},
{
"path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
"type": "registry:component",
"target": "app/logistics/components/map-arcs.tsx"
},
{
"path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
"type": "registry:component",
"target": "app/logistics/components/filter-sidebar.tsx"
},
{
"path": "src/registry/blocks/logistics-network/components/network-map.tsx",
"type": "registry:component",
"target": "app/logistics/components/network-map.tsx"
}
],
"categories": ["logistics", "network"],
"meta": {
"iframeHeight": "800px"
}
},
{
"name": "heatmap",
"type": "registry:block",
"title": "Heatmap",
"description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.",
"dependencies": [],
"registryDependencies": ["map", "card"],
"files": [
{
"path": "src/registry/blocks/heatmap/page.tsx",
"type": "registry:page",
"target": "app/heatmap/page.tsx"
}
],
"categories": ["visualization", "heatmap"]
},
{
"name": "delivery-tracker",
"type": "registry:block",
"title": "Delivery Tracker",
"description": "Live order tracking with route progress, courier position, and order details.",
"dependencies": ["lucide-react"],
"registryDependencies": ["map", "card", "badge", "button"],
"files": [
{
"path": "src/registry/blocks/delivery-tracker/page.tsx",
"type": "registry:page",
"target": "app/delivery/page.tsx"
}
],
"categories": ["tracking", "delivery"],
"meta": {
"iframeHeight": "680px"
}
}
]
}

View File

@@ -1,88 +0,0 @@
"use client";
import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map";
import { Zap } from "lucide-react";
import { ExampleCard } from "./example-card";
export function EVChargingExample() {
return (
<ExampleCard
label="EV Charging"
className="aspect-square"
delay="delay-700"
>
<Map center={[-122.425, 37.777]} zoom={11.5}>
<MapMarker longitude={-122.4194} latitude={37.7749}>
<MarkerContent>
<div className="bg-emerald-500 rounded-full p-1.5 shadow-lg shadow-emerald-500/30">
<Zap className="size-3 text-white fill-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="text-xs space-y-0.5">
<div className="font-medium">Market St Station</div>
<div className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="text-emerald-500">Available</span>
</div>
<div className="text-muted-foreground">150 kW $0.35/kWh</div>
</div>
</MarkerTooltip>
</MapMarker>
<MapMarker longitude={-122.4094} latitude={37.7849}>
<MarkerContent>
<div className="bg-emerald-500 rounded-full p-1.5 shadow-lg shadow-emerald-500/30">
<Zap className="size-3 text-white fill-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="text-xs space-y-0.5">
<div className="font-medium">Union Square</div>
<div className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="text-emerald-500">2 Available</span>
</div>
<div className="text-muted-foreground">50 kW $0.28/kWh</div>
</div>
</MarkerTooltip>
</MapMarker>
<MapMarker longitude={-122.4294} latitude={37.7689}>
<MarkerContent>
<div className="bg-amber-500 rounded-full p-1.5 shadow-lg shadow-amber-500/30">
<Zap className="size-3 text-white fill-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="text-xs space-y-0.5">
<div className="font-medium">Castro Station</div>
<div className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-amber-500" />
<span className="text-amber-500">In Use</span>
</div>
<div className="text-muted-foreground">~15 min remaining</div>
</div>
</MarkerTooltip>
</MapMarker>
<MapMarker longitude={-122.4394} latitude={37.7809}>
<MarkerContent>
<div className="bg-zinc-400 rounded-full p-1.5 shadow-lg">
<Zap className="size-3 text-white fill-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="text-xs space-y-0.5">
<div className="font-medium">Hayes Valley</div>
<div className="flex items-center gap-1">
<span className="size-1.5 rounded-full bg-zinc-400" />
<span className="text-muted-foreground">Offline</span>
</div>
</div>
</MarkerTooltip>
</MapMarker>
</Map>
</ExampleCard>
);
}

View File

@@ -1,42 +0,0 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
export function Footer() {
return (
<footer className="w-full py-5 border-t">
<div className="w-full container flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">mapcn</span>
<span className="text-muted-foreground/80"></span>
<span>
Built by
<Button variant="link" size="sm" className="px-1" asChild>
<Link
href="https://github.com/AnmolSaini16"
target="_blank"
rel="noopener noreferrer"
>
Anmol
</Link>
</Button>
</span>
</div>
<div className="flex items-center">
<Button variant="ghost" size="sm" asChild>
<Link href="/docs">Documentation</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link
href="https://github.com/AnmolSaini16/mapcn"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</Link>
</Button>
</div>
</div>
</footer>
);
}

View File

@@ -1,92 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Copy, Check, ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
const installCommand = "npx shadcn@latest add @mapcn/map";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={copy}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-4 text-emerald-500" />
) : (
<Copy className="size-4" />
)}
</button>
);
}
export function Hero() {
return (
<div className="relative">
<div className="absolute inset-x-0 -inset-y-32 -z-10 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.3] dark:opacity-[0.15]"
style={{
backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
backgroundSize: "24px 24px",
}}
/>
<div className="absolute inset-0 bg-linear-to-b from-background via-transparent to-background" />
</div>
<div className="container flex flex-col items-center text-center">
<h1 className="text-4xl sm:text-5xl md:text-6xl font-semibold tracking-tight animate-fade-up delay-100">
<span className="bg-linear-to-b from-foreground to-foreground/60 bg-clip-text text-transparent">
Beautiful maps, made simple.
</span>
</h1>
<p className="mt-6 text-foreground/80 text-lg md:text-xl leading-relaxed animate-fade-up delay-200 max-w-lg">
Ready to use, customizable map components for React.
<br className="hidden sm:block" />
Built on MapLibre. Styled with Tailwind.
</p>
<div className="mt-8 animate-fade-up delay-300 w-full max-w-lg">
<div className="bg-card border border-border rounded-lg shadow-xs overflow-hidden">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
<span className="size-2 rounded-full bg-foreground/20" />
<span className="size-2 rounded-full bg-foreground/20" />
<span className="size-2 rounded-full bg-foreground/20" />
</div>
<div className="flex items-center gap-3 px-4 py-3 font-mono text-sm">
<span className="text-emerald-500 shrink-0">$</span>
<code className="text-foreground/80 truncate flex-1 text-left">
{installCommand}
</code>
<CopyButton text={installCommand} />
</div>
</div>
</div>
<div className="mt-8 flex flex-wrap justify-center items-center gap-3 animate-fade-up delay-400">
<Button size="lg" asChild>
<Link href="/docs">
Get Started
<ArrowRight className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="lg" asChild>
<Link href="/docs/basic-map">View Components</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { Hero } from "./_components/hero";
import { ExamplesGrid } from "./_components/examples-grid";
import { Footer } from "./_components/footer";
import { Header } from "./_components/header";
export default function Page() {
return (
<div className="flex flex-col">
<Header />
<main className="flex-1 pb-32">
<section className="relative w-full py-24">
<Hero />
</section>
<section className="container">
<ExamplesGrid />
</section>
</main>
<Footer />
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { FlyToExample } from "./examples/flyto-example";
export function ExamplesGrid() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 animate-fade-in delay-400">
<div className="animate-fade-in grid grid-cols-1 gap-5 delay-400 sm:grid-cols-2 lg:grid-cols-4">
<AnalyticsExample />
<TrailExample />
<FlyToExample />

View File

@@ -56,7 +56,7 @@ export function DeliveryExample() {
)}
<MapMarker longitude={store.lng} latitude={store.lat}>
<MarkerContent>
<div className="size-3.5 rounded-full bg-blue-500 border-2 border-white shadow-lg" />
<div className="size-3.5 rounded-full bg-emerald-500 border-2 border-white shadow-lg" />
<MarkerLabel>Store</MarkerLabel>
</MarkerContent>
</MapMarker>

View File

@@ -0,0 +1,165 @@
"use client";
import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map";
import { Zap } from "lucide-react";
import { ExampleCard } from "./example-card";
type Status = "available" | "in-use" | "offline";
interface ChargingStation {
name: string;
lng: number;
lat: number;
status: Status;
detail: string;
}
const stations: ChargingStation[] = [
{
name: "Union Square",
lng: -122.4074,
lat: 37.7879,
status: "available",
detail: "50 kW • $0.28/kWh",
},
{
name: "Castro Station",
lng: -122.435,
lat: 37.7625,
status: "in-use",
detail: "~15 min remaining",
},
{
name: "Hayes Valley",
lng: -122.4264,
lat: 37.7759,
status: "offline",
detail: "",
},
{
name: "Embarcadero",
lng: -122.3934,
lat: 37.7935,
status: "available",
detail: "350 kW • $0.40/kWh",
},
{
name: "Marina District",
lng: -122.437,
lat: 37.801,
status: "available",
detail: "150 kW • $0.32/kWh",
},
{
name: "SoMa Charger",
lng: -122.401,
lat: 37.778,
status: "available",
detail: "50 kW • $0.30/kWh",
},
{
name: "Noe Valley",
lng: -122.431,
lat: 37.75,
status: "available",
detail: "150 kW • $0.33/kWh",
},
{
name: "Richmond Charger",
lng: -122.478,
lat: 37.781,
status: "in-use",
detail: "~8 min remaining",
},
{
name: "Potrero Hill",
lng: -122.401,
lat: 37.76,
status: "offline",
detail: "",
},
{
name: "Mission Bay",
lng: -122.391,
lat: 37.77,
status: "available",
detail: "350 kW • $0.38/kWh",
},
{
name: "Golden Gate Park",
lng: -122.466,
lat: 37.77,
status: "available",
detail: "150 kW • $0.34/kWh",
},
];
const statusConfig: Record<
Status,
{ bg: string; label: string; textClass: string }
> = {
available: {
bg: "bg-emerald-500",
label: "Available",
textClass: "text-emerald-500",
},
"in-use": {
bg: "bg-amber-500",
label: "In Use",
textClass: "text-amber-500",
},
offline: {
bg: "bg-zinc-400",
label: "Offline",
textClass: "text-muted-foreground",
},
};
export function EVChargingExample() {
return (
<ExampleCard
label="EV Charging"
className="aspect-square"
delay="delay-700"
>
<Map center={[-122.434, 37.776]} zoom={11}>
{stations.map((station) => {
const config = statusConfig[station.status];
return (
<MapMarker
key={station.name}
longitude={station.lng}
latitude={station.lat}
>
<MarkerContent>
<div
className={`${config.bg} rounded-full p-1.5 shadow-lg ${
station.status !== "offline"
? `shadow-${config.bg.replace("bg-", "")}/30`
: ""
}`}
>
<Zap className="size-3 text-white fill-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="text-xs space-y-0.5">
<div className="font-medium">{station.name}</div>
<div className="flex items-center gap-1">
<span className={`size-1.5 rounded-full ${config.bg}`} />
<span className={config.textClass}>{config.label}</span>
</div>
{station.detail && (
<div className="text-muted-foreground">
{station.detail}
</div>
)}
</div>
</MarkerTooltip>
</MapMarker>
);
})}
</Map>
</ExampleCard>
);
}

View File

@@ -18,13 +18,13 @@ export function ExampleCard({
return (
<div
className={cn(
"rounded-xl overflow-hidden shadow-sm bg-card border border-border/50 relative animate-scale-in",
"bg-card border-border/50 animate-scale-in relative overflow-hidden rounded-xl border shadow-sm",
delay,
className
className,
)}
>
{label && (
<div className="uppercase absolute top-2 left-2 z-10 tracking-wider text-[10px] text-muted-foreground bg-background/90 backdrop-blur-sm rounded px-2 py-1">
<div className="text-muted-foreground bg-background/90 absolute top-2 left-2 z-10 rounded px-2 py-1 text-[10px] tracking-wider uppercase backdrop-blur-sm">
{label}
</div>
)}

View File

@@ -0,0 +1,40 @@
import { ExamplesGrid } from "./_components/examples-grid";
import { Footer } from "@/components/footer";
import {
PageHeader,
PageHeaderHeading,
PageHeaderDescription,
PageActions,
} from "@/components/page-header";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function Page() {
return (
<>
<PageHeader>
<PageHeaderHeading>Beautiful maps, made simple</PageHeaderHeading>
<PageHeaderDescription>
Ready to use, customizable map components for React.
<br className="hidden sm:block" />
Built on MapLibre. Styled with Tailwind.
</PageHeaderDescription>
<PageActions>
<Button size="lg" asChild>
<Link href="/docs">Get Started</Link>
</Button>
<Button size="lg" variant="outline" asChild>
<Link href="/docs/basic-map">View Components</Link>
</Button>
</PageActions>
</PageHeader>
<section className="container-wide">
<ExamplesGrid />
</section>
<Footer />
</>
);
}

View File

@@ -0,0 +1,40 @@
import { getAllBlocks, createFileTreeForRegistryItemFiles } from "@/lib/blocks";
import { getBlockFileSource } from "@/lib/get-block-file-source";
import { highlightCode } from "@/lib/highlight";
import { BlockPreview } from "./block-preview";
import { IframePreview } from "./iframe-preview";
interface BlockDisplayProps {
name: string;
}
export async function BlockDisplay({ name }: BlockDisplayProps) {
const blocks = getAllBlocks();
const block = blocks.find((b) => b.name === name);
if (!block || !block.files?.length) {
return null;
}
const tree = createFileTreeForRegistryItemFiles(block.files);
const highlightedFiles = await Promise.all(
block.files.map(async (file) => {
const content = getBlockFileSource(file.path);
const lang = file.path.split(".").pop() ?? "tsx";
const highlightedContent = await highlightCode(content, lang);
return {
path: file.path,
target: file.target ?? file.path,
content,
highlightedContent,
};
})
);
return (
<BlockPreview block={block} tree={tree} highlightedFiles={highlightedFiles}>
<IframePreview src={`/view/${block.name}`} title={block.title ?? block.name} />
</BlockPreview>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { Check, Code, Eye, Fullscreen, Terminal } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { RegistryBlockItem, type FileTree } from "@/lib/blocks";
import { BlockViewerCode, type HighlightedFile } from "./block-viewer-code";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
interface BlockPreviewProps {
block: RegistryBlockItem;
children: React.ReactNode;
tree: FileTree[];
highlightedFiles: HighlightedFile[];
}
export function BlockPreview({
block,
children,
tree,
highlightedFiles,
}: BlockPreviewProps) {
const { name, title, description, meta } = block;
const [copiedType, setCopiedType] = useState<"code" | "cli" | null>(null);
async function copyCli() {
await navigator.clipboard.writeText(`npx shadcn@latest add @mapcn/${name}`);
setCopiedType("cli");
setTimeout(() => setCopiedType(null), 2000);
}
return (
<div
className="space-y-4"
style={
{
"--block-preview-height": meta?.iframeHeight ?? "930px",
} as React.CSSProperties
}
>
<div>
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
{description && (
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
)}
</div>
<Tabs defaultValue="preview" className="w-full">
<div className="flex items-center justify-between">
<TabsList className="h-8!">
<TabsTrigger value="preview" className="text-xs">
<Eye className="size-3.5" />
Preview
</TabsTrigger>
<TabsTrigger value="code" className="text-xs">
<Code className="size-3.5" />
Code
</TabsTrigger>
</TabsList>
<div className="hidden items-center gap-3 md:flex">
<Button
variant="outline"
size="icon-sm"
asChild
aria-label="Open in new tab"
>
<Link href={`/view/${name}`} target="_blank">
<Fullscreen />
</Link>
</Button>
<Separator orientation="vertical" className="h-4!" />
<Button
onClick={copyCli}
variant="outline"
aria-label="Copy CLI command"
size="sm"
>
{copiedType === "cli" ? <Check /> : <Terminal />}
npx shadcn add @mapcn/{name}
</Button>
</div>
</div>
<TabsContent value="preview" className="mt-2">
{children}
</TabsContent>
<TabsContent value="code" className="mt-2">
<BlockViewerCode tree={tree} highlightedFiles={highlightedFiles} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import * as React from "react";
import { Check, ChevronRight, Copy, File, Folder } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarProvider,
} from "@/components/ui/sidebar";
import { type FileTree } from "@/lib/blocks";
export interface HighlightedFile {
path: string;
target: string;
content: string;
highlightedContent: string;
}
interface BlockViewerCodeContext {
activeFile: string;
setActiveFile: (file: string) => void;
highlightedFiles: HighlightedFile[];
tree: FileTree[];
}
const BlockViewerCodeCtx = React.createContext<BlockViewerCodeContext | null>(
null
);
function useBlockViewerCode() {
const ctx = React.useContext(BlockViewerCodeCtx);
if (!ctx) {
throw new Error("useBlockViewerCode must be used within BlockViewerCode");
}
return ctx;
}
interface BlockViewerCodeProps {
tree: FileTree[];
highlightedFiles: HighlightedFile[];
}
export function BlockViewerCode({
tree,
highlightedFiles,
}: BlockViewerCodeProps) {
const [activeFile, setActiveFile] = React.useState<string>(
highlightedFiles[0]?.target ?? ""
);
const file = React.useMemo(
() => highlightedFiles.find((f) => f.target === activeFile),
[highlightedFiles, activeFile]
);
if (!file) return null;
return (
<BlockViewerCodeCtx.Provider
value={{ activeFile, setActiveFile, highlightedFiles, tree }}
>
<div className="flex overflow-hidden rounded-xl border h-(--block-preview-height)">
<div className="w-64 shrink-0">
<FileTreeSidebar />
</div>
<div className="min-w-0 flex-1 flex flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 text-sm">
<span className="text-muted-foreground">{file.target}</span>
<div className="ml-auto">
<CopyCodeButton />
</div>
</div>
<div
key={file.path}
dangerouslySetInnerHTML={{ __html: file.highlightedContent }}
className="flex-1 overflow-y-auto p-4 text-sm [&_pre]:bg-transparent! [&_code]:bg-transparent!"
/>
</div>
</div>
</BlockViewerCodeCtx.Provider>
);
}
function FileTreeSidebar() {
const { tree } = useBlockViewerCode();
return (
<SidebarProvider className="flex min-h-full! flex-col border-r">
<Sidebar collapsible="none" className="w-full flex-1 bg-card">
<SidebarGroupLabel className="h-12 rounded-none border-b px-4 text-sm">
Files
</SidebarGroupLabel>
<SidebarGroup className="p-0">
<SidebarGroupContent>
<SidebarMenu className="translate-x-0 gap-1.5">
{tree.map((file, index) => (
<TreeNode key={index} item={file} index={1} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Sidebar>
</SidebarProvider>
);
}
function TreeNode({ item, index }: { item: FileTree; index: number }) {
const { activeFile, setActiveFile } = useBlockViewerCode();
if (!item.children) {
return (
<SidebarMenuItem>
<SidebarMenuButton
isActive={item.path === activeFile}
onClick={() => item.path && setActiveFile(item.path)}
className="hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15 rounded-none whitespace-nowrap pl-(--index)"
data-index={index}
style={
{
"--index": `${index * (index === 2 ? 1.2 : 1.3)}rem`,
} as React.CSSProperties
}
>
<ChevronRight className="invisible" />
<File className="size-4" />
{item.name}
</SidebarMenuButton>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem>
<Collapsible
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
defaultOpen
>
<CollapsibleTrigger asChild>
<SidebarMenuButton
className="hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15 rounded-none whitespace-nowrap pl-(--index)"
style={
{
"--index": `${index * (index === 1 ? 1 : 1.2)}rem`,
} as React.CSSProperties
}
>
<ChevronRight className="transition-transform" />
<Folder />
{item.name}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="m-0 w-full translate-x-0 border-none p-0">
{item.children.map((subItem, key) => (
<TreeNode key={key} item={subItem} index={index + 1} />
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
function CopyCodeButton() {
const { activeFile, highlightedFiles } = useBlockViewerCode();
const [copied, setCopied] = React.useState(false);
const file = React.useMemo(
() => highlightedFiles.find((f) => f.target === activeFile),
[highlightedFiles, activeFile]
);
if (!file) return null;
return (
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={async () => {
await navigator.clipboard.writeText(file.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check /> : <Copy />}
</Button>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
interface IframePreviewProps {
src: string;
title: string;
}
export function IframePreview({ src, title }: IframePreviewProps) {
return (
<div className="relative w-full overflow-hidden rounded-xl border h-(--block-preview-height)">
<iframe src={src} title={title} className="size-full border-0" />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Metadata } from "next";
import { getAllBlocks } from "@/lib/blocks";
import { BlockDisplay } from "./_components/block-display";
export const metadata: Metadata = {
title: "Map blocks for your application",
description:
"Pre-built, ready-to-use map blocks. Browse, preview, and copy them into your app with one command.",
};
export default async function Page() {
const blocks = getAllBlocks();
return (
<>
<PageHeader align="left">
<PageHeaderHeading className="md:text-5xl">
Map blocks for your application
</PageHeaderHeading>
<PageHeaderDescription className="md:text-lg">
Pre-built, ready-to-use map blocks. Browse, preview, and copy them
into your app with one command.
</PageHeaderDescription>
<PageActions className="delay-300">
<Button asChild>
<a href="#blocks">Browse Blocks</a>
</Button>
<Button variant="outline" asChild>
<Link href="/docs">View Documentation</Link>
</Button>
</PageActions>
</PageHeader>
<section
className="animate-fade-up container scroll-mt-20 space-y-20 delay-400"
id="blocks"
>
{blocks.map((block) => (
<BlockDisplay key={block.name} name={block.name} />
))}
</section>
<Footer />
</>
);
}

View File

@@ -6,37 +6,31 @@ import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { docsNavigation } from "@/lib/docs-navigation";
import { Logo } from "@/components/logo";
import { CommandSearch } from "@/components/command-search";
import { ThemeToggle } from "@/components/theme-toggle";
import { GitHubButton } from "@/components/github-button";
import { docsNavigation } from "@/lib/site-navigation";
export function DocsSidebar() {
const pathname = usePathname();
const { setOpenMobile } = useSidebar();
return (
<Sidebar>
<SidebarHeader className="p-4 gap-4 pb-2">
<Logo />
<CommandSearch className="w-full flex" />
</SidebarHeader>
<SidebarContent>
<Sidebar
className="sticky top-16 z-30 hidden h-[calc(100svh-4rem)] overscroll-none bg-transparent lg:flex"
collapsible="none"
>
<SidebarContent className="pt-6">
{docsNavigation.map((group) => (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupLabel className="text-foreground">
{group.title}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
@@ -44,14 +38,13 @@ export function DocsSidebar() {
<SidebarMenuButton
asChild
isActive={pathname === item.href}
className="font-medium text-muted-foreground"
className="text-muted-foreground font-medium"
>
<Link
href={item.href}
onClick={() => setOpenMobile(false)}
>
<item.icon className="size-4" />
<span>{item.title}</span>
{item.title}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -61,14 +54,6 @@ export function DocsSidebar() {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className="border-t">
<div className="flex justify-between">
<GitHubButton withCount={false} />
<ThemeToggle />
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -15,7 +15,7 @@ function useActiveItem(itemIds: string[]) {
}
}
},
{ rootMargin: "0% 0% -80% 0%" }
{ rootMargin: "0% 0% -80% 0%" },
);
for (const id of itemIds ?? []) {
@@ -58,11 +58,11 @@ export function DocsToc({ items, className }: DocsTocProps) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<p className="text-foreground text-[13px] font-medium mb-2">
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium">
On This Page
</p>
<div className="relative">
<div className="absolute left-0 top-1 bottom-1 w-px bg-border" />
<div className="bg-border absolute top-1 bottom-1 left-0 w-px" />
<div className="flex flex-col gap-1">
{items.map((item) => {
const isActive = item.slug === activeHeading;
@@ -71,14 +71,14 @@ export function DocsToc({ items, className }: DocsTocProps) {
key={item.slug}
href={`#${item.slug}`}
className={cn(
"relative pl-3 py-1 text-[13px] no-underline transition-colors",
"relative py-1 pl-3 text-[0.8rem] no-underline transition-colors",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{isActive && (
<div className="absolute left-0 top-1 bottom-1 w-px bg-foreground rounded-full" />
<div className="bg-foreground absolute top-1 bottom-1 left-0 w-px rounded-full" />
)}
{item.title}
</a>

View File

@@ -62,12 +62,10 @@ export function DocsLayout({
toc = [],
}: DocsLayoutProps) {
return (
<div className="flex gap-8">
<div className="flex-1 min-w-0 max-w-3xl mx-auto pt-10 pb-20">
<div className="flex">
<div className="flex-1 min-w-0 max-w-[800px] lg:px-4 mx-auto pb-20 pt-12">
<DocsTitle title={title} description={description} />
<div className="mt-12 space-y-12">{children}</div>
{(prev || next) && (
<div className="flex items-center justify-between gap-4 mt-16">
{prev ? (
@@ -118,7 +116,7 @@ interface DocsSectionProps {
export function DocsSection({ title, children }: DocsSectionProps) {
const id = title ? slugify(title) : undefined;
return (
<section className="space-y-5 scroll-mt-24" id={id}>
<section className="space-y-5 scroll-m-24" id={id}>
{title && (
<h2 className="text-xl font-semibold tracking-tight text-foreground">
{title}

View File

@@ -3,13 +3,12 @@ import path from "path";
const EXAMPLES_DIR = path.join(
process.cwd(),
"src/app/docs/_components/examples"
"src/app/(main)/docs/_components/examples"
);
export function getExampleSource(filename: string): string {
const filePath = path.join(EXAMPLES_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
// Clean up the source for display:
return source.replace(/@\/registry\/map/g, "@/components/ui/map");
}

View File

@@ -10,7 +10,7 @@ import { AdvancedUsageExample } from "../_components/examples/advanced-usage-exa
import { CustomLayerExample } from "../_components/examples/custom-layer-example";
import { LayerMarkersExample } from "../_components/examples/layer-markers-example";
import { CodeBlock } from "../_components/code-block";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -136,6 +136,12 @@ export default function ApiReferencePage() {
description:
"Callback fired continuously as the viewport changes (during pan, zoom, rotate). Can be used alone to observe changes, or with viewport prop to enable controlled mode.",
},
{
name: "loading",
type: "boolean",
default: "false",
description: "Show a loading indicator on the map.",
},
]}
/>
</DocsSection>

View File

@@ -8,7 +8,7 @@ import { ComponentPreview } from "../_components/component-preview";
import { BasicMapExample } from "../_components/examples/basic-map-example";
import { ControlledMapExample } from "../_components/examples/controlled-map-example";
import { CustomStyleExample } from "../_components/examples/custom-style-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import ClusterExample from "../_components/examples/cluster-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import { MapControlsExample } from "../_components/examples/map-controls-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -0,0 +1,17 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { DocsSidebar } from "./_components/docs-sidebar";
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="container flex flex-col flex-1">
<SidebarProvider>
<DocsSidebar />
<main className="size-full">{children}</main>
</SidebarProvider>
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { ComponentPreview } from "../_components/component-preview";
import { MarkersExample } from "../_components/examples/markers-example";
import { PopupExample } from "../_components/examples/popup-example";
import { DraggableMarkerExample } from "../_components/examples/draggable-marker-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import { StandalonePopupExample } from "../_components/examples/standalone-popup-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -7,7 +7,7 @@ import {
import { ComponentPreview } from "../_components/component-preview";
import { RouteExample } from "../_components/examples/route-example";
import { OsrmRouteExample } from "../_components/examples/osrm-route-example";
import { getExampleSource } from "@/lib/get-example-source";
import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {

14
src/app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Header } from "@/components/header";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-background relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { slugToTitle } from "@/lib/utils";
import { blockComponents } from "@/registry/blocks/__index__";
import { notFound } from "next/navigation";
interface BlockViewPageProps {
params: Promise<{ name: string }>;
}
export const generateMetadata = async ({ params }: BlockViewPageProps) => {
const { name } = await params;
const Component = blockComponents[name];
if (!Component) {
return notFound();
}
const title = slugToTitle(name);
return {
title,
description: `View the ${title} block`,
};
};
export default async function BlockViewPage({ params }: BlockViewPageProps) {
const { name } = await params;
const Component = blockComponents[name];
if (!Component) {
notFound();
}
return (
<div className="bg-background min-h-screen">
<Component />
</div>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { docsNavigation, type NavItem } from "@/lib/docs-navigation";
import { cn } from "@/lib/utils";
export function DocsHeader({ className }: { className?: string }) {
const pathname = usePathname();
let activeItem: (NavItem & { groupTitle: string }) | null = null;
let groupHref = "/docs";
for (const group of docsNavigation) {
const item = group.items.find((navItem) => navItem.href === pathname);
if (item) {
activeItem = { ...item, groupTitle: group.title };
groupHref = group.items[0]?.href ?? "/docs";
break;
}
}
return (
<header
className={cn(
"w-full p-4 px-6 sticky top-0 z-50 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/70",
className
)}
>
<nav className="flex items-center gap-3">
<SidebarTrigger />
<div className="h-4 w-px shrink-0 bg-border" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/docs">Docs</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={groupHref}>
{activeItem?.groupTitle ?? "Overview"}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{pathname !== "/docs" && pathname !== "/docs/basic-map" && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0 truncate">
<BreadcrumbPage>
{activeItem?.title ?? "Documentation"}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</nav>
</header>
);
}

View File

@@ -1,19 +0,0 @@
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { DocsSidebar } from "./_components/docs-sidebar";
import { DocsHeader } from "./_components/docs-header";
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<DocsSidebar />
<SidebarInset>
<DocsHeader />
<main className="container">{children}</main>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import "@/styles/globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
import { TooltipProvider } from "@/components/ui/tooltip";
const geist = Geist({
subsets: ["latin"],
@@ -100,9 +102,9 @@ export default function RootLayout({
className={`${geist.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
<body className="font-sans antialiased min-h-screen flex flex-col">
<body className="font-sans antialiased">
<ThemeProvider>
<div className="flex-1">{children}</div>
<TooltipProvider>{children}</TooltipProvider>
<Analytics />
</ThemeProvider>
</body>

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center px-6">
<div className="min-h-[80vh] bg-background flex flex-col items-center justify-center px-6">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="relative">

View File

@@ -19,7 +19,7 @@ import {
CommandList,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { docsNavigation } from "@/lib/docs-navigation";
import { siteNavigation } from "@/lib/site-navigation";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
@@ -47,17 +47,17 @@ export function CommandSearch({ className }: { className?: string }) {
return (
<>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
aria-label="Search documentation"
className={cn(
"hidden group md:flex items-center w-[200px] dark:border-border/60 border-border/80 text-muted-foreground",
"hidden md:flex h-8 w-48 items-center gap-2 rounded-md border border-border/50 bg-muted/40 px-2.5 text-muted-foreground hover:bg-muted/70 hover:text-foreground",
className
)}
>
<SearchIcon className="size-3.5 shrink-0" />
<span>Search docs...</span>
<SearchIcon className="size-3.5" />
<span>Search...</span>
<KbdGroup className="ml-auto">
<Kbd></Kbd>
<Kbd>K</Kbd>
@@ -66,12 +66,12 @@ export function CommandSearch({ className }: { className?: string }) {
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Search Documentation"
description="Search for documentation pages and components"
title="Search..."
description="Jump to pages, components, and docs"
showCloseButton={false}
>
<CommandInput
placeholder="Search documentation..."
placeholder="Search..."
className="border-none text-sm h-10"
/>
<CommandList>
@@ -81,7 +81,7 @@ export function CommandSearch({ className }: { className?: string }) {
<span>No results found.</span>
</div>
</CommandEmpty>
{docsNavigation.map((group) => (
{siteNavigation.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((item) => (
<CommandItem

117
src/components/footer.tsx Normal file
View File

@@ -0,0 +1,117 @@
import Link from "next/link";
import { MapPin } from "lucide-react";
const footerLinks = {
product: [
{ label: "Documentation", href: "/docs" },
{ label: "Components", href: "/docs/basic-map" },
{ label: "Blocks", href: "/blocks" },
],
community: [
{
label: "GitHub",
href: "https://github.com/AnmolSaini16/mapcn",
external: true,
},
{
label: "Sponsor",
href: "https://github.com/sponsors/AnmolSaini16",
external: true,
},
],
resources: [
{ label: "MapLibre GL", href: "https://maplibre.org/", external: true },
{ label: "shadcn/ui", href: "https://ui.shadcn.com/", external: true },
{
label: "Tailwind CSS",
href: "https://tailwindcss.com/",
external: true,
},
],
};
export function Footer() {
return (
<footer className="mt-24 border-t md:mt-32">
<div className="container py-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
<div className="col-span-2 md:col-span-1">
<Link href="/" className="flex w-fit items-center gap-1.5">
<MapPin className="size-4 shrink-0" />
<span className="text-lg font-semibold tracking-tight">
mapcn
</span>
</Link>
<p className="text-muted-foreground mt-3 max-w-xs text-sm leading-relaxed">
Free & open-source, ready-to-use, customizable map components for
React.
</p>
<p className="text-muted-foreground mt-3 text-sm">
Built by{" "}
<Link
href="https://x.com/anmol16_"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
@anmol
</Link>
</p>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Product</h3>
<ul className="space-y-2.5">
{footerLinks.product.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Community</h3>
<ul className="space-y-2.5">
{footerLinks.community.map((link) => (
<li key={link.href}>
<Link
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Resources</h3>
<ul className="space-y-2.5">
{footerLinks.resources.map((link) => (
<li key={link.href}>
<Link
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,6 +1,8 @@
import { Heart } from "lucide-react";
import { Logo } from "@/components/logo";
import { MainNav } from "@/components/main-nav";
import { MobileNav } from "@/components/mobile-nav";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
@@ -15,11 +17,15 @@ import { cn } from "@/lib/utils";
export function Header({ className }: { className?: string }) {
return (
<header className={cn("w-full p-4 container", className)}>
<nav className="flex size-full items-center justify-between">
<Logo />
<header
className={cn("bg-background sticky top-0 z-50 h-14 w-full", className)}
>
<nav className="container flex size-full items-center gap-2">
<MobileNav />
<Logo className="hidden shrink-0 lg:flex" />
<MainNav className="hidden lg:flex" />
<div className="flex items-center gap-2 h-4.5">
<div className="ml-auto flex h-4.5 items-center gap-2">
<CommandSearch />
<Separator orientation="vertical" className="hidden md:block" />
<Tooltip>
@@ -30,8 +36,8 @@ export function Header({ className }: { className?: string }) {
target="_blank"
rel="noopener noreferrer"
>
<Heart className="size-4 text-pink-500" />
<span className="hidden md:inline">Sponsor</span>
<Heart className="size-3.5 text-pink-500" />
Sponsor
</a>
</Button>
</TooltipTrigger>

View File

@@ -1,16 +1,25 @@
import Link from "next/link";
import { MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
interface LogoProps {
className?: string;
onClick?: () => void;
}
export function Logo({ className }: LogoProps) {
export function Logo({ className, onClick }: LogoProps) {
return (
<Link href="/" className={cn("flex items-center gap-1.5 w-fit", className)}>
<MapPin className="size-4 shrink-0" />
<span className="font-semibold text-lg tracking-tight">mapcn</span>
</Link>
<Button
asChild
size="sm"
variant="ghost"
className={cn("px-2.5 text-base font-semibold", className)}
>
<Link href="/" onClick={onClick}>
<MapPin />
mapcn
</Link>
</Button>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { siteNavigation } from "@/lib/site-navigation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function MainNav({ className, ...props }: React.ComponentProps<"nav">) {
const navItems = siteNavigation
.find((group) => group.title === "Pages")
?.items.filter((item) => item.title !== "Home");
if (!navItems) return null;
return (
<nav className={cn("items-center gap-1", className)} {...props}>
{navItems.map((item) => (
<Button
key={item.href}
variant="ghost"
asChild
size="sm"
className="px-2.5"
>
<Link
href={item.href}
className="relative inline-flex items-center gap-1.5"
>
<span>{item.title}</span>
</Link>
</Button>
))}
</nav>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { siteNavigation } from "@/lib/site-navigation";
export function MobileNav() {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Open docs menu"
className="shrink-0 lg:hidden"
>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="rounded-r-xl">
<SheetHeader>
<SheetTitle className="sr-only">Sidebar</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto px-2">
<nav className="space-y-6">
{siteNavigation.map((group) => (
<div key={group.title}>
<h3 className="text-muted-foreground mb-2 px-2 text-sm font-medium">
{group.title}
</h3>
<ul>
{group.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className="flex items-center px-3 py-2.5"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { cn } from "@/lib/utils";
import { createContext, useContext } from "react";
type HeaderAlign = "center" | "left";
const PageHeaderContext = createContext<{ align: HeaderAlign }>({
align: "center",
});
function usePageHeaderContext() {
return useContext(PageHeaderContext);
}
interface PageHeaderProps {
children: React.ReactNode;
className?: string;
/** Show the dot grid background (default: true) */
showBackground?: boolean;
/** Header content alignment (default: center) */
align?: HeaderAlign;
}
function PageHeader({
children,
className,
showBackground = true,
align = "center",
}: PageHeaderProps) {
return (
<PageHeaderContext.Provider value={{ align }}>
<div className="relative">
{showBackground && (
<div className="pointer-events-none absolute inset-x-0 -inset-y-10 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.20] dark:opacity-[0.12]"
style={{
backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
backgroundSize: "24px 24px",
}}
/>
<div className="from-background to-background absolute inset-0 bg-linear-to-b via-transparent" />
</div>
)}
<section
className={cn(
"container mx-auto flex w-full max-w-6xl flex-col gap-4 py-16 md:py-24 lg:pt-26 lg:pb-24",
align === "center"
? "items-center text-center"
: "items-start text-left",
className,
)}
>
{children}
</section>
</div>
</PageHeaderContext.Provider>
);
}
interface PageHeaderHeadingProps {
children: React.ReactNode;
className?: string;
as?: "h1" | "h2";
}
function PageHeaderHeading({
children,
className,
as: Comp = "h1",
}: PageHeaderHeadingProps) {
const { align } = usePageHeaderContext();
return (
<Comp
className={cn(
"animate-fade-up max-w-4xl text-4xl font-semibold tracking-tight delay-100 sm:text-5xl md:text-6xl",
align === "center" ? "text-center" : "text-left",
className,
)}
>
<span className="from-foreground via-foreground to-foreground/65 bg-linear-to-b bg-clip-text text-transparent">
{children}
</span>
</Comp>
);
}
interface PageHeaderDescriptionProps {
children: React.ReactNode;
className?: string;
}
function PageHeaderDescription({
children,
className,
}: PageHeaderDescriptionProps) {
const { align } = usePageHeaderContext();
return (
<p
className={cn(
"text-muted-foreground animate-fade-up max-w-2xl leading-relaxed delay-200 sm:text-lg sm:leading-relaxed md:text-xl md:leading-relaxed",
align === "center" ? "text-center" : "text-left",
className,
)}
>
{children}
</p>
);
}
interface PageContentProps {
children: React.ReactNode;
className?: string;
}
function PageContent({ children, className }: PageContentProps) {
return (
<div className={cn("animate-fade-up w-full max-w-lg delay-300", className)}>
{children}
</div>
);
}
interface PageActionsProps {
children: React.ReactNode;
className?: string;
}
function PageActions({ children, className }: PageActionsProps) {
const { align } = usePageHeaderContext();
return (
<div
className={cn(
"animate-fade-up mt-3 flex flex-wrap items-center gap-3 delay-400",
align === "center" ? "justify-center" : "justify-start",
className,
)}
>
{children}
</div>
);
}
export {
PageHeader,
PageHeaderHeading,
PageHeaderDescription,
PageContent,
PageActions,
};

View File

@@ -60,7 +60,6 @@ export function ThemeToggle() {
size="icon-sm"
>
{resolvedTheme === "dark" ? <Moon /> : <Sun />}
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2 pr-1">

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,30 +1,32 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/60",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
@@ -34,7 +36,7 @@ const buttonVariants = cva(
size: "default",
},
}
);
)
function Button({
className,
@@ -44,9 +46,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
@@ -56,7 +58,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }

View File

@@ -1,18 +1,18 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"flex flex-col gap-4 rounded-xl border bg-card py-4 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,12 +20,12 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
className
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,17 +35,17 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -58,27 +58,27 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-4", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn("flex items-center px-4 [.border-t]:pt-4", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

357
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,357 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}

View File

@@ -17,7 +17,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
@@ -36,7 +36,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -48,9 +48,11 @@ function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
@@ -58,24 +60,26 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
@@ -108,7 +112,7 @@ function SheetTitle({
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
@@ -121,7 +125,7 @@ function SheetDescription({
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)

View File

@@ -1,57 +1,56 @@
"use client";
"use client"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd, KbdGroup } from "./kbd";
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext);
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context;
return context
}
function SidebarProvider({
@@ -63,36 +62,36 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState);
setOpenProp(openState)
} else {
_setOpen(openState);
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
);
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -101,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
event.preventDefault()
toggleSidebar()
}
};
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -125,7 +124,7 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
)
return (
<SidebarContext.Provider value={contextValue}>
@@ -140,7 +139,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
@@ -149,7 +148,7 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
)
}
function Sidebar({
@@ -160,25 +159,25 @@ function Sidebar({
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
);
)
}
if (isMobile) {
@@ -188,7 +187,7 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -203,12 +202,12 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@@ -245,13 +244,13 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
)
}
function SidebarTrigger({
@@ -259,40 +258,29 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar } = useSidebar()
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Toggle Sidebar{" "}
<KbdGroup>
<Kbd></Kbd>
<Kbd>{SIDEBAR_KEYBOARD_SHORTCUT.toUpperCase()}</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
);
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar } = useSidebar()
return (
<button
@@ -303,17 +291,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -321,13 +309,13 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
)
}
function SidebarInput({
@@ -338,10 +326,10 @@ function SidebarInput({
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -352,7 +340,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -363,7 +351,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
)
}
function SidebarSeparator({
@@ -374,10 +362,10 @@ function SidebarSeparator({
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -391,7 +379,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
);
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -402,7 +390,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
)
}
function SidebarGroupLabel({
@@ -410,20 +398,20 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
)
}
function SidebarGroupAction({
@@ -431,14 +419,14 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
@@ -446,7 +434,7 @@ function SidebarGroupAction({
)}
{...props}
/>
);
)
}
function SidebarGroupContent({
@@ -460,7 +448,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)}
{...props}
/>
);
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -471,7 +459,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -482,11 +470,11 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)}
{...props}
/>
);
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -505,7 +493,7 @@ const sidebarMenuButtonVariants = cva(
size: "default",
},
}
);
)
function SidebarMenuButton({
asChild = false,
@@ -516,12 +504,12 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
@@ -532,16 +520,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
)
if (!tooltip) {
return button;
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
}
return (
@@ -554,7 +542,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
);
)
}
function SidebarMenuAction({
@@ -563,17 +551,17 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
@@ -581,12 +569,12 @@ function SidebarMenuAction({
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
)
}
function SidebarMenuBadge({
@@ -598,7 +586,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
@@ -608,7 +596,7 @@ function SidebarMenuBadge({
)}
{...props}
/>
);
)
}
function SidebarMenuSkeleton({
@@ -616,12 +604,12 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
@@ -646,7 +634,7 @@ function SidebarMenuSkeleton({
}
/>
</div>
);
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -655,13 +643,13 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
)
}
function SidebarMenuSubItem({
@@ -675,7 +663,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
)
}
function SidebarMenuSubButton({
@@ -685,11 +673,11 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a";
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
@@ -698,7 +686,7 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
@@ -707,7 +695,7 @@ function SidebarMenuSubButton({
)}
{...props}
/>
);
)
}
export {
@@ -735,4 +723,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
}

View File

@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)

View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -1,9 +1,9 @@
"use client";
"use client"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
@@ -15,23 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
);
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
@@ -46,16 +42,16 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

77
src/lib/blocks.ts Normal file
View File

@@ -0,0 +1,77 @@
import registry from "../../registry.json";
export interface RegistryBlockItem {
name: string;
type: string;
title?: string;
description?: string;
files?: Array<{ path: string; target?: string }>;
registryDependencies?: string[];
categories?: string[];
meta?: { iframeHeight?: string };
}
interface RegistrySchema {
items: RegistryBlockItem[];
}
export interface FileTree {
name: string;
path?: string;
children?: FileTree[];
}
const typedRegistry = registry as RegistrySchema;
export function getAllBlocks(): RegistryBlockItem[] {
return typedRegistry.items
.filter((item) => item.type === "registry:block")
.map((item) => ({
name: item.name,
type: item.type,
title: item.title ?? item.name,
description: item.description,
files: item.files ?? [],
registryDependencies: item.registryDependencies ?? [],
categories: item.categories ?? [],
meta: item.meta,
}));
}
export function createFileTreeForRegistryItemFiles(
files: Array<{ path: string; target?: string }>,
): FileTree[] {
const root: FileTree[] = [];
for (const file of files) {
const filePath = file.target ?? file.path;
const parts = filePath.split("/");
let currentLevel = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
const existingNode = currentLevel.find((node) => node.name === part);
if (existingNode) {
if (isFile) {
existingNode.path = filePath;
} else {
currentLevel = existingNode.children!;
}
} else {
const newNode: FileTree = isFile
? { name: part, path: filePath }
: { name: part, children: [] };
currentLevel.push(newNode);
if (!isFile) {
currentLevel = newNode.children!;
}
}
}
}
return root;
}

View File

@@ -0,0 +1,11 @@
import fs from "fs";
import path from "path";
const SRC_DIR = process.cwd();
export function getBlockFileSource(registryPath: string): string {
const filePath = path.join(SRC_DIR, registryPath);
const source = fs.readFileSync(filePath, "utf-8");
return source.replace(/@\/registry\/map/g, "@/components/ui/map");
}

View File

@@ -1,29 +1,37 @@
import {
Map,
BookOpen,
Code,
Braces,
Code,
Home,
Layers,
LayoutGrid,
LucideIcon,
Map,
MapPin,
MessageSquare,
Route,
Wrench,
Settings,
Layers,
LucideIcon,
Wrench,
} from "lucide-react";
export interface NavItem {
export interface MainNavItem {
href: string;
label: string;
}
export interface SiteNavigationItem {
title: string;
href: string;
icon: LucideIcon;
new?: boolean;
}
export interface NavGroup {
export interface SiteNavigationGroup {
title: string;
items: NavItem[];
items: SiteNavigationItem[];
}
export const docsNavigation: NavGroup[] = [
export const docsNavigation: SiteNavigationGroup[] = [
{
title: "Basics",
items: [
@@ -45,3 +53,18 @@ export const docsNavigation: NavGroup[] = [
],
},
];
const navItems: SiteNavigationItem[] = [
{ title: "Home", href: "/", icon: Home },
{ title: "Docs", href: "/docs", icon: BookOpen },
{ title: "Components", href: "/docs/basic-map", icon: Map },
{ title: "Blocks", href: "/blocks", icon: LayoutGrid },
];
export const siteNavigation: SiteNavigationGroup[] = [
{
title: "Pages",
items: navItems,
},
...docsNavigation,
];

View File

@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function slugToTitle(name: string): string {
return name.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { RegistryBlockItem } from "@/lib/blocks";
export const blockComponents: Record<
RegistryBlockItem["name"],
React.LazyExoticComponent<React.ComponentType<object>>
> = {
"analytics-map": React.lazy(() =>
import("./analytics-map/page").then((mod) => ({
default: mod.AnalyticsMapBlock,
}))
),
heatmap: React.lazy(() =>
import("./heatmap/page").then((mod) => ({
default: mod.HeatmapBlock,
}))
),
"delivery-tracker": React.lazy(() =>
import("./delivery-tracker/page").then((mod) => ({
default: mod.DeliveryBlock,
}))
),
"logistics-network": React.lazy(() =>
import("./logistics-network/page").then((mod) => ({
default: mod.LogisticsNetworkBlock,
}))
),
};

View File

@@ -0,0 +1,45 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { type BreakdownRow } from "../data";
interface BreakdownCardProps {
title: string;
rows: BreakdownRow[];
}
export function BreakdownCard({ title, rows }: BreakdownCardProps) {
const maxRowValue =
rows.length > 0 ? Math.max(...rows.map((row) => row.value)) : 0;
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground mb-2 flex items-center justify-between text-[11px] tracking-wider uppercase">
<span>{title}</span>
<span>Visitors</span>
</div>
<div className="space-y-3">
{rows.map((row) => (
<div key={row.label} className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-foreground/90 truncate">{row.label}</span>
<span className="text-foreground font-medium">{row.value}</span>
</div>
<div className="bg-muted h-1 rounded-full">
<div
className="h-full rounded-full bg-blue-500/85"
style={{ width: `${(row.value / maxRowValue) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ChartContainer } from "@/components/ui/chart";
import { TrendingUp } from "lucide-react";
import { Area, AreaChart, Cell, Pie, PieChart } from "recharts";
import {
deviceCategoryChartConfig,
deviceCategoryData,
usersPerDay,
usersPerDayChartConfig,
} from "../data";
function MetricChart() {
return (
<ChartContainer
config={usersPerDayChartConfig}
className="aspect-auto h-8 w-full"
>
<AreaChart data={usersPerDay} margin={{ left: 4, right: 4, top: 4 }}>
<defs>
<linearGradient id="usersGradient" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-users)"
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor="var(--color-users)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
type="natural"
dataKey="users"
stroke="var(--color-users)"
strokeWidth={1.5}
fill="url(#usersGradient)"
/>
</AreaChart>
</ChartContainer>
);
}
export function OverviewCard() {
return (
<Card className="bg-card/70 absolute top-4 left-4 z-10 w-60 backdrop-blur-sm">
<CardHeader>
<div>
<p className="text-muted-foreground pb-2 text-[10px] tracking-wider uppercase">
Users in last 7 days
</p>
<p className="text-3xl leading-none font-semibold">3,803</p>
</div>
</CardHeader>
<CardContent>
<MetricChart />
<div className="mt-4 flex items-center gap-1.5 text-xs">
<TrendingUp className="size-3 text-emerald-500" />
<span className="font-medium text-emerald-500">+12.5%</span>
<span className="text-muted-foreground">vs previous 7 days</span>
</div>
<div className="border-border/60 mt-4 border-t pt-4">
<p className="text-muted-foreground text-[10px] tracking-wider uppercase">
Device category in last 7 days
</p>
<ChartContainer
config={deviceCategoryChartConfig}
className="mx-auto mt-3 aspect-square h-32 w-32"
>
<PieChart>
<Pie
data={deviceCategoryData}
dataKey="value"
nameKey="name"
innerRadius={32}
outerRadius={52}
strokeWidth={2}
>
{deviceCategoryData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="mt-3 grid grid-cols-3 gap-2">
{deviceCategoryData.map((device) => (
<div key={device.name} className="text-center">
<p className="text-muted-foreground flex items-center justify-center gap-1.5 text-[10px] tracking-wide uppercase">
<span
className="size-2 rounded-full"
style={{ backgroundColor: device.fill }}
/>
{device.name}
</p>
<p className="text-foreground mt-1 leading-none font-medium tabular-nums">
{device.value}%
</p>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,99 @@
import { type ChartConfig } from "@/components/ui/chart";
export interface LocationPoint {
city: string;
lng: number;
lat: number;
size: number;
}
export interface BreakdownRow {
label: string;
value: number;
}
export const locations: LocationPoint[] = [
{ city: "San Francisco", lng: -122.4194, lat: 37.7749, size: 16 },
{ city: "New York", lng: -74.006, lat: 40.7128, size: 15 },
{ city: "Toronto", lng: -79.3832, lat: 43.6532, size: 11 },
{ city: "Mexico City", lng: -99.1332, lat: 19.4326, size: 10 },
{ city: "Sao Paulo", lng: -46.6333, lat: -23.5505, size: 12 },
{ city: "Buenos Aires", lng: -58.3816, lat: -34.6037, size: 9 },
{ city: "London", lng: -0.1276, lat: 51.5074, size: 14 },
{ city: "Berlin", lng: 13.405, lat: 52.52, size: 11 },
{ city: "Paris", lng: 2.3522, lat: 48.8566, size: 13 },
{ city: "Madrid", lng: -3.7038, lat: 40.4168, size: 10 },
{ city: "Cairo", lng: 31.2357, lat: 30.0444, size: 9 },
{ city: "Lagos", lng: 3.3792, lat: 6.5244, size: 10 },
{ city: "Mumbai", lng: 72.8777, lat: 19.076, size: 13 },
{ city: "Dubai", lng: 55.2708, lat: 25.2048, size: 11 },
{ city: "Seoul", lng: 126.978, lat: 37.5665, size: 12 },
{ city: "Singapore", lng: 103.8198, lat: 1.3521, size: 10 },
{ city: "Tokyo", lng: 139.6917, lat: 35.6895, size: 12 },
{ city: "Sydney", lng: 151.2093, lat: -33.8688, size: 9 },
{ city: "Auckland", lng: 174.7633, lat: -36.8485, size: 8 },
];
export const usersPerDay = [
{ day: "Mon", users: 320 },
{ day: "Tue", users: 410 },
{ day: "Wed", users: 560 },
{ day: "Thu", users: 640 },
{ day: "Fri", users: 780 },
{ day: "Sat", users: 690 },
{ day: "Sun", users: 720 },
];
export const usersPerDayChartConfig = {
users: {
label: "Users",
color: "var(--color-blue-500)",
},
} satisfies ChartConfig;
export const deviceCategoryData = [
{ name: "Desktop", value: 73.3, fill: "var(--color-blue-500)" },
{ name: "Mobile", value: 25.0, fill: "var(--color-blue-400)" },
{ name: "Tablet", value: 1.7, fill: "var(--color-blue-300)" },
];
export const deviceCategoryChartConfig = {
desktop: { label: "Desktop", color: "var(--color-blue-500)" },
mobile: { label: "Mobile", color: "var(--color-blue-400)" },
tablet: { label: "Tablet", color: "var(--color-blue-300)" },
} satisfies ChartConfig;
export const visitedPagesRows: BreakdownRow[] = [
{ label: "Home", value: 31 },
{ label: "Pricing", value: 23 },
{ label: "Docs / Basic Map", value: 18 },
{ label: "Installation", value: 12 },
{ label: "Components", value: 9 },
{ label: "Blog", value: 6 },
];
export const countriesRows: BreakdownRow[] = [
{ label: "United States", value: 27 },
{ label: "India", value: 14 },
{ label: "United Kingdom", value: 8 },
{ label: "Germany", value: 6 },
{ label: "Japan", value: 4 },
{ label: "Australia", value: 2 },
];
export const referrersRows: BreakdownRow[] = [
{ label: "google", value: 38 },
{ label: "direct", value: 26 },
{ label: "github.com", value: 19 },
{ label: "x.com", value: 11 },
{ label: "ui.shadcn.com", value: 8 },
{ label: "other", value: 5 },
];
export const browsersRows: BreakdownRow[] = [
{ label: "Chrome", value: 52 },
{ label: "Safari", value: 21 },
{ label: "Firefox", value: 14 },
{ label: "Edge", value: 8 },
{ label: "Other", value: 5 },
];

View File

@@ -0,0 +1,83 @@
"use client";
import {
Map,
MapControls,
MapMarker,
MarkerContent,
MarkerTooltip,
} from "@/registry/map";
import { OverviewCard } from "./components/overview-card";
import { BreakdownCard } from "./components/breakdown-card";
import {
locations,
visitedPagesRows,
countriesRows,
referrersRows,
browsersRows,
} from "./data";
const MAP_HEIGHT = "38rem";
export function AnalyticsMapBlock() {
return (
<div
className="bg-background relative min-h-screen"
style={{ "--map-height": MAP_HEIGHT } as React.CSSProperties}
>
<div className="relative h-(--map-height)">
<Map
center={[-2, 16]}
zoom={1.5}
scrollZoom={false}
renderWorldCopies={true}
>
<MapControls showFullscreen />
{locations.map((location) => (
<MapMarker
key={location.city}
longitude={location.lng}
latitude={location.lat}
>
<MarkerContent>
<div
className="rounded-full bg-blue-500/70"
style={{
width: location.size * 3,
height: location.size * 3,
}}
/>
</MarkerContent>
<MarkerTooltip
offset={20}
className="bg-background text-foreground border"
>
<p className="text-muted-foreground font-medium">
{location.city}
</p>
<p className="mt-1">
<span className="font-medium tabular-nums">
{location.size}
</span>{" "}
active users
</p>
</MarkerTooltip>
</MapMarker>
))}
</Map>
<div
className="via-background/30 to-background pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-linear-to-b from-transparent"
aria-hidden
/>
<OverviewCard />
</div>
<div className="grid gap-4 p-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<BreakdownCard title="Visited pages" rows={visitedPagesRows} />
<BreakdownCard title="Referrers" rows={referrersRows} />
<BreakdownCard title="Countries" rows={countriesRows} />
<BreakdownCard title="Browsers" rows={browsersRows} />
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Clock3, Utensils, Truck, UserRound } from "lucide-react";
import {
Map,
MapMarker,
MapRoute,
MarkerContent,
MarkerTooltip,
} from "@/registry/map";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface DeliveryMeal {
name: string;
price: string;
quantity: number;
}
interface OsrmRouteData {
coordinates: [number, number][];
duration: number;
distance: number;
}
const deliveryMeals: DeliveryMeal[] = [
{
name: "Spicy Tofu Grain Bowl",
price: "$44.00",
quantity: 1,
},
{
name: "Herb Chicken Rice Box",
price: "$58.00",
quantity: 2,
},
{
name: "Roasted Veggie Wrap",
price: "$29.00",
quantity: 1,
},
];
const pickup = { lng: -122.466, lat: 37.716 };
const dropoff = { lng: -122.399, lat: 37.683 };
function formatDistance(meters?: number) {
if (!meters) return "--";
if (meters < 1000) return `${Math.round(meters)} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
function formatDuration(seconds?: number) {
if (!seconds) return "--";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export function DeliveryBlock() {
const [routeData, setRouteData] = useState<OsrmRouteData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchRoute() {
setLoading(true);
try {
const response = await fetch(
`https://router.project-osrm.org/route/v1/driving/${pickup.lng},${pickup.lat};${dropoff.lng},${dropoff.lat}?overview=full&geometries=geojson`
);
const data = await response.json();
const route = data?.routes?.[0];
if (!route?.geometry?.coordinates) return;
setRouteData({
coordinates: route.geometry.coordinates as [number, number][],
duration: route.duration as number,
distance: route.distance as number,
});
} catch (error) {
console.error("Failed to fetch route:", error);
} finally {
setLoading(false);
}
}
fetchRoute();
}, []);
const progressCoordinates = useMemo(() => {
const progressCount = Math.max(
2,
Math.floor(
(routeData?.coordinates?.length ?? 0) * (routeData ? 0.62 : 0.66)
)
);
return routeData?.coordinates?.slice(0, progressCount) ?? [];
}, [routeData]);
const courierPosition = progressCoordinates[progressCoordinates.length - 1];
return (
<div className="p-8">
<div className="grid md:grid-cols-[1.05fr_1fr] bg-sidebar rounded-lg border md:h-[600px] max-w-7xl mx-auto">
<div className="p-5 md:p-6 flex flex-col">
<div className="space-y-1">
<h3 className="text-2xl font-semibold tracking-tight">
Track Delivery
</h3>
<p className="text-sm text-muted-foreground">Mon Feb 10 - 2-3 PM</p>
</div>
<Card className="mt-5">
<CardHeader>
<CardTitle className="font-medium">
Order items ({deliveryMeals.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{deliveryMeals.map((meal) => (
<div key={meal.name} className="flex items-center gap-3">
<div className="grid size-8 place-items-center rounded-full bg-muted text-xs font-medium">
<Utensils className="size-4 text-muted-foreground" />
</div>
<div className="min-w-4 flex-1">
<p className="truncate text-sm font-medium pb-1">
{meal.name}
</p>
<p className="text-xs text-muted-foreground">
{meal.price}
</p>
</div>
<Badge
variant="secondary"
className="h-6 rounded-full px-2.5"
>
x{meal.quantity}
</Badge>
</div>
))}
<div className="flex items-center justify-between border-t border-border/60 pt-3 text-sm">
<span className="text-muted-foreground">Bundle total</span>
<span className="font-medium">$189.00</span>
</div>
</CardContent>
</Card>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Pickup confirmed
</p>
<p className="text-sm font-medium">Mon, Feb 10 at 1:48 PM</p>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Remaining travel
</p>
<p className="text-sm font-medium">
{formatDuration(routeData?.duration)}
</p>
</CardContent>
</Card>
</div>
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button size="sm" className="gap-1.5">
<Clock3 className="size-4" />
View timeline
</Button>
<Button variant="outline" size="sm" className="gap-1.5">
<UserRound className="size-4" />
Contact courier
</Button>
</div>
</div>
<div className="relative h-[400px] overflow-hidden rounded-xl md:h-full shadow-sm">
<Map
loading={loading}
center={[-122.435, 37.696]}
zoom={12}
minZoom={10}
maxZoom={16}
styles={{
light: "https://tiles.openfreemap.org/styles/bright",
dark: "https://tiles.openfreemap.org/styles/dark",
}}
>
<MapRoute
id="delivery-full-route"
coordinates={routeData?.coordinates ?? []}
color="#5b6572"
width={5.2}
opacity={0.3}
interactive={false}
/>
<MapRoute
id="delivery-progress-route"
coordinates={progressCoordinates}
color="#3b82f6"
width={6}
opacity={0.95}
interactive={false}
/>
{courierPosition && (
<MapMarker
longitude={courierPosition[0]}
latitude={courierPosition[1]}
offset={[0, 10]}
>
<MarkerContent>
<div className="relative grid size-9 place-items-center rounded-full bg-emerald-500 dark:bg-emerald-600">
<Truck className="size-4 text-white" />
</div>
</MarkerContent>
<MarkerTooltip>
<div className="space-y-0.5 text-xs">
<p className="font-medium">
Order {formatDuration(routeData?.duration)} away
</p>
<p className="text-muted-foreground">
Route {formatDistance(routeData?.distance)}
</p>
</div>
</MarkerTooltip>
</MapMarker>
)}
<MapMarker longitude={pickup.lng} latitude={pickup.lat}>
<MarkerContent>
<div className="size-4 rounded-full border-2 border-white bg-emerald-500 shadow-sm" />
</MarkerContent>
<MarkerTooltip>
<p className="text-xs font-medium">Origin</p>
</MarkerTooltip>
</MapMarker>
<MapMarker longitude={dropoff.lng} latitude={dropoff.lat}>
<MarkerContent>
<div className="size-4 rounded-full border-2 border-white bg-rose-500 shadow-sm" />
</MarkerContent>
<MarkerTooltip>
<p className="text-xs font-medium">Destination</p>
</MarkerTooltip>
</MapMarker>
</Map>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More