basuicn 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +96 -96
  2. package/README_CLI.md +44 -44
  3. package/dist/assets/index-1YAQdTE0.css +2 -0
  4. package/dist/assets/index-BsQ6nn74.js +237 -0
  5. package/dist/index.html +4 -22
  6. package/dist/ui-cli.cjs +1 -1
  7. package/dist/ui-cli.js +124 -0
  8. package/package.json +1 -1
  9. package/registry.json +40 -40
  10. package/scripts/build-cli.mjs +12 -12
  11. package/scripts/generate-theme-css.ts +74 -74
  12. package/scripts/ui-cli.ts +1 -1
  13. package/dist/assets/abap-CglpPUUh.js +0 -1
  14. package/dist/assets/actionscript-3-CGv72Q9E.js +0 -1
  15. package/dist/assets/ada-CfxXaXwr.js +0 -1
  16. package/dist/assets/andromeeda-DXsn6mTE.js +0 -1
  17. package/dist/assets/angular-html-Bc8jBW13.js +0 -1
  18. package/dist/assets/angular-ts-BKEiR2gl.js +0 -1
  19. package/dist/assets/apache-D5xAQKSB.js +0 -1
  20. package/dist/assets/apex-qgfedJV2.js +0 -1
  21. package/dist/assets/apl-akXtgiiz.js +0 -1
  22. package/dist/assets/applescript-DqumQxTt.js +0 -1
  23. package/dist/assets/ara-Ditv3ow6.js +0 -1
  24. package/dist/assets/asciidoc-DL8QwiT7.js +0 -1
  25. package/dist/assets/asm-K6TMxl3o.js +0 -1
  26. package/dist/assets/astro-CN0Tv6yD.js +0 -1
  27. package/dist/assets/aurora-x-DGv-xZaa.js +0 -1
  28. package/dist/assets/awk-CYSfQSDT.js +0 -1
  29. package/dist/assets/ayu-dark-DGBaJbpH.js +0 -1
  30. package/dist/assets/ayu-light-BQrGPVs2.js +0 -1
  31. package/dist/assets/ayu-mirage-Do-5o2VL.js +0 -1
  32. package/dist/assets/ballerina-BE-8n54s.js +0 -1
  33. package/dist/assets/base-80a1f760-BubTTHfk.js +0 -1
  34. package/dist/assets/bat-B1zTc1QZ.js +0 -1
  35. package/dist/assets/beancount-DhObrvif.js +0 -1
  36. package/dist/assets/berry-Cnbvthv1.js +0 -1
  37. package/dist/assets/bibtex-pp5SrwZy.js +0 -1
  38. package/dist/assets/bicep-BL6eYxbs.js +0 -1
  39. package/dist/assets/bird2-D57-fQHo.js +0 -1
  40. package/dist/assets/blade-e8vwYS05.js +0 -1
  41. package/dist/assets/bsl-B0twaj2j.js +0 -1
  42. package/dist/assets/c-BbKTZcBW.js +0 -1
  43. package/dist/assets/c3-BHTco5Hb.js +0 -1
  44. package/dist/assets/cadence-CoPxW0qb.js +0 -1
  45. package/dist/assets/cairo-DASKFAWI.js +0 -1
  46. package/dist/assets/catppuccin-frappe-9fYwMJCf.js +0 -1
  47. package/dist/assets/catppuccin-latte-BUiKH1yZ.js +0 -1
  48. package/dist/assets/catppuccin-macchiato-ZmO2e0M_.js +0 -1
  49. package/dist/assets/catppuccin-mocha-CFwbXQfO.js +0 -1
  50. package/dist/assets/chunk-BEqpzyXh.js +0 -1
  51. package/dist/assets/clarity-DVUGm1xP.js +0 -1
  52. package/dist/assets/clojure-BgXJG6FG.js +0 -1
  53. package/dist/assets/cmake-CZndsvm6.js +0 -1
  54. package/dist/assets/cobol-DYVFfDXp.js +0 -1
  55. package/dist/assets/codeowners-Dd7a447M.js +0 -1
  56. package/dist/assets/codeql-XJR6llG-.js +0 -1
  57. package/dist/assets/coffee--RG78j0v.js +0 -1
  58. package/dist/assets/common-lisp-BOMbLvgT.js +0 -1
  59. package/dist/assets/consoleHook-59e792cb-3dE1G69s.js +0 -2
  60. package/dist/assets/coq-DnhmQYDa.js +0 -1
  61. package/dist/assets/cpp-B8wbpQ1o.js +0 -1
  62. package/dist/assets/crystal-2t0qtInQ.js +0 -1
  63. package/dist/assets/csharp-BdEyRqBm.js +0 -1
  64. package/dist/assets/css-BGJ_Me92.js +0 -1
  65. package/dist/assets/csv-CXchYFbz.js +0 -1
  66. package/dist/assets/cue-D7YpBZvd.js +0 -1
  67. package/dist/assets/cypher-XIm1N8YJ.js +0 -1
  68. package/dist/assets/d-DvB0pbZH.js +0 -1
  69. package/dist/assets/dark-plus-BUdpMFIA.js +0 -1
  70. package/dist/assets/dart-CJ0nYDsz.js +0 -1
  71. package/dist/assets/dax-CEynWJsU.js +0 -1
  72. package/dist/assets/desktop-Dk-YiYS_.js +0 -1
  73. package/dist/assets/diff-abLZ-xM1.js +0 -1
  74. package/dist/assets/docker-BT48KsRJ.js +0 -1
  75. package/dist/assets/dotenv-NOMQQbM0.js +0 -1
  76. package/dist/assets/dracula-BUW0ZyNO.js +0 -1
  77. package/dist/assets/dracula-soft-BMjAt1_u.js +0 -1
  78. package/dist/assets/dream-maker-D26TP1Q3.js +0 -1
  79. package/dist/assets/edge-CxTqP9J2.js +0 -1
  80. package/dist/assets/elixir-rfdqmTwN.js +0 -1
  81. package/dist/assets/elm-b1gwuH2v.js +0 -1
  82. package/dist/assets/emacs-lisp-CgQmnPzZ.js +0 -1
  83. package/dist/assets/erb-BzQ154zj.js +0 -1
  84. package/dist/assets/erlang-DnbJbitJ.js +0 -1
  85. package/dist/assets/everforest-dark-hC6a272f.js +0 -1
  86. package/dist/assets/everforest-light-BTpbYY5G.js +0 -1
  87. package/dist/assets/fennel-DWOL-Ewx.js +0 -1
  88. package/dist/assets/fish-CQPQq0pQ.js +0 -1
  89. package/dist/assets/fluent-BYdsr2M8.js +0 -1
  90. package/dist/assets/fortran-fixed-form-Bse4XUyD.js +0 -1
  91. package/dist/assets/fortran-free-form-C_EtY4F9.js +0 -1
  92. package/dist/assets/fsharp-Dy8OnZ0O.js +0 -1
  93. package/dist/assets/gdresource-BD_7yRHA.js +0 -1
  94. package/dist/assets/gdscript-CH_tD-VB.js +0 -1
  95. package/dist/assets/gdshader-BemFblFP.js +0 -1
  96. package/dist/assets/genie-hCPg_eq7.js +0 -1
  97. package/dist/assets/gherkin-CO0ypD1P.js +0 -1
  98. package/dist/assets/git-commit-CIMjlyHf.js +0 -1
  99. package/dist/assets/git-rebase-BhLC-wcM.js +0 -1
  100. package/dist/assets/github-dark-cA72fqmI.js +0 -1
  101. package/dist/assets/github-dark-default-DiYTFvY5.js +0 -1
  102. package/dist/assets/github-dark-dimmed-D-uBHzyG.js +0 -1
  103. package/dist/assets/github-dark-high-contrast-BrUSkckI.js +0 -1
  104. package/dist/assets/github-light-default-BT-6Vf7C.js +0 -1
  105. package/dist/assets/github-light-high-contrast-DqmEyTTc.js +0 -1
  106. package/dist/assets/github-light-piFPqU8R.js +0 -1
  107. package/dist/assets/gleam-CWJA_H5i.js +0 -1
  108. package/dist/assets/glimmer-js-UABHyGyr.js +0 -1
  109. package/dist/assets/glimmer-ts-BZodzUJU.js +0 -1
  110. package/dist/assets/glsl-DB4OjpDf.js +0 -1
  111. package/dist/assets/gn-nlTwLSjP.js +0 -1
  112. package/dist/assets/gnuplot-wYV0MbPl.js +0 -1
  113. package/dist/assets/go-Dhd8RnZp.js +0 -1
  114. package/dist/assets/graphql-CjkeIubW.js +0 -1
  115. package/dist/assets/groovy-BusafOA8.js +0 -1
  116. package/dist/assets/gruvbox-dark-hard-BrrZYoPF.js +0 -1
  117. package/dist/assets/gruvbox-dark-medium-DsxcLuUs.js +0 -1
  118. package/dist/assets/gruvbox-dark-soft-DQXOs_GH.js +0 -1
  119. package/dist/assets/gruvbox-light-hard-CtHwj_cB.js +0 -1
  120. package/dist/assets/gruvbox-light-medium-CPJMdgXQ.js +0 -1
  121. package/dist/assets/gruvbox-light-soft-C4Xh9Hc-.js +0 -1
  122. package/dist/assets/hack-hTulK3E0.js +0 -1
  123. package/dist/assets/haml-0xmzGOI8.js +0 -1
  124. package/dist/assets/handlebars-BXRDjB0q.js +0 -1
  125. package/dist/assets/haskell-CaDqJkn4.js +0 -1
  126. package/dist/assets/haxe-Dxg-8_J7.js +0 -1
  127. package/dist/assets/hcl-Bjg-rMJ0.js +0 -1
  128. package/dist/assets/hjson-BMbeOOMg.js +0 -1
  129. package/dist/assets/hlsl-D-cuwCFV.js +0 -1
  130. package/dist/assets/horizon-BofYY9of.js +0 -1
  131. package/dist/assets/horizon-bright-BtJSJLk4.js +0 -1
  132. package/dist/assets/houston-BIBYxFpQ.js +0 -1
  133. package/dist/assets/html-DNHpMnNu.js +0 -1
  134. package/dist/assets/html-derivative-BBCc_afp.js +0 -1
  135. package/dist/assets/http-BJnSZ0fu.js +0 -1
  136. package/dist/assets/hurl-DVkxBugG.js +0 -1
  137. package/dist/assets/hxml-CGx66ebi.js +0 -1
  138. package/dist/assets/hy-Cye3Jt7O.js +0 -1
  139. package/dist/assets/imba-CjkQtgD2.js +0 -1
  140. package/dist/assets/index-599aeaf7-tfuZHJ9A.js +0 -16
  141. package/dist/assets/index-DMOps9TG.js +0 -1893
  142. package/dist/assets/index-DReVoGMp.css +0 -1
  143. package/dist/assets/ini-BvMtfDNl.js +0 -1
  144. package/dist/assets/java-D2XAnJB0.js +0 -1
  145. package/dist/assets/javascript-X-FQYNvg.js +0 -1
  146. package/dist/assets/jinja-BnJYnQiG.js +0 -1
  147. package/dist/assets/jison-DnA7sY80.js +0 -1
  148. package/dist/assets/json-SC38HrRr.js +0 -1
  149. package/dist/assets/json5-BCxDPjxU.js +0 -1
  150. package/dist/assets/jsonc-BV8VFjqO.js +0 -1
  151. package/dist/assets/jsonl-DHrr7c-M.js +0 -1
  152. package/dist/assets/jsonnet-xURar8mu.js +0 -1
  153. package/dist/assets/jssm-DtoLUQAt.js +0 -1
  154. package/dist/assets/jsx-bsCPoRSN.js +0 -1
  155. package/dist/assets/julia-Dk81kxcu.js +0 -1
  156. package/dist/assets/just-DvZye5TV.js +0 -1
  157. package/dist/assets/kanagawa-dragon-COwlYgQB.js +0 -1
  158. package/dist/assets/kanagawa-lotus-GZWqyq_E.js +0 -1
  159. package/dist/assets/kanagawa-wave-CP4uw3DX.js +0 -1
  160. package/dist/assets/kdl-DGo2N7N5.js +0 -1
  161. package/dist/assets/kotlin-Cm4W8Bbs.js +0 -1
  162. package/dist/assets/kusto-DNFwIuzY.js +0 -1
  163. package/dist/assets/laserwave-DfABGYsD.js +0 -1
  164. package/dist/assets/latex-D3SwBEWn.js +0 -1
  165. package/dist/assets/lean-D-8a6Wwv.js +0 -1
  166. package/dist/assets/less-B-qZzFKw.js +0 -1
  167. package/dist/assets/light-plus-DS_rmE1N.js +0 -1
  168. package/dist/assets/liquid-CVass2H-.js +0 -1
  169. package/dist/assets/llvm-Ch-k6TUk.js +0 -1
  170. package/dist/assets/log-BGwbCG2S.js +0 -1
  171. package/dist/assets/logo-DHGHbFWy.js +0 -1
  172. package/dist/assets/lua-D_mthnvY.js +0 -1
  173. package/dist/assets/luau-qsvm_nYh.js +0 -1
  174. package/dist/assets/make-Dlfjp_Q8.js +0 -1
  175. package/dist/assets/markdown-COh4ruD_.js +0 -1
  176. package/dist/assets/marko-LZp-7Vlz.js +0 -1
  177. package/dist/assets/material-theme-Ccrl1jie.js +0 -1
  178. package/dist/assets/material-theme-darker-CcOH0iHe.js +0 -1
  179. package/dist/assets/material-theme-lighter-DoKpC7NM.js +0 -1
  180. package/dist/assets/material-theme-ocean-BcvBZHgH.js +0 -1
  181. package/dist/assets/material-theme-palenight-DIL4EAzl.js +0 -1
  182. package/dist/assets/matlab-CBqMuw7w.js +0 -1
  183. package/dist/assets/mdc-BWMWBj66.js +0 -1
  184. package/dist/assets/mdx-BjoJxHxW.js +0 -1
  185. package/dist/assets/mermaid-DjAh2yR5.js +0 -1
  186. package/dist/assets/min-dark-CbwghdE5.js +0 -1
  187. package/dist/assets/min-light-_F39d_94.js +0 -1
  188. package/dist/assets/mipsasm-DA1H0O78.js +0 -1
  189. package/dist/assets/mojo-BVgjRjTn.js +0 -1
  190. package/dist/assets/monokai-CxgvaWkX.js +0 -1
  191. package/dist/assets/moonbit-tKY94ZOM.js +0 -1
  192. package/dist/assets/move-B5WspEjG.js +0 -1
  193. package/dist/assets/narrat-CDPRStf_.js +0 -1
  194. package/dist/assets/nextflow-D6ub5x6I.js +0 -1
  195. package/dist/assets/nextflow-groovy-DC2pAw1t.js +0 -1
  196. package/dist/assets/nginx-tinol6SS.js +0 -1
  197. package/dist/assets/night-owl-CCydx7ZR.js +0 -1
  198. package/dist/assets/night-owl-light-CLM7UUPY.js +0 -1
  199. package/dist/assets/nim-B62YiABd.js +0 -1
  200. package/dist/assets/nix-DXFN4IwW.js +0 -1
  201. package/dist/assets/node-C92mwFMz.js +0 -4
  202. package/dist/assets/nord-CiCWZ1de.js +0 -1
  203. package/dist/assets/nushell-Ck9o2qOx.js +0 -1
  204. package/dist/assets/objective-c-4koxci9E.js +0 -1
  205. package/dist/assets/objective-cpp-BPFAgSN9.js +0 -1
  206. package/dist/assets/ocaml-BdlBLhZV.js +0 -1
  207. package/dist/assets/odin-BcGOLCqZ.js +0 -1
  208. package/dist/assets/one-dark-pro-DEMdTgiO.js +0 -1
  209. package/dist/assets/one-light-Cmpe9X1u.js +0 -1
  210. package/dist/assets/openscad-DBveSHYE.js +0 -1
  211. package/dist/assets/pascal-DxOVE99u.js +0 -1
  212. package/dist/assets/perl-Di890ZVN.js +0 -1
  213. package/dist/assets/php-BE50NddE.js +0 -1
  214. package/dist/assets/pkl-pIvtf7Vu.js +0 -1
  215. package/dist/assets/plastic-Dvdf8qVN.js +0 -1
  216. package/dist/assets/plsql-d50MFTFK.js +0 -1
  217. package/dist/assets/po-CboxWwYg.js +0 -1
  218. package/dist/assets/poimandres-DHgQGH4M.js +0 -1
  219. package/dist/assets/polar-DNp7Oek8.js +0 -1
  220. package/dist/assets/postcss-LpmI6XQv.js +0 -1
  221. package/dist/assets/powerquery-BNCl8Uwz.js +0 -1
  222. package/dist/assets/powershell-CZ9J0XuS.js +0 -1
  223. package/dist/assets/prisma-AELUiJCl.js +0 -1
  224. package/dist/assets/prolog-BlqwejRD.js +0 -1
  225. package/dist/assets/proto-DRQC3swD.js +0 -1
  226. package/dist/assets/pug-DGP7PTk-.js +0 -1
  227. package/dist/assets/puppet-DBKR8MlH.js +0 -1
  228. package/dist/assets/purescript-DItLzxZS.js +0 -1
  229. package/dist/assets/python-D9t_ETu8.js +0 -1
  230. package/dist/assets/qml-BBpLsqsj.js +0 -1
  231. package/dist/assets/qmldir-DP_KD1nk.js +0 -1
  232. package/dist/assets/qss-CL02opOd.js +0 -1
  233. package/dist/assets/r-BL7rt_9D.js +0 -1
  234. package/dist/assets/racket-K0frLTWj.js +0 -1
  235. package/dist/assets/raku-DwgLlPg9.js +0 -1
  236. package/dist/assets/razor-CeWBotJS.js +0 -1
  237. package/dist/assets/red-DzDu-aXc.js +0 -1
  238. package/dist/assets/reg-B_y3c7di.js +0 -1
  239. package/dist/assets/regexp-Dd73s7V-.js +0 -1
  240. package/dist/assets/rel-CT65ywJp.js +0 -1
  241. package/dist/assets/riscv-Qv5W2vIg.js +0 -1
  242. package/dist/assets/ron-cRHu9zPp.js +0 -1
  243. package/dist/assets/rose-pine-DFmTJHIJ.js +0 -1
  244. package/dist/assets/rose-pine-dawn-YIuK9wUm.js +0 -1
  245. package/dist/assets/rose-pine-moon-BYEHfkUB.js +0 -1
  246. package/dist/assets/rosmsg-_L1z4wrx.js +0 -1
  247. package/dist/assets/rst-CKr96H10.js +0 -1
  248. package/dist/assets/ruby-B7TsannQ.js +0 -1
  249. package/dist/assets/runtime-Dva4yzBQ.js +0 -1
  250. package/dist/assets/rust-BA5wQ_EY.js +0 -1
  251. package/dist/assets/sas-C_sF_r8a.js +0 -1
  252. package/dist/assets/sass-sd__ov8V.js +0 -1
  253. package/dist/assets/scala--cVG4U7H.js +0 -1
  254. package/dist/assets/scheme-D6Q1O-kY.js +0 -1
  255. package/dist/assets/scss-B16LvjsL.js +0 -1
  256. package/dist/assets/sdbl-jB0DUrL_.js +0 -1
  257. package/dist/assets/shaderlab-uUHiM5Mm.js +0 -1
  258. package/dist/assets/shellscript-dnyLKRin.js +0 -1
  259. package/dist/assets/shellsession-DTlo_aKR.js +0 -1
  260. package/dist/assets/slack-dark-DHhAlQBa.js +0 -1
  261. package/dist/assets/slack-ochin-PZXbcZaN.js +0 -1
  262. package/dist/assets/smalltalk-C7VkdTlv.js +0 -1
  263. package/dist/assets/snazzy-light-CPc_Qts9.js +0 -1
  264. package/dist/assets/solarized-dark-DCCfKtTh.js +0 -1
  265. package/dist/assets/solarized-light-B84T-R9X.js +0 -1
  266. package/dist/assets/solidity-DVsDPpDS.js +0 -1
  267. package/dist/assets/soy-CYe5idz8.js +0 -1
  268. package/dist/assets/sparql-D5zsvcbT.js +0 -1
  269. package/dist/assets/splunk-A-BwP6Va.js +0 -1
  270. package/dist/assets/sql-D-E0nBNJ.js +0 -1
  271. package/dist/assets/ssh-config-CFPv6mTy.js +0 -1
  272. package/dist/assets/stata-uX6s6LU7.js +0 -1
  273. package/dist/assets/stylus-CUf3IESt.js +0 -1
  274. package/dist/assets/surrealql-GkyIuwxY.js +0 -1
  275. package/dist/assets/svelte-CfnJqTwW.js +0 -1
  276. package/dist/assets/swift-CSiiYB_Q.js +0 -1
  277. package/dist/assets/synthwave-84-uJp1j2Jd.js +0 -1
  278. package/dist/assets/system-verilog-rMjKy-O9.js +0 -1
  279. package/dist/assets/systemd-R-KDM4-t.js +0 -1
  280. package/dist/assets/talonscript-D666vCtm.js +0 -1
  281. package/dist/assets/tasl-68cbBocU.js +0 -1
  282. package/dist/assets/tcl-Y39dJ_pX.js +0 -1
  283. package/dist/assets/templ-D1EOUGk9.js +0 -1
  284. package/dist/assets/terraform-BF7wAo-b.js +0 -1
  285. package/dist/assets/tex-DJNezBXL.js +0 -1
  286. package/dist/assets/tokyo-night-EeABtN82.js +0 -1
  287. package/dist/assets/toml-BrMFN1JF.js +0 -1
  288. package/dist/assets/ts-tags-B6yMAMBr.js +0 -1
  289. package/dist/assets/tsv-Dp2l5v7f.js +0 -1
  290. package/dist/assets/tsx-BOLo_Ogf.js +0 -1
  291. package/dist/assets/turtle-_N1aDtpV.js +0 -1
  292. package/dist/assets/twig-BI3vVzh2.js +0 -1
  293. package/dist/assets/typescript-kRJFfHtG.js +0 -1
  294. package/dist/assets/typespec-b80CR7oU.js +0 -1
  295. package/dist/assets/typst-DP_izaNK.js +0 -1
  296. package/dist/assets/utils-52664384-CNFmSnPR.js +0 -6
  297. package/dist/assets/v-CdNC_Wok.js +0 -1
  298. package/dist/assets/vala-Bu4pYkLC.js +0 -1
  299. package/dist/assets/vb-u84IyWSS.js +0 -1
  300. package/dist/assets/verilog-BC33pi7Z.js +0 -1
  301. package/dist/assets/vesper-7-XtS0Kg.js +0 -1
  302. package/dist/assets/vhdl-BBxTOQe0.js +0 -1
  303. package/dist/assets/viml-X_r-ivXA.js +0 -1
  304. package/dist/assets/vitesse-black-DRdlY-cW.js +0 -1
  305. package/dist/assets/vitesse-dark-Bz0hOFHJ.js +0 -1
  306. package/dist/assets/vitesse-light-7kCLd0yn.js +0 -1
  307. package/dist/assets/vue-B2GeWsQX.js +0 -1
  308. package/dist/assets/vue-html-DIRaMPNP.js +0 -1
  309. package/dist/assets/vue-vine-CfdXDvVv.js +0 -1
  310. package/dist/assets/vyper-E7qIwMXy.js +0 -1
  311. package/dist/assets/wasm-C18OEcCL.js +0 -1
  312. package/dist/assets/wasm-CfqNvg3Z.js +0 -1
  313. package/dist/assets/wenyan-siXAC4el.js +0 -1
  314. package/dist/assets/wgsl-BJQt8Uld.js +0 -1
  315. package/dist/assets/wikitext-Ci0ePJmf.js +0 -1
  316. package/dist/assets/wit-BdDJ7geN.js +0 -1
  317. package/dist/assets/wolfram-Bi-O1IUM.js +0 -1
  318. package/dist/assets/xml-CFID48H0.js +0 -1
  319. package/dist/assets/xsl-BK4ngHMJ.js +0 -1
  320. package/dist/assets/yaml-D6lFkv0K.js +0 -1
  321. package/dist/assets/zenscript-CnkrxCBw.js +0 -1
  322. package/dist/assets/zig-CgMsxF9-.js +0 -1
package/registry.json CHANGED
@@ -167,7 +167,7 @@
167
167
  "files": [
168
168
  {
169
169
  "path": "src/components/ui/calendar/Calendar.tsx",
170
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { DayPicker, type DateRange, type Matcher } from 'react-day-picker';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\n\r\nconst calendarVariants = tv({\r\n base: 'rdp-custom',\r\n variants: {\r\n size: {\r\n sm: '[&_.rdp-day]:h-7 [&_.rdp-day]:w-7 [&_.rdp-day]:text-xs',\r\n md: '[&_.rdp-day]:h-9 [&_.rdp-day]:w-9 [&_.rdp-day]:text-sm',\r\n lg: '[&_.rdp-day]:h-11 [&_.rdp-day]:w-11 [&_.rdp-day]:text-base',\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\nconst wrapperVariants = tv({\r\n base: 'inline-block rounded-xl border border-border bg-background p-3 shadow-sm',\r\n});\r\n\r\nexport type CalendarMode = 'single' | 'range' | 'multiple';\r\n\r\n/** Props for the Calendar component */\r\nexport interface CalendarProps extends VariantProps<typeof calendarVariants> {\r\n /** Selection mode: single date, date range, or multiple dates */\r\n mode?: CalendarMode;\r\n /** Currently selected value (Date, DateRange, or Date[] depending on mode) */\r\n selected?: Date | DateRange | Date[];\r\n /** Callback fired when the selection changes */\r\n onSelect?: (value: Date | DateRange | Date[] | undefined) => void;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Disable all dates after today */\r\n disableFutureDates?: boolean;\r\n /** Disable the entire calendar */\r\n disabled?: boolean;\r\n /** Locale key from react-day-picker/locale (defaults to 'enUS') */\r\n locale?: keyof typeof locales;\r\n className?: string;\r\n /** Additional class name for the outer wrapper */\r\n wrapperClassName?: string;\r\n /** Number of months to display side by side */\r\n numberOfMonths?: number;\r\n /** Show days from adjacent months */\r\n showOutsideDays?: boolean;\r\n}\r\n\r\nconst Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(({\r\n mode = 'single',\r\n selected,\r\n onSelect,\r\n disablePastDates = false,\r\n disableFutureDates = false,\r\n disabled = false,\r\n locale = 'enUS',\r\n className,\r\n wrapperClassName,\r\n size,\r\n numberOfMonths = 1,\r\n showOutsideDays = true,\r\n}, ref) => {\r\n const getDisabled = (): Matcher | Matcher[] | undefined => {\r\n if (disabled) return true;\r\n if (disablePastDates && disableFutureDates) return () => true;\r\n if (disablePastDates) return { before: new Date() };\r\n if (disableFutureDates) return { after: new Date() };\r\n return undefined;\r\n };\r\n\r\n const commonProps = {\r\n locale: locales[locale as keyof typeof locales],\r\n disabled: getDisabled(),\r\n numberOfMonths,\r\n showOutsideDays,\r\n className: calendarVariants({ size, className }),\r\n };\r\n\r\n return (\r\n <div ref={ref} className={wrapperVariants({ className: wrapperClassName })}>\r\n {mode === 'range' ? (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"range\"\r\n selected={selected as DateRange | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n ) : mode === 'multiple' ? (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"multiple\"\r\n selected={selected as Date[] | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n ) : (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"single\"\r\n selected={selected as Date | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nCalendar.displayName = 'Calendar';\r\n\r\nexport { Calendar };\r\n"
170
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { DayPicker, type DateRange, type Matcher } from 'react-day-picker';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport * as locales from 'react-day-picker/locale';\n\nimport 'react-day-picker/dist/style.css';\n\nconst calendarVariants = tv({\n base: 'rdp-custom',\n variants: {\n size: {\n sm: '[&_.rdp-day]:h-7 [&_.rdp-day]:w-7 [&_.rdp-day]:text-xs',\n md: '[&_.rdp-day]:h-9 [&_.rdp-day]:w-9 [&_.rdp-day]:text-sm',\n lg: '[&_.rdp-day]:h-11 [&_.rdp-day]:w-11 [&_.rdp-day]:text-base',\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nconst wrapperVariants = tv({\n base: 'inline-block rounded-xl border border-border bg-background p-3 shadow-sm',\n});\n\nexport type CalendarMode = 'single' | 'range' | 'multiple';\n\n/** Props for the Calendar component */\nexport interface CalendarProps extends VariantProps<typeof calendarVariants> {\n /** Selection mode: single date, date range, or multiple dates */\n mode?: CalendarMode;\n /** Currently selected value (Date, DateRange, or Date[] depending on mode) */\n selected?: Date | DateRange | Date[];\n /** Callback fired when the selection changes */\n onSelect?: (value: Date | DateRange | Date[] | undefined) => void;\n /** Disable all dates before today */\n disablePastDates?: boolean;\n /** Disable all dates after today */\n disableFutureDates?: boolean;\n /** Disable the entire calendar */\n disabled?: boolean;\n /** Locale key from react-day-picker/locale (defaults to 'enUS') */\n locale?: keyof typeof locales;\n className?: string;\n /** Additional class name for the outer wrapper */\n wrapperClassName?: string;\n /** Number of months to display side by side */\n numberOfMonths?: number;\n /** Show days from adjacent months */\n showOutsideDays?: boolean;\n}\n\nconst Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(({\n mode = 'single',\n selected,\n onSelect,\n disablePastDates = false,\n disableFutureDates = false,\n disabled = false,\n locale = 'enUS',\n className,\n wrapperClassName,\n size,\n numberOfMonths = 1,\n showOutsideDays = true,\n}, ref) => {\n const getDisabled = (): Matcher | Matcher[] | undefined => {\n if (disabled) return true;\n if (disablePastDates && disableFutureDates) return () => true;\n if (disablePastDates) return { before: new Date() };\n if (disableFutureDates) return { after: new Date() };\n return undefined;\n };\n\n const commonProps = {\n locale: locales[locale as keyof typeof locales],\n disabled: getDisabled(),\n numberOfMonths,\n showOutsideDays,\n className: calendarVariants({ size, className }),\n };\n\n return (\n <div ref={ref} className={wrapperVariants({ className: wrapperClassName })}>\n {mode === 'range' ? (\n <DayPicker\n {...commonProps}\n mode=\"range\"\n selected={selected as DateRange | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n ) : mode === 'multiple' ? (\n <DayPicker\n {...commonProps}\n mode=\"multiple\"\n selected={selected as Date[] | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n ) : (\n <DayPicker\n {...commonProps}\n mode=\"single\"\n selected={selected as Date | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n )}\n </div>\n );\n});\n\nCalendar.displayName = 'Calendar';\n\nexport { Calendar };\n"
171
171
  }
172
172
  ]
173
173
  },
@@ -195,7 +195,7 @@
195
195
  "files": [
196
196
  {
197
197
  "path": "src/components/ui/carousel/Carousel.tsx",
198
- "content": "import * as React from 'react';\r\nimport { useKeenSlider, type KeenSliderOptions, type KeenSliderPlugin, type KeenSliderInstance } from 'keen-slider/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\r\nimport 'keen-slider/keen-slider.min.css';\r\n\r\n// ─── Variants ─────────────────────────────────────────────────────────────────\r\n\r\nconst carouselVariants = tv({\r\n slots: {\r\n root: 'relative w-full select-none',\r\n viewport: 'keen-slider overflow-hidden rounded-xl',\r\n slide: 'keen-slider__slide',\r\n arrow: [\r\n 'absolute top-1/2 -translate-y-1/2 z-10',\r\n 'flex items-center justify-center',\r\n 'h-9 w-9 rounded-full',\r\n 'bg-background/80 backdrop-blur-sm border border-border shadow-md',\r\n 'text-foreground transition-all duration-150',\r\n 'hover:bg-background hover:scale-105',\r\n 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n ],\r\n dotsWrapper: 'flex justify-center gap-1.5 mt-3',\r\n dot: [\r\n 'h-1.5 rounded-full bg-border transition-all duration-300',\r\n 'hover:bg-muted-foreground cursor-pointer',\r\n ],\r\n },\r\n});\r\n\r\nconst { root, viewport, slide, arrow, dotsWrapper, dot } = carouselVariants();\r\n\r\n// ─── Context ──────────────────────────────────────────────────────────────────\r\n\r\ninterface CarouselContextValue {\r\n instanceRef: React.MutableRefObject<KeenSliderInstance | null>;\r\n currentSlide: number;\r\n slideCount: number;\r\n loop: boolean;\r\n}\r\n\r\nconst CarouselContext = React.createContext<CarouselContextValue | null>(null);\r\n\r\nfunction useCarousel() {\r\n const ctx = React.useContext(CarouselContext);\r\n if (!ctx) throw new Error('useCarousel must be used within <Carousel>');\r\n return ctx;\r\n}\r\n\r\n// ─── AutoPlay plugin ──────────────────────────────────────────────────────────\r\n\r\nexport function AutoPlayPlugin(interval = 3000): KeenSliderPlugin {\r\n return (slider) => {\r\n let timeout: ReturnType<typeof setTimeout>;\r\n let mouseOver = false;\r\n\r\n const clearNext = () => clearTimeout(timeout);\r\n const next = () => {\r\n clearNext();\r\n timeout = setTimeout(() => {\r\n if (!mouseOver) slider.next();\r\n }, interval);\r\n };\r\n\r\n slider.on('created', () => {\r\n slider.container.addEventListener('mouseover', () => { mouseOver = true; clearNext(); });\r\n slider.container.addEventListener('mouseout', () => { mouseOver = false; next(); });\r\n next();\r\n });\r\n slider.on('dragStarted', clearNext);\r\n slider.on('animationEnded', next);\r\n slider.on('updated', next);\r\n slider.on('destroyed', clearNext);\r\n };\r\n}\r\n\r\n// ─── WheelControls plugin ────────────────────────────────────────────────────\r\n\r\nexport const WheelControlsPlugin: KeenSliderPlugin = (slider) => {\r\n let touchTimeout: ReturnType<typeof setTimeout>;\r\n let position = { x: 0, y: 0 };\r\n let wheelActive = false;\r\n\r\n const dispatch = (e: WheelEvent, name: string) => {\r\n position.x -= e.deltaX;\r\n position.y -= e.deltaY;\r\n slider.container.dispatchEvent(new CustomEvent(name, { detail: { x: position.x, y: position.y } }));\r\n };\r\n\r\n const wheelStart = (e: WheelEvent) => {\r\n position = { x: e.pageX, y: e.pageY };\r\n dispatch(e, 'ksDragStart');\r\n };\r\n\r\n const wheel = (e: WheelEvent) => {\r\n dispatch(e, 'ksDrag');\r\n };\r\n\r\n const wheelEnd = (e: WheelEvent) => {\r\n dispatch(e, 'ksDragEnd');\r\n };\r\n\r\n const eventWheel = (e: WheelEvent) => {\r\n if (!wheelActive) {\r\n wheelStart(e);\r\n wheelActive = true;\r\n }\r\n wheel(e);\r\n clearTimeout(touchTimeout);\r\n touchTimeout = setTimeout(() => {\r\n wheelActive = false;\r\n wheelEnd(e);\r\n }, 50);\r\n };\r\n\r\n slider.on('created', () => {\r\n slider.container.addEventListener('wheel', eventWheel, { passive: true });\r\n });\r\n slider.on('destroyed', () => {\r\n slider.container.removeEventListener('wheel', eventWheel);\r\n });\r\n};\r\n\r\n// ─── MutationPlugin ───────────────────────────────────────────────────────────\r\n\r\nexport const MutationPlugin: KeenSliderPlugin = (slider) => {\r\n const observer = new MutationObserver((mutations) => {\r\n mutations.forEach(() => slider.update());\r\n });\r\n slider.on('created', () => {\r\n observer.observe(slider.container, { childList: true });\r\n });\r\n slider.on('destroyed', () => observer.disconnect());\r\n};\r\n\r\n// ─── Carousel3DPlugin ─────────────────────────────────────────────────────────\r\n\r\n/**\r\n * 3-D rotating carousel plugin.\r\n * Wrap the `keen-slider` div inside a perspective scene:\r\n * <div style={{ perspective: '1000px' }}>\r\n * <div ref={sliderRef} style={{ transformStyle: 'preserve-3d' }} …>\r\n * {slides}\r\n * </div>\r\n * </div>\r\n *\r\n * @param depth translateZ radius in px (default 280). For N slides of width W:\r\n * depth ≈ (W / 2) / Math.tan(Math.PI / N)\r\n */\r\nexport function Carousel3DPlugin(depth = 280): KeenSliderPlugin {\r\n return (slider) => {\r\n function applyRotation() {\r\n const deg = 360 * slider.track.details.progress;\r\n slider.container.style.transform = `translateZ(-${depth}px) rotateY(${-deg}deg)`;\r\n }\r\n\r\n slider.on('created', () => {\r\n const perSlide = 360 / slider.slides.length;\r\n slider.slides.forEach((el, i) => {\r\n el.style.transform = `rotateY(${perSlide * i}deg) translateZ(${depth}px)`;\r\n });\r\n applyRotation();\r\n });\r\n\r\n slider.on('detailsChanged', applyRotation);\r\n };\r\n}\r\n\r\n// ─── ResizePlugin ─────────────────────────────────────────────────────────────\r\n\r\nexport const ResizePlugin: KeenSliderPlugin = (slider) => {\r\n const observer = new ResizeObserver(() => slider.update());\r\n slider.on('created', () => observer.observe(slider.container));\r\n slider.on('destroyed', () => observer.disconnect());\r\n};\r\n\r\n// ─── Root ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselProps {\r\n children: React.ReactNode;\r\n /** Loop back to start after last slide */\r\n loop?: boolean;\r\n /** Auto-advance interval in ms; `false` to disable */\r\n autoPlay?: number | false;\r\n /** Initial slide index */\r\n initial?: number;\r\n /** Slides to show per view */\r\n slidesPerView?: number;\r\n /** Gap between slides in px */\r\n spacing?: number;\r\n /** Drag / swipe enabled */\r\n drag?: boolean;\r\n /** Vertical orientation — must also provide `height` */\r\n vertical?: boolean;\r\n /** Explicit height for the viewport (required for vertical mode) e.g. \"320px\" */\r\n height?: string;\r\n /** Scroll mode */\r\n mode?: 'snap' | 'free' | 'free-snap';\r\n /** Enable mouse-wheel navigation */\r\n wheelControls?: boolean;\r\n /** Watch DOM mutations and auto-update */\r\n mutationObserver?: boolean;\r\n /** Breakpoint overrides — keyed by media query string */\r\n breakpoints?: KeenSliderOptions['breakpoints'];\r\n /** Called when the active slide changes */\r\n onSlideChange?: (index: number) => void;\r\n /** Called on every position change — provides progress 0-1 */\r\n onDetailsChanged?: (progress: number, rel: number) => void;\r\n /** Called once the slider is ready */\r\n onCreated?: (instance: CarouselContextValue['instanceRef']['current']) => void;\r\n /** Extra className applied to the inner viewport div */\r\n viewportClassName?: string;\r\n className?: string;\r\n}\r\n\r\nconst Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(\r\n (\r\n {\r\n children,\r\n loop = false,\r\n autoPlay = false,\r\n initial = 0,\r\n slidesPerView = 1,\r\n spacing = 16,\r\n drag = true,\r\n vertical = false,\r\n height,\r\n mode = 'snap',\r\n wheelControls = false,\r\n mutationObserver = false,\r\n breakpoints,\r\n onSlideChange,\r\n onDetailsChanged,\r\n onCreated,\r\n viewportClassName,\r\n className,\r\n },\r\n ref\r\n ) => {\r\n const [currentSlide, setCurrentSlide] = React.useState(initial);\r\n const [slideCount, setSlideCount] = React.useState(0);\r\n\r\n const plugins: KeenSliderPlugin[] = [];\r\n if (autoPlay !== false) plugins.push(AutoPlayPlugin(autoPlay));\r\n if (wheelControls) plugins.push(WheelControlsPlugin);\r\n if (mutationObserver) plugins.push(MutationPlugin);\r\n plugins.push(ResizePlugin);\r\n\r\n const [sliderRef, instanceRef] = useKeenSlider<HTMLDivElement>(\r\n {\r\n loop,\r\n initial,\r\n drag,\r\n vertical,\r\n mode,\r\n breakpoints,\r\n slides: { perView: slidesPerView, spacing },\r\n slideChanged(s) {\r\n setCurrentSlide(s.track.details.rel);\r\n onSlideChange?.(s.track.details.rel);\r\n },\r\n detailsChanged(s) {\r\n onDetailsChanged?.(s.track.details.progress, s.track.details.rel);\r\n },\r\n created(s) {\r\n setSlideCount(s.track.details.slides.length);\r\n onCreated?.(s);\r\n },\r\n updated(s) {\r\n setSlideCount(s.track.details.slides.length);\r\n },\r\n },\r\n plugins\r\n );\r\n\r\n // Separate CarouselSlide children from navigation children\r\n const slides: React.ReactNode[] = [];\r\n const navigation: React.ReactNode[] = [];\r\n React.Children.forEach(children, (child) => {\r\n if (React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'CarouselSlide') {\r\n slides.push(child);\r\n } else {\r\n navigation.push(child);\r\n }\r\n });\r\n\r\n return (\r\n <CarouselContext.Provider value={{ instanceRef, currentSlide, slideCount, loop }}>\r\n <div ref={ref} className={root({ className })} data-testid=\"carousel\">\r\n <div\r\n ref={sliderRef}\r\n className={cn(viewport(), viewportClassName)}\r\n style={height ? { height } : undefined}\r\n >\r\n {slides}\r\n </div>\r\n {navigation}\r\n </div>\r\n </CarouselContext.Provider>\r\n );\r\n }\r\n);\r\nCarousel.displayName = 'Carousel';\r\n\r\n// ─── CarouselSlide ────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselSlideProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst CarouselSlide = React.forwardRef<HTMLDivElement, CarouselSlideProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <div ref={ref} className={slide({ className })} {...props}>\r\n {children}\r\n </div>\r\n )\r\n);\r\nCarouselSlide.displayName = 'CarouselSlide';\r\n\r\n// ─── CarouselPrev / CarouselNext ──────────────────────────────────────────────\r\n\r\nexport interface CarouselArrowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselPrev = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, loop } = useCarousel();\r\n const disabled = !loop && currentSlide === 0;\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Previous slide\"\r\n disabled={disabled}\r\n onClick={() => instanceRef.current?.prev()}\r\n className={arrow({ className: cn('left-2', className) })}\r\n {...props}\r\n >\r\n <ChevronLeft className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nCarouselPrev.displayName = 'CarouselPrev';\r\n\r\nconst CarouselNext = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, slideCount, loop } = useCarousel();\r\n const disabled = !loop && currentSlide === slideCount - 1;\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Next slide\"\r\n disabled={disabled}\r\n onClick={() => instanceRef.current?.next()}\r\n className={arrow({ className: cn('right-2', className) })}\r\n {...props}\r\n >\r\n <ChevronRight className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nCarouselNext.displayName = 'CarouselNext';\r\n\r\n// ─── CarouselDots ─────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselDotsProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselDots = React.forwardRef<HTMLDivElement, CarouselDotsProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, slideCount } = useCarousel();\r\n\r\n if (slideCount === 0) return null;\r\n\r\n return (\r\n <div ref={ref} className={dotsWrapper({ className })} role=\"tablist\" aria-label=\"Carousel navigation\" {...props}>\r\n {Array.from({ length: slideCount }).map((_, i) => (\r\n <button\r\n key={i}\r\n role=\"tab\"\r\n aria-selected={i === currentSlide}\r\n aria-label={`Go to slide ${i + 1}`}\r\n onClick={() => instanceRef.current?.moveToIdx(i)}\r\n className={dot({\r\n className: cn(\r\n i === currentSlide\r\n ? 'w-6 bg-primary'\r\n : 'w-1.5 bg-border hover:bg-muted-foreground'\r\n ),\r\n })}\r\n />\r\n ))}\r\n </div>\r\n );\r\n }\r\n);\r\nCarouselDots.displayName = 'CarouselDots';\r\n\r\n// ─── CarouselProgress ─────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselProgressProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselProgress = React.forwardRef<HTMLDivElement, CarouselProgressProps>(\r\n ({ className, ...props }, ref) => {\r\n const { currentSlide, slideCount } = useCarousel();\r\n\r\n const pct = slideCount > 1\r\n ? Math.round((currentSlide / (slideCount - 1)) * 100)\r\n : 100;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"progressbar\"\r\n aria-valuenow={pct}\r\n aria-valuemin={0}\r\n aria-valuemax={100}\r\n aria-label=\"Carousel progress\"\r\n className={cn('w-full h-1 bg-border rounded-full overflow-hidden mt-3', className)}\r\n {...props}\r\n >\r\n <div\r\n className=\"h-full bg-primary rounded-full transition-all duration-300\"\r\n style={{ width: `${pct}%` }}\r\n />\r\n </div>\r\n );\r\n }\r\n);\r\nCarouselProgress.displayName = 'CarouselProgress';\r\n\r\n// ─── CarouselCounter ──────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselCounterProps extends React.HTMLAttributes<HTMLSpanElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselCounter = React.forwardRef<HTMLSpanElement, CarouselCounterProps>(\r\n ({ className, ...props }, ref) => {\r\n const { currentSlide, slideCount } = useCarousel();\r\n\r\n return (\r\n <span\r\n ref={ref}\r\n aria-live=\"polite\"\r\n aria-label=\"Slide counter\"\r\n className={cn('text-xs text-muted-foreground tabular-nums', className)}\r\n {...props}\r\n >\r\n {currentSlide + 1} / {slideCount}\r\n </span>\r\n );\r\n }\r\n);\r\nCarouselCounter.displayName = 'CarouselCounter';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Carousel,\r\n CarouselSlide,\r\n CarouselPrev,\r\n CarouselNext,\r\n CarouselDots,\r\n CarouselProgress,\r\n CarouselCounter,\r\n useCarousel,\r\n};\r\n"
198
+ "content": "import * as React from 'react';\nimport { useKeenSlider, type KeenSliderOptions, type KeenSliderPlugin, type KeenSliderInstance } from 'keen-slider/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport 'keen-slider/keen-slider.min.css';\n\n// ─── Variants ─────────────────────────────────────────────────────────────────\n\nconst carouselVariants = tv({\n slots: {\n root: 'relative w-full select-none',\n viewport: 'keen-slider overflow-hidden rounded-xl',\n slide: 'keen-slider__slide',\n arrow: [\n 'absolute top-1/2 -translate-y-1/2 z-10',\n 'flex items-center justify-center',\n 'h-9 w-9 rounded-full',\n 'bg-background/80 backdrop-blur-sm border border-border shadow-md',\n 'text-foreground transition-all duration-150',\n 'hover:bg-background hover:scale-105',\n 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\n ],\n dotsWrapper: 'flex justify-center gap-1.5 mt-3',\n dot: [\n 'h-1.5 rounded-full bg-border transition-all duration-300',\n 'hover:bg-muted-foreground cursor-pointer',\n ],\n },\n});\n\nconst { root, viewport, slide, arrow, dotsWrapper, dot } = carouselVariants();\n\n// ─── Context ──────────────────────────────────────────────────────────────────\n\ninterface CarouselContextValue {\n instanceRef: React.MutableRefObject<KeenSliderInstance | null>;\n currentSlide: number;\n slideCount: number;\n loop: boolean;\n}\n\nconst CarouselContext = React.createContext<CarouselContextValue | null>(null);\n\nfunction useCarousel() {\n const ctx = React.useContext(CarouselContext);\n if (!ctx) throw new Error('useCarousel must be used within <Carousel>');\n return ctx;\n}\n\n// ─── AutoPlay plugin ──────────────────────────────────────────────────────────\n\nexport function AutoPlayPlugin(interval = 3000): KeenSliderPlugin {\n return (slider) => {\n let timeout: ReturnType<typeof setTimeout>;\n let mouseOver = false;\n\n const clearNext = () => clearTimeout(timeout);\n const next = () => {\n clearNext();\n timeout = setTimeout(() => {\n if (!mouseOver) slider.next();\n }, interval);\n };\n\n slider.on('created', () => {\n slider.container.addEventListener('mouseover', () => { mouseOver = true; clearNext(); });\n slider.container.addEventListener('mouseout', () => { mouseOver = false; next(); });\n next();\n });\n slider.on('dragStarted', clearNext);\n slider.on('animationEnded', next);\n slider.on('updated', next);\n slider.on('destroyed', clearNext);\n };\n}\n\n// ─── WheelControls plugin ────────────────────────────────────────────────────\n\nexport const WheelControlsPlugin: KeenSliderPlugin = (slider) => {\n let touchTimeout: ReturnType<typeof setTimeout>;\n let position = { x: 0, y: 0 };\n let wheelActive = false;\n\n const dispatch = (e: WheelEvent, name: string) => {\n position.x -= e.deltaX;\n position.y -= e.deltaY;\n slider.container.dispatchEvent(new CustomEvent(name, { detail: { x: position.x, y: position.y } }));\n };\n\n const wheelStart = (e: WheelEvent) => {\n position = { x: e.pageX, y: e.pageY };\n dispatch(e, 'ksDragStart');\n };\n\n const wheel = (e: WheelEvent) => {\n dispatch(e, 'ksDrag');\n };\n\n const wheelEnd = (e: WheelEvent) => {\n dispatch(e, 'ksDragEnd');\n };\n\n const eventWheel = (e: WheelEvent) => {\n if (!wheelActive) {\n wheelStart(e);\n wheelActive = true;\n }\n wheel(e);\n clearTimeout(touchTimeout);\n touchTimeout = setTimeout(() => {\n wheelActive = false;\n wheelEnd(e);\n }, 50);\n };\n\n slider.on('created', () => {\n slider.container.addEventListener('wheel', eventWheel, { passive: true });\n });\n slider.on('destroyed', () => {\n slider.container.removeEventListener('wheel', eventWheel);\n });\n};\n\n// ─── MutationPlugin ───────────────────────────────────────────────────────────\n\nexport const MutationPlugin: KeenSliderPlugin = (slider) => {\n const observer = new MutationObserver((mutations) => {\n mutations.forEach(() => slider.update());\n });\n slider.on('created', () => {\n observer.observe(slider.container, { childList: true });\n });\n slider.on('destroyed', () => observer.disconnect());\n};\n\n// ─── Carousel3DPlugin ─────────────────────────────────────────────────────────\n\n/**\n * 3-D rotating carousel plugin.\n * Wrap the `keen-slider` div inside a perspective scene:\n * <div style={{ perspective: '1000px' }}>\n * <div ref={sliderRef} style={{ transformStyle: 'preserve-3d' }} …>\n * {slides}\n * </div>\n * </div>\n *\n * @param depth translateZ radius in px (default 280). For N slides of width W:\n * depth ≈ (W / 2) / Math.tan(Math.PI / N)\n */\nexport function Carousel3DPlugin(depth = 280): KeenSliderPlugin {\n return (slider) => {\n function applyRotation() {\n const deg = 360 * slider.track.details.progress;\n slider.container.style.transform = `translateZ(-${depth}px) rotateY(${-deg}deg)`;\n }\n\n slider.on('created', () => {\n const perSlide = 360 / slider.slides.length;\n slider.slides.forEach((el, i) => {\n el.style.transform = `rotateY(${perSlide * i}deg) translateZ(${depth}px)`;\n });\n applyRotation();\n });\n\n slider.on('detailsChanged', applyRotation);\n };\n}\n\n// ─── ResizePlugin ─────────────────────────────────────────────────────────────\n\nexport const ResizePlugin: KeenSliderPlugin = (slider) => {\n const observer = new ResizeObserver(() => slider.update());\n slider.on('created', () => observer.observe(slider.container));\n slider.on('destroyed', () => observer.disconnect());\n};\n\n// ─── Root ─────────────────────────────────────────────────────────────────────\n\nexport interface CarouselProps {\n children: React.ReactNode;\n /** Loop back to start after last slide */\n loop?: boolean;\n /** Auto-advance interval in ms; `false` to disable */\n autoPlay?: number | false;\n /** Initial slide index */\n initial?: number;\n /** Slides to show per view */\n slidesPerView?: number;\n /** Gap between slides in px */\n spacing?: number;\n /** Drag / swipe enabled */\n drag?: boolean;\n /** Vertical orientation — must also provide `height` */\n vertical?: boolean;\n /** Explicit height for the viewport (required for vertical mode) e.g. \"320px\" */\n height?: string;\n /** Scroll mode */\n mode?: 'snap' | 'free' | 'free-snap';\n /** Enable mouse-wheel navigation */\n wheelControls?: boolean;\n /** Watch DOM mutations and auto-update */\n mutationObserver?: boolean;\n /** Breakpoint overrides — keyed by media query string */\n breakpoints?: KeenSliderOptions['breakpoints'];\n /** Called when the active slide changes */\n onSlideChange?: (index: number) => void;\n /** Called on every position change — provides progress 0-1 */\n onDetailsChanged?: (progress: number, rel: number) => void;\n /** Called once the slider is ready */\n onCreated?: (instance: CarouselContextValue['instanceRef']['current']) => void;\n /** Extra className applied to the inner viewport div */\n viewportClassName?: string;\n className?: string;\n}\n\nconst Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(\n (\n {\n children,\n loop = false,\n autoPlay = false,\n initial = 0,\n slidesPerView = 1,\n spacing = 16,\n drag = true,\n vertical = false,\n height,\n mode = 'snap',\n wheelControls = false,\n mutationObserver = false,\n breakpoints,\n onSlideChange,\n onDetailsChanged,\n onCreated,\n viewportClassName,\n className,\n },\n ref\n ) => {\n const [currentSlide, setCurrentSlide] = React.useState(initial);\n const [slideCount, setSlideCount] = React.useState(0);\n\n const plugins: KeenSliderPlugin[] = [];\n if (autoPlay !== false) plugins.push(AutoPlayPlugin(autoPlay));\n if (wheelControls) plugins.push(WheelControlsPlugin);\n if (mutationObserver) plugins.push(MutationPlugin);\n plugins.push(ResizePlugin);\n\n const [sliderRef, instanceRef] = useKeenSlider<HTMLDivElement>(\n {\n loop,\n initial,\n drag,\n vertical,\n mode,\n breakpoints,\n slides: { perView: slidesPerView, spacing },\n slideChanged(s) {\n setCurrentSlide(s.track.details.rel);\n onSlideChange?.(s.track.details.rel);\n },\n detailsChanged(s) {\n onDetailsChanged?.(s.track.details.progress, s.track.details.rel);\n },\n created(s) {\n setSlideCount(s.track.details.slides.length);\n onCreated?.(s);\n },\n updated(s) {\n setSlideCount(s.track.details.slides.length);\n },\n },\n plugins\n );\n\n // Separate CarouselSlide children from navigation children\n const slides: React.ReactNode[] = [];\n const navigation: React.ReactNode[] = [];\n React.Children.forEach(children, (child) => {\n if (React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'CarouselSlide') {\n slides.push(child);\n } else {\n navigation.push(child);\n }\n });\n\n return (\n <CarouselContext.Provider value={{ instanceRef, currentSlide, slideCount, loop }}>\n <div ref={ref} className={root({ className })} data-testid=\"carousel\">\n <div\n ref={sliderRef}\n className={cn(viewport(), viewportClassName)}\n style={height ? { height } : undefined}\n >\n {slides}\n </div>\n {navigation}\n </div>\n </CarouselContext.Provider>\n );\n }\n);\nCarousel.displayName = 'Carousel';\n\n// ─── CarouselSlide ────────────────────────────────────────────────────────────\n\nexport interface CarouselSlideProps extends React.HTMLAttributes<HTMLDivElement> {\n className?: string;\n children?: React.ReactNode;\n}\n\nconst CarouselSlide = React.forwardRef<HTMLDivElement, CarouselSlideProps>(\n ({ className, children, ...props }, ref) => (\n <div ref={ref} className={slide({ className })} {...props}>\n {children}\n </div>\n )\n);\nCarouselSlide.displayName = 'CarouselSlide';\n\n// ─── CarouselPrev / CarouselNext ──────────────────────────────────────────────\n\nexport interface CarouselArrowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n className?: string;\n}\n\nconst CarouselPrev = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\n ({ className, ...props }, ref) => {\n const { instanceRef, currentSlide, loop } = useCarousel();\n const disabled = !loop && currentSlide === 0;\n\n return (\n <button\n ref={ref}\n aria-label=\"Previous slide\"\n disabled={disabled}\n onClick={() => instanceRef.current?.prev()}\n className={arrow({ className: cn('left-2', className) })}\n {...props}\n >\n <ChevronLeft className=\"h-4 w-4\" />\n </button>\n );\n }\n);\nCarouselPrev.displayName = 'CarouselPrev';\n\nconst CarouselNext = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\n ({ className, ...props }, ref) => {\n const { instanceRef, currentSlide, slideCount, loop } = useCarousel();\n const disabled = !loop && currentSlide === slideCount - 1;\n\n return (\n <button\n ref={ref}\n aria-label=\"Next slide\"\n disabled={disabled}\n onClick={() => instanceRef.current?.next()}\n className={arrow({ className: cn('right-2', className) })}\n {...props}\n >\n <ChevronRight className=\"h-4 w-4\" />\n </button>\n );\n }\n);\nCarouselNext.displayName = 'CarouselNext';\n\n// ─── CarouselDots ─────────────────────────────────────────────────────────────\n\nexport interface CarouselDotsProps extends React.HTMLAttributes<HTMLDivElement> {\n className?: string;\n}\n\nconst CarouselDots = React.forwardRef<HTMLDivElement, CarouselDotsProps>(\n ({ className, ...props }, ref) => {\n const { instanceRef, currentSlide, slideCount } = useCarousel();\n\n if (slideCount === 0) return null;\n\n return (\n <div ref={ref} className={dotsWrapper({ className })} role=\"tablist\" aria-label=\"Carousel navigation\" {...props}>\n {Array.from({ length: slideCount }).map((_, i) => (\n <button\n key={i}\n role=\"tab\"\n aria-selected={i === currentSlide}\n aria-label={`Go to slide ${i + 1}`}\n onClick={() => instanceRef.current?.moveToIdx(i)}\n className={dot({\n className: cn(\n i === currentSlide\n ? 'w-6 bg-primary'\n : 'w-1.5 bg-border hover:bg-muted-foreground'\n ),\n })}\n />\n ))}\n </div>\n );\n }\n);\nCarouselDots.displayName = 'CarouselDots';\n\n// ─── CarouselProgress ─────────────────────────────────────────────────────────\n\nexport interface CarouselProgressProps extends React.HTMLAttributes<HTMLDivElement> {\n className?: string;\n}\n\nconst CarouselProgress = React.forwardRef<HTMLDivElement, CarouselProgressProps>(\n ({ className, ...props }, ref) => {\n const { currentSlide, slideCount } = useCarousel();\n\n const pct = slideCount > 1\n ? Math.round((currentSlide / (slideCount - 1)) * 100)\n : 100;\n\n return (\n <div\n ref={ref}\n role=\"progressbar\"\n aria-valuenow={pct}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-label=\"Carousel progress\"\n className={cn('w-full h-1 bg-border rounded-full overflow-hidden mt-3', className)}\n {...props}\n >\n <div\n className=\"h-full bg-primary rounded-full transition-all duration-300\"\n style={{ width: `${pct}%` }}\n />\n </div>\n );\n }\n);\nCarouselProgress.displayName = 'CarouselProgress';\n\n// ─── CarouselCounter ──────────────────────────────────────────────────────────\n\nexport interface CarouselCounterProps extends React.HTMLAttributes<HTMLSpanElement> {\n className?: string;\n}\n\nconst CarouselCounter = React.forwardRef<HTMLSpanElement, CarouselCounterProps>(\n ({ className, ...props }, ref) => {\n const { currentSlide, slideCount } = useCarousel();\n\n return (\n <span\n ref={ref}\n aria-live=\"polite\"\n aria-label=\"Slide counter\"\n className={cn('text-xs text-muted-foreground tabular-nums', className)}\n {...props}\n >\n {currentSlide + 1} / {slideCount}\n </span>\n );\n }\n);\nCarouselCounter.displayName = 'CarouselCounter';\n\n// ─── Exports ──────────────────────────────────────────────────────────────────\n\nexport {\n Carousel,\n CarouselSlide,\n CarouselPrev,\n CarouselNext,\n CarouselDots,\n CarouselProgress,\n CarouselCounter,\n useCarousel,\n};\n"
199
199
  }
200
200
  ]
201
201
  },
@@ -208,11 +208,11 @@
208
208
  "files": [
209
209
  {
210
210
  "path": "src/components/ui/chart/chart-tokens.ts",
211
- "content": "// ─── Chart design tokens (Kraken palette) ────────────────────────────────────\n\nexport const CHART_COLORS = [\n '#7132f5', '#149e61', '#2563eb', '#f59e0b',\n '#ef4444', '#c4b0ff', '#5741d8', '#1acc72',\n] as const;\n\n/** Axis tick style */\nexport const CHART_AX = { fontSize: 12, fill: '#9497a9' } as const;\n\n/** Cartesian grid style */\nexport const CHART_GRD = { stroke: '#dedee5', strokeDasharray: '4 4' } as const;\n\n// ─── Shared prop types ────────────────────────────────────────────────────────\n\nexport type SeriesItem = {\n key: string;\n name?: string;\n color?: string;\n stackId?: string;\n};\n\nexport type PieDataItem = {\n name: string;\n value: number;\n color?: string;\n};\n\n/** Dữ liệu trả về khi onClick trên các chart\n * - activeLabel : giá trị trục X (category) tại vị trí click\n * - activeIndex : vị trí trong mảng data\n * - dataKey : key của series được click (line/area/bar cụ thể)\n * - payload : toàn bộ row data tại vị trí đó (data[activeIndex])\n */\nexport type ChartClickEvent = {\n activeLabel?: string | number;\n activeIndex?: number;\n dataKey?: string;\n payload?: Record<string, unknown>;\n};\n"
211
+ "content": "// ─── Chart design tokens (Kraken palette) ────────────────────────────────────\r\n\r\nexport const CHART_COLORS = [\r\n '#7132f5', '#149e61', '#2563eb', '#f59e0b',\r\n '#ef4444', '#c4b0ff', '#5741d8', '#1acc72',\r\n] as const;\r\n\r\n/** Axis tick style */\r\nexport const CHART_AX = { fontSize: 12, fill: '#9497a9' } as const;\r\n\r\n/** Cartesian grid style */\r\nexport const CHART_GRD = { stroke: '#dedee5', strokeDasharray: '4 4' } as const;\r\n\r\n// ─── Shared prop types ────────────────────────────────────────────────────────\r\n\r\nexport type SeriesItem = {\r\n key: string;\r\n name?: string;\r\n color?: string;\r\n stackId?: string;\r\n};\r\n\r\nexport type PieDataItem = {\r\n name: string;\r\n value: number;\r\n color?: string;\r\n};\r\n\r\n/** Dữ liệu trả về khi onClick trên các chart\r\n * - activeLabel : giá trị trục X (category) tại vị trí click\r\n * - activeIndex : vị trí trong mảng data\r\n * - dataKey : key của series được click (line/area/bar cụ thể)\r\n * - payload : toàn bộ row data tại vị trí đó (data[activeIndex])\r\n */\r\nexport type ChartClickEvent = {\r\n activeLabel?: string | number;\r\n activeIndex?: number;\r\n dataKey?: string;\r\n payload?: Record<string, unknown>;\r\n};\r\n"
212
212
  },
213
213
  {
214
214
  "path": "src/components/ui/chart/Chart.tsx",
215
- "content": "import * as React from 'react';\nimport {\n LineChart, Line,\n BarChart, Bar,\n AreaChart, Area,\n PieChart, Pie, Cell,\n RadarChart, Radar, PolarGrid, PolarAngleAxis,\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,\n} from 'recharts';\nimport type { TooltipContentProps, TooltipIndex } from 'recharts';\nimport {\n CHART_COLORS, CHART_AX, CHART_GRD,\n} from './chart-tokens';\nimport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\n\n// ─── Helper: tìm row bằng activeLabel (recharts v3 không có activePayload trong onClick) ─\nconst resolveByLabel = (\n data: Record<string, unknown>[],\n labelKey: string,\n activeLabel: string | number | undefined,\n e: React.SyntheticEvent,\n cb: (payload: ChartClickEvent, event: React.MouseEvent) => void,\n) => {\n if (activeLabel == null) return;\n const idx = data.findIndex(d => d[labelKey] === activeLabel);\n if (idx < 0) return;\n cb({ activeLabel, activeIndex: idx, payload: data[idx] }, e as React.MouseEvent);\n};\n\n// ─── Custom Tooltip ───────────────────────────────────────────────────────────\nexport const ChartTooltip = ({ active, payload, label }: TooltipContentProps) => {\n if (!active || !payload?.length) return null;\n return (\n <div className=\"rounded-lg border border-border bg-background p-3 shadow-[rgba(0,0,0,0.1)_0px_4px_20px] text-sm min-w-[150px]\">\n {label != null && (\n <p className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n {label}\n </p>\n )}\n {payload.map((e) => (\n <div key={String(e.dataKey)} className=\"flex items-center gap-2 py-0.5\">\n <span className=\"h-2 w-2 shrink-0 rounded-full\" style={{ background: e.color }} />\n <span className=\"text-muted-foreground\">{e.name}:</span>\n <span className=\"ml-auto pl-3 font-semibold text-foreground\">\n {typeof e.value === 'number' ? e.value.toLocaleString() : e.value}\n </span>\n </div>\n ))}\n </div>\n );\n};\n\n// ─── ChartLine ────────────────────────────────────────────────────────────────\nexport interface ChartLineProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n curved?: boolean;\n defaultIndex?: TooltipIndex;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartLine: React.FC<ChartLineProps> = ({\n data, xKey, series, height = 280, curved = true, defaultIndex, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <LineChart\n data={data}\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\n : undefined}\n >\n <CartesianGrid {...CHART_GRD} vertical={false} />\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => (\n <Line key={s.key} type={curved ? 'monotone' : 'linear'}\n dataKey={s.key} name={s.name ?? s.key}\n stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n strokeWidth={2} dot={false}\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\n ))}\n </LineChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartBar ─────────────────────────────────────────────────────────────────\nexport interface ChartBarProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n stacked?: boolean;\n layout?: 'horizontal' | 'vertical';\n defaultIndex?: TooltipIndex;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartBar: React.FC<ChartBarProps> = ({\n data, xKey, series, height = 280, stacked, layout = 'horizontal', defaultIndex, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <BarChart data={data} layout={layout} margin={{ top: 4, right: 16, left: 0, bottom: 0 }} barCategoryGap=\"35%\">\n {layout === 'horizontal' ? (\n <>\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n </>\n ) : (\n <>\n <XAxis type=\"number\" tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis dataKey={xKey} type=\"category\" tick={CHART_AX} axisLine={false} tickLine={false} width={80} />\n </>\n )}\n <CartesianGrid {...CHART_GRD} vertical={layout === 'vertical'} horizontal={layout === 'horizontal'} />\n <Tooltip content={ChartTooltip} cursor={{ fill: 'rgba(113,50,245,0.05)' }} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => (\n // Bar.onClick giống Pie.onClick — recharts truyền toàn bộ row data trực tiếp\n <Bar key={s.key} dataKey={s.key} name={s.name ?? s.key}\n fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n radius={layout === 'horizontal' ? [4, 4, 0, 0] : [0, 4, 4, 0]}\n stackId={stacked ? 'stack' : undefined}\n cursor={onClick ? 'pointer' : undefined}\n onClick={onClick\n ? (barData, index, e) => {\n const row = barData as unknown as Record<string, unknown>;\n onClick({ activeLabel: row[xKey] as string | number, activeIndex: index, dataKey: s.key, payload: row }, e as React.MouseEvent);\n }\n : undefined} />\n ))}\n </BarChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartArea ────────────────────────────────────────────────────────────────\nexport interface ChartAreaProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n stacked?: boolean;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartArea: React.FC<ChartAreaProps> = ({\n data, xKey, series, height = 280, stacked, onClick,\n}) => {\n const uid = React.useId().replace(/:/g, '');\n return (\n <ResponsiveContainer width=\"100%\" height={height}>\n <AreaChart\n data={data}\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\n : undefined}\n >\n <defs>\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <linearGradient key={s.key} id={`${uid}${s.key}`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" stopColor={c} stopOpacity={0.2} />\n <stop offset=\"95%\" stopColor={c} stopOpacity={0} />\n </linearGradient>\n );\n })}\n </defs>\n <CartesianGrid {...CHART_GRD} vertical={false} />\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n <Tooltip content={ChartTooltip} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <Area key={s.key} type=\"monotone\" dataKey={s.key} name={s.name ?? s.key}\n stroke={c} fill={`url(#${uid}${s.key})`} strokeWidth={2}\n stackId={stacked ? 'stack' : undefined}\n dot={false}\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\n );\n })}\n </AreaChart>\n </ResponsiveContainer>\n );\n};\n\n// ─── Helper: custom activeDot với onClick cho Line/Area ──────────────────────\ntype ActiveDotRenderProps = {\n cx?: number; cy?: number; r?: number;\n fill?: string; stroke?: string;\n payload?: Record<string, unknown>;\n index?: number;\n dataKey?: string; // key của series được click (vd: 'revenue', 'cost')\n};\n\nconst makeActiveDot = (\n xKey: string,\n onClick: (payload: ChartClickEvent, event: React.MouseEvent) => void,\n) => (dotProps: unknown) => {\n const { cx = 0, cy = 0, fill = '', payload = {}, index = 0, dataKey } = dotProps as ActiveDotRenderProps;\n return (\n <circle\n cx={cx} cy={cy} r={6}\n fill={fill} stroke=\"white\" strokeWidth={2}\n style={{ cursor: 'pointer' }}\n onClick={(e) => {\n e.stopPropagation();\n onClick({ activeLabel: payload[xKey] as string | number, activeIndex: index, dataKey, payload }, e);\n }}\n />\n );\n};\n\n// ─── ChartPie ─────────────────────────────────────────────────────────────────\nexport interface ChartPieProps {\n data: PieDataItem[];\n donut?: boolean;\n height?: number;\n showLabel?: boolean;\n defaultIndex?: TooltipIndex;\n isAnimationActive?: boolean;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartPie: React.FC<ChartPieProps> = ({\n data, donut = false, height = 300, showLabel = true, defaultIndex, isAnimationActive = true, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <PieChart margin={{ top: 20, right: 30, left: 30, bottom: 20 }}>\n <Pie data={data}\n cx=\"50%\" cy=\"50%\"\n dataKey=\"value\"\n innerRadius={donut ? 50 : 0}\n outerRadius={80}\n isAnimationActive={isAnimationActive}\n onClick={onClick\n ? (d, index, e) => {\n const row = d as unknown as Record<string, unknown>;\n onClick({ activeLabel: row['name'] as string, activeIndex: index, dataKey: 'value', payload: row }, e as React.MouseEvent);\n }\n : undefined}\n label={showLabel ? (props) => {\n const { cx = 0, cy = 0, midAngle = 0, outerRadius = 0, percent = 0, name = '' } = props as {\n cx?: number; cy?: number; midAngle?: number;\n outerRadius?: number; percent?: number; name?: string;\n };\n const RADIAN = Math.PI / 180;\n const radius = outerRadius + 15;\n const x = cx + radius * Math.cos(-midAngle * RADIAN);\n const y = cy + radius * Math.sin(-midAngle * RADIAN);\n const shortName = name.length > 12 ? `${name.substring(0, 12)}...` : name;\n return (\n <text x={x} y={y} fill=\"currentColor\" className=\"text-[11px] text-muted-foreground\" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline=\"central\">\n {shortName} {(percent * 100).toFixed(0)}%\n </text>\n );\n } : false}\n labelLine={showLabel ? { stroke: 'currentColor', className: 'text-border opacity-50' } : false}\n >\n {data.map((entry, i) => (\n <Cell\n key={entry.name}\n fill={entry.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n style={{ cursor: onClick ? 'pointer' : 'default', outline: 'none' }}\n />\n ))}\n </Pie>\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 11, paddingTop: 10 }} iconType=\"circle\" />\n </PieChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartRadar ───────────────────────────────────────────────────────────────\nexport interface ChartRadarProps {\n data: Record<string, unknown>[];\n angleKey: string;\n series: Array<{ key: string; name?: string; color?: string }>;\n height?: number;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartRadar: React.FC<ChartRadarProps> = ({\n data, angleKey, series, height = 280, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <RadarChart\n data={data}\n cx=\"50%\" cy=\"50%\" outerRadius=\"72%\"\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, angleKey, activeLabel, e, onClick)\n : undefined}\n >\n <PolarGrid stroke=\"#dedee5\" />\n <PolarAngleAxis dataKey={angleKey} tick={CHART_AX} />\n <Tooltip content={ChartTooltip} />\n <Legend wrapperStyle={{ fontSize: 12 }} iconType=\"circle\" />\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <Radar key={s.key} dataKey={s.key} name={s.name ?? s.key}\n stroke={c} fill={c} fillOpacity={0.15} strokeWidth={2} />\n );\n })}\n </RadarChart>\n </ResponsiveContainer>\n);\n\n// ─── Re-export Recharts primitives for composed / custom charts ───────────────\nexport {\n ResponsiveContainer, LineChart, BarChart, AreaChart, PieChart, RadarChart,\n Line, Bar, Area, Pie, Cell, Radar,\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, PolarGrid, PolarAngleAxis,\n} from 'recharts';\nexport type { TooltipProps, TooltipContentProps } from 'recharts';\n\n// ─── Re-export tokens so consumers only need one import path ──────────────────\nexport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\nexport { CHART_COLORS } from './chart-tokens';\n"
215
+ "content": "import * as React from 'react';\r\nimport {\r\n LineChart, Line,\r\n BarChart, Bar,\r\n AreaChart, Area,\r\n PieChart, Pie, Cell,\r\n RadarChart, Radar, PolarGrid, PolarAngleAxis,\r\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,\r\n} from 'recharts';\r\nimport type { TooltipContentProps, TooltipIndex } from 'recharts';\r\nimport {\r\n CHART_COLORS, CHART_AX, CHART_GRD,\r\n} from './chart-tokens';\r\nimport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\r\n\r\n// ─── Helper: tìm row bằng activeLabel (recharts v3 không có activePayload trong onClick) ─\r\nconst resolveByLabel = (\r\n data: Record<string, unknown>[],\r\n labelKey: string,\r\n activeLabel: string | number | undefined,\r\n e: React.SyntheticEvent,\r\n cb: (payload: ChartClickEvent, event: React.MouseEvent) => void,\r\n) => {\r\n if (activeLabel == null) return;\r\n const idx = data.findIndex(d => d[labelKey] === activeLabel);\r\n if (idx < 0) return;\r\n cb({ activeLabel, activeIndex: idx, payload: data[idx] }, e as React.MouseEvent);\r\n};\r\n\r\n// ─── Custom Tooltip ───────────────────────────────────────────────────────────\r\nexport const ChartTooltip = ({ active, payload, label }: TooltipContentProps) => {\r\n if (!active || !payload?.length) return null;\r\n return (\r\n <div className=\"rounded-lg border border-border bg-background p-3 shadow-[rgba(0,0,0,0.1)_0px_4px_20px] text-sm min-w-[150px]\">\r\n {label != null && (\r\n <p className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\r\n {label}\r\n </p>\r\n )}\r\n {payload.map((e) => (\r\n <div key={String(e.dataKey)} className=\"flex items-center gap-2 py-0.5\">\r\n <span className=\"h-2 w-2 shrink-0 rounded-full\" style={{ background: e.color }} />\r\n <span className=\"text-muted-foreground\">{e.name}:</span>\r\n <span className=\"ml-auto pl-3 font-semibold text-foreground\">\r\n {typeof e.value === 'number' ? e.value.toLocaleString() : e.value}\r\n </span>\r\n </div>\r\n ))}\r\n </div>\r\n );\r\n};\r\n\r\n// ─── ChartLine ────────────────────────────────────────────────────────────────\r\nexport interface ChartLineProps {\r\n data: Record<string, unknown>[];\r\n xKey: string;\r\n series: SeriesItem[];\r\n height?: number;\r\n curved?: boolean;\r\n defaultIndex?: TooltipIndex;\r\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\r\n}\r\n\r\nexport const ChartLine: React.FC<ChartLineProps> = ({\r\n data, xKey, series, height = 280, curved = true, defaultIndex, onClick,\r\n}) => (\r\n <ResponsiveContainer width=\"100%\" height={height}>\r\n <LineChart\r\n data={data}\r\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\r\n style={{ cursor: onClick ? 'pointer' : undefined }}\r\n onClick={onClick\r\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\r\n : undefined}\r\n >\r\n <CartesianGrid {...CHART_GRD} vertical={false} />\r\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\r\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\r\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\r\n <Legend wrapperStyle={{ fontSize: 12 }} />\r\n {series.map((s, i) => (\r\n <Line key={s.key} type={curved ? 'monotone' : 'linear'}\r\n dataKey={s.key} name={s.name ?? s.key}\r\n stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\r\n strokeWidth={2} dot={false}\r\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\r\n ))}\r\n </LineChart>\r\n </ResponsiveContainer>\r\n);\r\n\r\n// ─── ChartBar ─────────────────────────────────────────────────────────────────\r\nexport interface ChartBarProps {\r\n data: Record<string, unknown>[];\r\n xKey: string;\r\n series: SeriesItem[];\r\n height?: number;\r\n stacked?: boolean;\r\n layout?: 'horizontal' | 'vertical';\r\n defaultIndex?: TooltipIndex;\r\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\r\n}\r\n\r\nexport const ChartBar: React.FC<ChartBarProps> = ({\r\n data, xKey, series, height = 280, stacked, layout = 'horizontal', defaultIndex, onClick,\r\n}) => (\r\n <ResponsiveContainer width=\"100%\" height={height}>\r\n <BarChart data={data} layout={layout} margin={{ top: 4, right: 16, left: 0, bottom: 0 }} barCategoryGap=\"35%\">\r\n {layout === 'horizontal' ? (\r\n <>\r\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\r\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\r\n </>\r\n ) : (\r\n <>\r\n <XAxis type=\"number\" tick={CHART_AX} axisLine={false} tickLine={false} />\r\n <YAxis dataKey={xKey} type=\"category\" tick={CHART_AX} axisLine={false} tickLine={false} width={80} />\r\n </>\r\n )}\r\n <CartesianGrid {...CHART_GRD} vertical={layout === 'vertical'} horizontal={layout === 'horizontal'} />\r\n <Tooltip content={ChartTooltip} cursor={{ fill: 'rgba(113,50,245,0.05)' }} defaultIndex={defaultIndex} />\r\n <Legend wrapperStyle={{ fontSize: 12 }} />\r\n {series.map((s, i) => (\r\n // Bar.onClick giống Pie.onClick — recharts truyền toàn bộ row data trực tiếp\r\n <Bar key={s.key} dataKey={s.key} name={s.name ?? s.key}\r\n fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\r\n radius={layout === 'horizontal' ? [4, 4, 0, 0] : [0, 4, 4, 0]}\r\n stackId={stacked ? 'stack' : undefined}\r\n cursor={onClick ? 'pointer' : undefined}\r\n onClick={onClick\r\n ? (barData, index, e) => {\r\n const row = barData as unknown as Record<string, unknown>;\r\n onClick({ activeLabel: row[xKey] as string | number, activeIndex: index, dataKey: s.key, payload: row }, e as React.MouseEvent);\r\n }\r\n : undefined} />\r\n ))}\r\n </BarChart>\r\n </ResponsiveContainer>\r\n);\r\n\r\n// ─── ChartArea ────────────────────────────────────────────────────────────────\r\nexport interface ChartAreaProps {\r\n data: Record<string, unknown>[];\r\n xKey: string;\r\n series: SeriesItem[];\r\n height?: number;\r\n stacked?: boolean;\r\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\r\n}\r\n\r\nexport const ChartArea: React.FC<ChartAreaProps> = ({\r\n data, xKey, series, height = 280, stacked, onClick,\r\n}) => {\r\n const uid = React.useId().replace(/:/g, '');\r\n return (\r\n <ResponsiveContainer width=\"100%\" height={height}>\r\n <AreaChart\r\n data={data}\r\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\r\n style={{ cursor: onClick ? 'pointer' : undefined }}\r\n onClick={onClick\r\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\r\n : undefined}\r\n >\r\n <defs>\r\n {series.map((s, i) => {\r\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\r\n return (\r\n <linearGradient key={s.key} id={`${uid}${s.key}`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\r\n <stop offset=\"5%\" stopColor={c} stopOpacity={0.2} />\r\n <stop offset=\"95%\" stopColor={c} stopOpacity={0} />\r\n </linearGradient>\r\n );\r\n })}\r\n </defs>\r\n <CartesianGrid {...CHART_GRD} vertical={false} />\r\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\r\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\r\n <Tooltip content={ChartTooltip} />\r\n <Legend wrapperStyle={{ fontSize: 12 }} />\r\n {series.map((s, i) => {\r\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\r\n return (\r\n <Area key={s.key} type=\"monotone\" dataKey={s.key} name={s.name ?? s.key}\r\n stroke={c} fill={`url(#${uid}${s.key})`} strokeWidth={2}\r\n stackId={stacked ? 'stack' : undefined}\r\n dot={false}\r\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\r\n );\r\n })}\r\n </AreaChart>\r\n </ResponsiveContainer>\r\n );\r\n};\r\n\r\n// ─── Helper: custom activeDot với onClick cho Line/Area ──────────────────────\r\ntype ActiveDotRenderProps = {\r\n cx?: number; cy?: number; r?: number;\r\n fill?: string; stroke?: string;\r\n payload?: Record<string, unknown>;\r\n index?: number;\r\n dataKey?: string; // key của series được click (vd: 'revenue', 'cost')\r\n};\r\n\r\nconst makeActiveDot = (\r\n xKey: string,\r\n onClick: (payload: ChartClickEvent, event: React.MouseEvent) => void,\r\n) => (dotProps: unknown) => {\r\n const { cx = 0, cy = 0, fill = '', payload = {}, index = 0, dataKey } = dotProps as ActiveDotRenderProps;\r\n return (\r\n <circle\r\n cx={cx} cy={cy} r={6}\r\n fill={fill} stroke=\"white\" strokeWidth={2}\r\n style={{ cursor: 'pointer' }}\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n onClick({ activeLabel: payload[xKey] as string | number, activeIndex: index, dataKey, payload }, e);\r\n }}\r\n />\r\n );\r\n};\r\n\r\n// ─── ChartPie ─────────────────────────────────────────────────────────────────\r\nexport interface ChartPieProps {\r\n data: PieDataItem[];\r\n donut?: boolean;\r\n height?: number;\r\n showLabel?: boolean;\r\n defaultIndex?: TooltipIndex;\r\n isAnimationActive?: boolean;\r\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\r\n}\r\n\r\nexport const ChartPie: React.FC<ChartPieProps> = ({\r\n data, donut = false, height = 300, showLabel = true, defaultIndex, isAnimationActive = true, onClick,\r\n}) => (\r\n <ResponsiveContainer width=\"100%\" height={height}>\r\n <PieChart margin={{ top: 20, right: 30, left: 30, bottom: 20 }}>\r\n <Pie data={data}\r\n cx=\"50%\" cy=\"50%\"\r\n dataKey=\"value\"\r\n innerRadius={donut ? 50 : 0}\r\n outerRadius={80}\r\n isAnimationActive={isAnimationActive}\r\n onClick={onClick\r\n ? (d, index, e) => {\r\n const row = d as unknown as Record<string, unknown>;\r\n onClick({ activeLabel: row['name'] as string, activeIndex: index, dataKey: 'value', payload: row }, e as React.MouseEvent);\r\n }\r\n : undefined}\r\n label={showLabel ? (props) => {\r\n const { cx = 0, cy = 0, midAngle = 0, outerRadius = 0, percent = 0, name = '' } = props as {\r\n cx?: number; cy?: number; midAngle?: number;\r\n outerRadius?: number; percent?: number; name?: string;\r\n };\r\n const RADIAN = Math.PI / 180;\r\n const radius = outerRadius + 15;\r\n const x = cx + radius * Math.cos(-midAngle * RADIAN);\r\n const y = cy + radius * Math.sin(-midAngle * RADIAN);\r\n const shortName = name.length > 12 ? `${name.substring(0, 12)}...` : name;\r\n return (\r\n <text x={x} y={y} fill=\"currentColor\" className=\"text-[11px] text-muted-foreground\" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline=\"central\">\r\n {shortName} {(percent * 100).toFixed(0)}%\r\n </text>\r\n );\r\n } : false}\r\n labelLine={showLabel ? { stroke: 'currentColor', className: 'text-border opacity-50' } : false}\r\n >\r\n {data.map((entry, i) => (\r\n <Cell\r\n key={entry.name}\r\n fill={entry.color ?? CHART_COLORS[i % CHART_COLORS.length]}\r\n style={{ cursor: onClick ? 'pointer' : 'default', outline: 'none' }}\r\n />\r\n ))}\r\n </Pie>\r\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\r\n <Legend wrapperStyle={{ fontSize: 11, paddingTop: 10 }} iconType=\"circle\" />\r\n </PieChart>\r\n </ResponsiveContainer>\r\n);\r\n\r\n// ─── ChartRadar ───────────────────────────────────────────────────────────────\r\nexport interface ChartRadarProps {\r\n data: Record<string, unknown>[];\r\n angleKey: string;\r\n series: Array<{ key: string; name?: string; color?: string }>;\r\n height?: number;\r\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\r\n}\r\n\r\nexport const ChartRadar: React.FC<ChartRadarProps> = ({\r\n data, angleKey, series, height = 280, onClick,\r\n}) => (\r\n <ResponsiveContainer width=\"100%\" height={height}>\r\n <RadarChart\r\n data={data}\r\n cx=\"50%\" cy=\"50%\" outerRadius=\"72%\"\r\n style={{ cursor: onClick ? 'pointer' : undefined }}\r\n onClick={onClick\r\n ? ({ activeLabel }, e) => resolveByLabel(data, angleKey, activeLabel, e, onClick)\r\n : undefined}\r\n >\r\n <PolarGrid stroke=\"#dedee5\" />\r\n <PolarAngleAxis dataKey={angleKey} tick={CHART_AX} />\r\n <Tooltip content={ChartTooltip} />\r\n <Legend wrapperStyle={{ fontSize: 12 }} iconType=\"circle\" />\r\n {series.map((s, i) => {\r\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\r\n return (\r\n <Radar key={s.key} dataKey={s.key} name={s.name ?? s.key}\r\n stroke={c} fill={c} fillOpacity={0.15} strokeWidth={2} />\r\n );\r\n })}\r\n </RadarChart>\r\n </ResponsiveContainer>\r\n);\r\n\r\n// ─── Re-export Recharts primitives for composed / custom charts ───────────────\r\nexport {\r\n ResponsiveContainer, LineChart, BarChart, AreaChart, PieChart, RadarChart,\r\n Line, Bar, Area, Pie, Cell, Radar,\r\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, PolarGrid, PolarAngleAxis,\r\n} from 'recharts';\r\nexport type { TooltipProps, TooltipContentProps } from 'recharts';\r\n\r\n// ─── Re-export tokens so consumers only need one import path ──────────────────\r\nexport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\r\nexport { CHART_COLORS } from './chart-tokens';\r\n"
216
216
  }
217
217
  ]
218
218
  },
@@ -242,19 +242,19 @@
242
242
  "files": [
243
243
  {
244
244
  "path": "src/components/ui/code-sandbox/CodeSandbox.tsx",
245
- "content": "import React, { useState, useEffect } from 'react';\nimport { SandpackProvider } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\nimport { SandboxLayout } from './SandboxLayout';\nimport { SANDBOX_TEMPLATES } from './templates';\nimport type { SandboxTemplate } from './templates';\n\n// ─── Dark mode detection ─────────────────────────────────────\n\nfunction useIsDark() {\n const [isDark, setIsDark] = useState(\n () => document.documentElement.classList.contains('dark'),\n );\n\n useEffect(() => {\n const observer = new MutationObserver(() => {\n setIsDark(document.documentElement.classList.contains('dark'));\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n });\n return () => observer.disconnect();\n }, []);\n\n return isDark;\n}\n\n// ─── Sandpack themes ─────────────────────────────────────────\n\nconst lightTheme = {\n colors: {\n surface1: '#ffffff',\n surface2: '#f8fafc',\n surface3: '#f1f5f9',\n clickable: '#64748b',\n base: '#0f172a',\n disabled: '#94a3b8',\n hover: '#0f172a',\n accent: '#2f27ce',\n error: '#ef4444',\n errorSurface: '#fef2f2',\n },\n syntax: {\n plain: '#0f172a',\n comment: { color: '#94a3b8', fontStyle: 'italic' as const },\n keyword: '#7c3aed',\n tag: '#2563eb',\n punctuation: '#64748b',\n definition: '#059669',\n property: '#0891b2',\n static: '#c2410c',\n string: '#16a34a',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\nconst darkTheme = {\n colors: {\n surface1: '#0f172a',\n surface2: '#1e293b',\n surface3: '#334155',\n clickable: '#94a3b8',\n base: '#e2e8f0',\n disabled: '#475569',\n hover: '#f8fafc',\n accent: '#6366f1',\n error: '#ef4444',\n errorSurface: '#450a0a',\n },\n syntax: {\n plain: '#e2e8f0',\n comment: { color: '#64748b', fontStyle: 'italic' as const },\n keyword: '#c084fc',\n tag: '#60a5fa',\n punctuation: '#94a3b8',\n definition: '#34d399',\n property: '#22d3ee',\n static: '#fb923c',\n string: '#4ade80',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\n// ─── Props ───────────────────────────────────────────────────\n\nexport interface CodeSandboxProps {\n /** Starting template id */\n defaultTemplate?: string;\n /** Custom initial files (overrides template) */\n files?: Record<string, string>;\n /** Extra dependencies */\n dependencies?: Record<string, string>;\n /** Container className */\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────\n\nexport function CodeSandbox({\n defaultTemplate = 'react-ts',\n files: customFiles,\n dependencies: extraDeps,\n className,\n}: CodeSandboxProps) {\n const isDark = useIsDark();\n const [templateId, setTemplateId] = useState(defaultTemplate);\n\n const template: SandboxTemplate =\n SANDBOX_TEMPLATES.find((t) => t.id === templateId) ??\n SANDBOX_TEMPLATES[0];\n\n const files = customFiles ?? template.files;\n const deps = {\n ...template.dependencies,\n ...extraDeps,\n };\n\n return (\n <div className={cn('h-full w-full', className)}>\n <SandpackProvider\n key={templateId}\n template={template.template}\n theme={isDark ? darkTheme : lightTheme}\n files={files}\n customSetup={{\n dependencies: {\n react: '^18.0.0',\n 'react-dom': '^18.0.0',\n 'react-scripts': '^5.0.0',\n ...deps,\n },\n }}\n options={{\n recompileMode: 'delayed',\n recompileDelay: 400,\n classes: {\n 'sp-wrapper': 'h-full w-full',\n },\n }}\n >\n <SandboxLayout\n templateId={templateId}\n onTemplateChange={setTemplateId}\n className=\"h-full w-full\"\n />\n </SandpackProvider>\n </div>\n );\n}\n"
245
+ "content": "import React, { useState, useEffect } from 'react';\r\nimport { SandpackProvider } from '@codesandbox/sandpack-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { SandboxLayout } from './SandboxLayout';\r\nimport { SANDBOX_TEMPLATES } from './templates';\r\nimport type { SandboxTemplate } from './templates';\r\n\r\n// ─── Dark mode detection ─────────────────────────────────────\r\n\r\nfunction useIsDark() {\r\n const [isDark, setIsDark] = useState(\r\n () => document.documentElement.classList.contains('dark'),\r\n );\r\n\r\n useEffect(() => {\r\n const observer = new MutationObserver(() => {\r\n setIsDark(document.documentElement.classList.contains('dark'));\r\n });\r\n observer.observe(document.documentElement, {\r\n attributes: true,\r\n attributeFilter: ['class'],\r\n });\r\n return () => observer.disconnect();\r\n }, []);\r\n\r\n return isDark;\r\n}\r\n\r\n// ─── Sandpack themes ─────────────────────────────────────────\r\n\r\nconst lightTheme = {\r\n colors: {\r\n surface1: '#ffffff',\r\n surface2: '#f8fafc',\r\n surface3: '#f1f5f9',\r\n clickable: '#64748b',\r\n base: '#0f172a',\r\n disabled: '#94a3b8',\r\n hover: '#0f172a',\r\n accent: '#2f27ce',\r\n error: '#ef4444',\r\n errorSurface: '#fef2f2',\r\n },\r\n syntax: {\r\n plain: '#0f172a',\r\n comment: { color: '#94a3b8', fontStyle: 'italic' as const },\r\n keyword: '#7c3aed',\r\n tag: '#2563eb',\r\n punctuation: '#64748b',\r\n definition: '#059669',\r\n property: '#0891b2',\r\n static: '#c2410c',\r\n string: '#16a34a',\r\n },\r\n font: {\r\n body: 'Inter, system-ui, sans-serif',\r\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\r\n size: '13px',\r\n lineHeight: '20px',\r\n },\r\n};\r\n\r\nconst darkTheme = {\r\n colors: {\r\n surface1: '#0f172a',\r\n surface2: '#1e293b',\r\n surface3: '#334155',\r\n clickable: '#94a3b8',\r\n base: '#e2e8f0',\r\n disabled: '#475569',\r\n hover: '#f8fafc',\r\n accent: '#6366f1',\r\n error: '#ef4444',\r\n errorSurface: '#450a0a',\r\n },\r\n syntax: {\r\n plain: '#e2e8f0',\r\n comment: { color: '#64748b', fontStyle: 'italic' as const },\r\n keyword: '#c084fc',\r\n tag: '#60a5fa',\r\n punctuation: '#94a3b8',\r\n definition: '#34d399',\r\n property: '#22d3ee',\r\n static: '#fb923c',\r\n string: '#4ade80',\r\n },\r\n font: {\r\n body: 'Inter, system-ui, sans-serif',\r\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\r\n size: '13px',\r\n lineHeight: '20px',\r\n },\r\n};\r\n\r\n// ─── Props ───────────────────────────────────────────────────\r\n\r\nexport interface CodeSandboxProps {\r\n /** Starting template id */\r\n defaultTemplate?: string;\r\n /** Custom initial files (overrides template) */\r\n files?: Record<string, string>;\r\n /** Extra dependencies */\r\n dependencies?: Record<string, string>;\r\n /** Container className */\r\n className?: string;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────\r\n\r\nexport function CodeSandbox({\r\n defaultTemplate = 'react-ts',\r\n files: customFiles,\r\n dependencies: extraDeps,\r\n className,\r\n}: CodeSandboxProps) {\r\n const isDark = useIsDark();\r\n const [templateId, setTemplateId] = useState(defaultTemplate);\r\n\r\n const template: SandboxTemplate =\r\n SANDBOX_TEMPLATES.find((t) => t.id === templateId) ??\r\n SANDBOX_TEMPLATES[0];\r\n\r\n const files = customFiles ?? template.files;\r\n const deps = {\r\n ...template.dependencies,\r\n ...extraDeps,\r\n };\r\n\r\n return (\r\n <div className={cn('h-full w-full', className)}>\r\n <SandpackProvider\r\n key={templateId}\r\n template={template.template}\r\n theme={isDark ? darkTheme : lightTheme}\r\n files={files}\r\n customSetup={{\r\n dependencies: {\r\n react: '^18.0.0',\r\n 'react-dom': '^18.0.0',\r\n 'react-scripts': '^5.0.0',\r\n ...deps,\r\n },\r\n }}\r\n options={{\r\n recompileMode: 'delayed',\r\n recompileDelay: 400,\r\n classes: {\r\n 'sp-wrapper': 'h-full w-full',\r\n },\r\n }}\r\n >\r\n <SandboxLayout\r\n templateId={templateId}\r\n onTemplateChange={setTemplateId}\r\n className=\"h-full w-full\"\r\n />\r\n </SandpackProvider>\r\n </div>\r\n );\r\n}\r\n"
246
246
  },
247
247
  {
248
248
  "path": "src/components/ui/code-sandbox/DependencyPanel.tsx",
249
- "content": "import React, { useState } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport { Package, Plus, X } from 'lucide-react';\n\nconst POPULAR_PACKAGES = [\n 'axios',\n 'framer-motion',\n 'zustand',\n 'react-router-dom',\n 'date-fns',\n 'clsx',\n 'zod',\n 'react-hook-form',\n 'swr',\n 'lodash',\n];\n\nexport function DependencyPanel() {\n const { sandpack } = useSandpack();\n const [newDep, setNewDep] = useState('');\n\n let deps: Record<string, string> = {};\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n deps = pkg.dependencies || {};\n } catch {\n /* ignore parse error */\n }\n\n const addDep = (name: string) => {\n if (!name.trim() || deps[name]) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n pkg.dependencies = { ...pkg.dependencies, [name.trim()]: 'latest' };\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n setNewDep('');\n } catch {\n /* ignore */\n }\n };\n\n const removeDep = (name: string) => {\n if (['react', 'react-dom', 'react-scripts'].includes(name)) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n const { [name]: _, ...rest } = pkg.dependencies || {};\n pkg.dependencies = rest;\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n } catch {\n /* ignore */\n }\n };\n\n const available = POPULAR_PACKAGES.filter((p) => !deps[p]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Dependencies\n </div>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n addDep(newDep);\n }}\n className=\"flex gap-1 px-3 pb-2\"\n >\n <input\n value={newDep}\n onChange={(e) => setNewDep(e.target.value)}\n placeholder=\"Package name...\"\n className=\"flex-1 min-w-0 bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n <button\n type=\"submit\"\n className=\"px-2 py-1.5 bg-primary text-primary-foreground rounded text-xs font-medium hover:bg-primary/90 transition-colors shrink-0\"\n >\n <Plus className=\"w-3.5 h-3.5\" />\n </button>\n </form>\n\n {available.length > 0 && (\n <div className=\"px-3 pb-3\">\n <div className=\"text-[10px] text-muted-foreground mb-1.5\">\n Quick add:\n </div>\n <div className=\"flex flex-wrap gap-1\">\n {available.slice(0, 5).map((p) => (\n <button\n key={p}\n onClick={() => addDep(p)}\n className=\"text-[10px] px-1.5 py-0.5 bg-muted hover:bg-accent rounded border border-border transition-colors\"\n >\n + {p}\n </button>\n ))}\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto border-t border-border\">\n <div className=\"px-3 py-1.5 text-[10px] text-muted-foreground\">\n Installed ({Object.keys(deps).length})\n </div>\n {Object.entries(deps).map(([name, version]) => (\n <div\n key={name}\n className=\"flex items-center justify-between px-3 py-1.5 hover:bg-muted/50 text-[13px] group\"\n >\n <div className=\"flex items-center gap-1.5 truncate min-w-0\">\n <Package className=\"w-3 h-3 text-muted-foreground shrink-0\" />\n <span className=\"truncate\">{name}</span>\n <span className=\"text-[10px] text-muted-foreground shrink-0\">\n {version as string}\n </span>\n </div>\n {!['react', 'react-dom', 'react-scripts'].includes(name) && (\n <button\n onClick={() => removeDep(name)}\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger transition-opacity shrink-0\"\n >\n <X className=\"w-3 h-3\" />\n </button>\n )}\n </div>\n ))}\n </div>\n </div>\n );\n}\n"
249
+ "content": "import React, { useState } from 'react';\r\nimport { useSandpack } from '@codesandbox/sandpack-react';\r\nimport { Package, Plus, X } from 'lucide-react';\r\n\r\nconst POPULAR_PACKAGES = [\r\n 'axios',\r\n 'framer-motion',\r\n 'zustand',\r\n 'react-router-dom',\r\n 'date-fns',\r\n 'clsx',\r\n 'zod',\r\n 'react-hook-form',\r\n 'swr',\r\n 'lodash',\r\n];\r\n\r\nexport function DependencyPanel() {\r\n const { sandpack } = useSandpack();\r\n const [newDep, setNewDep] = useState('');\r\n\r\n let deps: Record<string, string> = {};\r\n try {\r\n const pkg = JSON.parse(\r\n sandpack.files['/package.json']?.code || '{}',\r\n );\r\n deps = pkg.dependencies || {};\r\n } catch {\r\n /* ignore parse error */\r\n }\r\n\r\n const addDep = (name: string) => {\r\n if (!name.trim() || deps[name]) return;\r\n try {\r\n const pkg = JSON.parse(\r\n sandpack.files['/package.json']?.code || '{}',\r\n );\r\n pkg.dependencies = { ...pkg.dependencies, [name.trim()]: 'latest' };\r\n sandpack.updateFile(\r\n '/package.json',\r\n JSON.stringify(pkg, null, 2),\r\n );\r\n setNewDep('');\r\n } catch {\r\n /* ignore */\r\n }\r\n };\r\n\r\n const removeDep = (name: string) => {\r\n if (['react', 'react-dom', 'react-scripts'].includes(name)) return;\r\n try {\r\n const pkg = JSON.parse(\r\n sandpack.files['/package.json']?.code || '{}',\r\n );\r\n const { [name]: _, ...rest } = pkg.dependencies || {};\r\n pkg.dependencies = rest;\r\n sandpack.updateFile(\r\n '/package.json',\r\n JSON.stringify(pkg, null, 2),\r\n );\r\n } catch {\r\n /* ignore */\r\n }\r\n };\r\n\r\n const available = POPULAR_PACKAGES.filter((p) => !deps[p]);\r\n\r\n return (\r\n <div className=\"flex flex-col h-full text-foreground\">\r\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\r\n Dependencies\r\n </div>\r\n\r\n <form\r\n onSubmit={(e) => {\r\n e.preventDefault();\r\n addDep(newDep);\r\n }}\r\n className=\"flex gap-1 px-3 pb-2\"\r\n >\r\n <input\r\n value={newDep}\r\n onChange={(e) => setNewDep(e.target.value)}\r\n placeholder=\"Package name...\"\r\n className=\"flex-1 min-w-0 bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\r\n />\r\n <button\r\n type=\"submit\"\r\n className=\"px-2 py-1.5 bg-primary text-primary-foreground rounded text-xs font-medium hover:bg-primary/90 transition-colors shrink-0\"\r\n >\r\n <Plus className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </form>\r\n\r\n {available.length > 0 && (\r\n <div className=\"px-3 pb-3\">\r\n <div className=\"text-[10px] text-muted-foreground mb-1.5\">\r\n Quick add:\r\n </div>\r\n <div className=\"flex flex-wrap gap-1\">\r\n {available.slice(0, 5).map((p) => (\r\n <button\r\n key={p}\r\n onClick={() => addDep(p)}\r\n className=\"text-[10px] px-1.5 py-0.5 bg-muted hover:bg-accent rounded border border-border transition-colors\"\r\n >\r\n + {p}\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n )}\r\n\r\n <div className=\"flex-1 overflow-y-auto border-t border-border\">\r\n <div className=\"px-3 py-1.5 text-[10px] text-muted-foreground\">\r\n Installed ({Object.keys(deps).length})\r\n </div>\r\n {Object.entries(deps).map(([name, version]) => (\r\n <div\r\n key={name}\r\n className=\"flex items-center justify-between px-3 py-1.5 hover:bg-muted/50 text-[13px] group\"\r\n >\r\n <div className=\"flex items-center gap-1.5 truncate min-w-0\">\r\n <Package className=\"w-3 h-3 text-muted-foreground shrink-0\" />\r\n <span className=\"truncate\">{name}</span>\r\n <span className=\"text-[10px] text-muted-foreground shrink-0\">\r\n {version as string}\r\n </span>\r\n </div>\r\n {!['react', 'react-dom', 'react-scripts'].includes(name) && (\r\n <button\r\n onClick={() => removeDep(name)}\r\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger transition-opacity shrink-0\"\r\n >\r\n <X className=\"w-3 h-3\" />\r\n </button>\r\n )}\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
250
250
  },
251
251
  {
252
252
  "path": "src/components/ui/code-sandbox/FileTree.tsx",
253
- "content": "import React, { useState, useMemo } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport {\n ChevronRight,\n ChevronDown,\n FilePlus,\n FolderPlus,\n Trash2,\n Folder,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Tree data structure ─────────────────────────────────────\n\ninterface TreeNode {\n name: string;\n path: string;\n type: 'file' | 'folder';\n children?: TreeNode[];\n}\n\nfunction buildFileTree(paths: string[]): TreeNode[] {\n const root: TreeNode[] = [];\n\n for (const filePath of paths) {\n const parts = filePath.split('/').filter(Boolean);\n let level = root;\n let currentPath = '';\n\n for (let i = 0; i < parts.length; i++) {\n currentPath += '/' + parts[i];\n const isLast = i === parts.length - 1;\n\n if (isLast) {\n level.push({ name: parts[i], path: currentPath, type: 'file' });\n } else {\n let folder = level.find(\n (n) => n.type === 'folder' && n.name === parts[i],\n );\n if (!folder) {\n folder = {\n name: parts[i],\n path: currentPath,\n type: 'folder',\n children: [],\n };\n level.push(folder);\n }\n level = folder.children!;\n }\n }\n }\n\n function sortNodes(nodes: TreeNode[]): TreeNode[] {\n return nodes\n .sort((a, b) => {\n if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;\n return a.name.localeCompare(b.name);\n })\n .map((n) => {\n if (n.children) n.children = sortNodes(n.children);\n return n;\n });\n }\n\n return sortNodes(root);\n}\n\n// ─── File icon by extension ──────────────────────────────────\n\nfunction getFileIcon(name: string): { label: string; color: string } {\n const ext = name.split('.').pop()?.toLowerCase();\n switch (ext) {\n case 'js':\n case 'mjs':\n return { label: 'JS', color: '#f7df1e' };\n case 'jsx':\n return { label: 'JSX', color: '#61dafb' };\n case 'ts':\n return { label: 'TS', color: '#3178c6' };\n case 'tsx':\n return { label: 'TSX', color: '#3178c6' };\n case 'css':\n case 'scss':\n return { label: '#', color: '#264de4' };\n case 'html':\n return { label: '<>', color: '#e34f26' };\n case 'json':\n return { label: '{ }', color: '#5b5b5b' };\n case 'md':\n return { label: 'M', color: '#083fa1' };\n case 'svg':\n return { label: 'SVG', color: '#ffb13b' };\n default:\n return { label: '\\u00B7', color: '#6b7280' };\n }\n}\n\n// ─── Single tree node ────────────────────────────────────────\n\nfunction TreeItem({\n node,\n depth,\n activeFile,\n openFile,\n deleteFile,\n expanded,\n toggleFolder,\n}: {\n node: TreeNode;\n depth: number;\n activeFile: string;\n openFile: (p: string) => void;\n deleteFile: (p: string) => void;\n expanded: Set<string>;\n toggleFolder: (p: string) => void;\n}) {\n const pl = 12 + depth * 16;\n\n if (node.type === 'folder') {\n const isOpen = expanded.has(node.path);\n return (\n <>\n <div\n className=\"flex items-center py-1 px-2 cursor-pointer hover:bg-muted/50 text-[13px] group\"\n style={{ paddingLeft: pl }}\n onClick={() => toggleFolder(node.path)}\n >\n {isOpen ? (\n <ChevronDown className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n ) : (\n <ChevronRight className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n )}\n <Folder className=\"w-3.5 h-3.5 mr-1.5 shrink-0 text-amber-500\" />\n <span className=\"truncate\">{node.name}</span>\n </div>\n {isOpen &&\n node.children?.map((child) => (\n <TreeItem\n key={child.path}\n node={child}\n depth={depth + 1}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </>\n );\n }\n\n const icon = getFileIcon(node.name);\n const isActive = node.path === activeFile;\n\n return (\n <div\n className={cn(\n 'flex items-center py-1 px-2 cursor-pointer text-[13px] group',\n isActive\n ? 'bg-primary/10 text-primary font-medium'\n : 'hover:bg-muted/50',\n )}\n style={{ paddingLeft: pl }}\n onClick={() => openFile(node.path)}\n >\n <span\n className=\"w-5 mr-1.5 shrink-0 text-[9px] font-bold text-center leading-none\"\n style={{ color: icon.color }}\n >\n {icon.label}\n </span>\n <span className=\"truncate flex-1\">{node.name}</span>\n <button\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger shrink-0 transition-opacity\"\n onClick={(e) => {\n e.stopPropagation();\n deleteFile(node.path);\n }}\n >\n <Trash2 className=\"w-3 h-3\" />\n </button>\n </div>\n );\n}\n\n// ─── File Tree ───────────────────────────────────────────────\n\nexport function FileTree() {\n const { sandpack } = useSandpack();\n const { files, activeFile, openFile, addFile, deleteFile } = sandpack;\n\n const [expanded, setExpanded] = useState<Set<string>>(\n () =>\n new Set([\n '/',\n '/src',\n '/components',\n '/examples',\n '/public',\n ]),\n );\n const [creating, setCreating] = useState<'file' | 'folder' | null>(null);\n const [newName, setNewName] = useState('');\n\n const filePaths = Object.keys(files).filter((p) => !p.endsWith('.gitkeep'));\n const tree = useMemo(() => buildFileTree(filePaths), [filePaths]);\n\n const toggleFolder = (path: string) => {\n setExpanded((prev) => {\n const next = new Set(prev);\n if (next.has(path)) next.delete(path);\n else next.add(path);\n return next;\n });\n };\n\n const handleCreate = (e: React.FormEvent) => {\n e.preventDefault();\n if (!newName.trim()) return;\n const path = newName.startsWith('/') ? newName : '/' + newName;\n if (creating === 'file') {\n addFile(path, '');\n openFile(path);\n } else {\n addFile(`${path}/.gitkeep`, '');\n setExpanded((prev) => new Set([...prev, path]));\n }\n setNewName('');\n setCreating(null);\n };\n\n return (\n <div className=\"flex flex-col h-full text-foreground select-none\">\n {/* Header */}\n <div className=\"flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0 group\">\n <span>Explorer</span>\n <div className=\"flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity\">\n <button\n onClick={() => setCreating('file')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New File\"\n >\n <FilePlus className=\"w-3.5 h-3.5\" />\n </button>\n <button\n onClick={() => setCreating('folder')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New Folder\"\n >\n <FolderPlus className=\"w-3.5 h-3.5\" />\n </button>\n </div>\n </div>\n\n {/* Create input */}\n {creating && (\n <form onSubmit={handleCreate} className=\"px-3 pb-2\">\n <input\n autoFocus\n type=\"text\"\n placeholder={\n creating === 'file'\n ? '/src/NewFile.tsx'\n : '/src/newfolder'\n }\n className=\"w-full bg-muted border border-primary text-foreground text-[13px] px-2 py-1 rounded outline-none font-mono\"\n value={newName}\n onChange={(e) => setNewName(e.target.value)}\n onBlur={() => setCreating(null)}\n />\n </form>\n )}\n\n {/* Tree */}\n <div className=\"flex-1 overflow-y-auto\">\n {tree.map((node) => (\n <TreeItem\n key={node.path}\n node={node}\n depth={0}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </div>\n </div>\n );\n}\n"
253
+ "content": "import React, { useState, useMemo } from 'react';\r\nimport { useSandpack } from '@codesandbox/sandpack-react';\r\nimport {\r\n ChevronRight,\r\n ChevronDown,\r\n FilePlus,\r\n FolderPlus,\r\n Trash2,\r\n Folder,\r\n} from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Tree data structure ─────────────────────────────────────\r\n\r\ninterface TreeNode {\r\n name: string;\r\n path: string;\r\n type: 'file' | 'folder';\r\n children?: TreeNode[];\r\n}\r\n\r\nfunction buildFileTree(paths: string[]): TreeNode[] {\r\n const root: TreeNode[] = [];\r\n\r\n for (const filePath of paths) {\r\n const parts = filePath.split('/').filter(Boolean);\r\n let level = root;\r\n let currentPath = '';\r\n\r\n for (let i = 0; i < parts.length; i++) {\r\n currentPath += '/' + parts[i];\r\n const isLast = i === parts.length - 1;\r\n\r\n if (isLast) {\r\n level.push({ name: parts[i], path: currentPath, type: 'file' });\r\n } else {\r\n let folder = level.find(\r\n (n) => n.type === 'folder' && n.name === parts[i],\r\n );\r\n if (!folder) {\r\n folder = {\r\n name: parts[i],\r\n path: currentPath,\r\n type: 'folder',\r\n children: [],\r\n };\r\n level.push(folder);\r\n }\r\n level = folder.children!;\r\n }\r\n }\r\n }\r\n\r\n function sortNodes(nodes: TreeNode[]): TreeNode[] {\r\n return nodes\r\n .sort((a, b) => {\r\n if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;\r\n return a.name.localeCompare(b.name);\r\n })\r\n .map((n) => {\r\n if (n.children) n.children = sortNodes(n.children);\r\n return n;\r\n });\r\n }\r\n\r\n return sortNodes(root);\r\n}\r\n\r\n// ─── File icon by extension ──────────────────────────────────\r\n\r\nfunction getFileIcon(name: string): { label: string; color: string } {\r\n const ext = name.split('.').pop()?.toLowerCase();\r\n switch (ext) {\r\n case 'js':\r\n case 'mjs':\r\n return { label: 'JS', color: '#f7df1e' };\r\n case 'jsx':\r\n return { label: 'JSX', color: '#61dafb' };\r\n case 'ts':\r\n return { label: 'TS', color: '#3178c6' };\r\n case 'tsx':\r\n return { label: 'TSX', color: '#3178c6' };\r\n case 'css':\r\n case 'scss':\r\n return { label: '#', color: '#264de4' };\r\n case 'html':\r\n return { label: '<>', color: '#e34f26' };\r\n case 'json':\r\n return { label: '{ }', color: '#5b5b5b' };\r\n case 'md':\r\n return { label: 'M', color: '#083fa1' };\r\n case 'svg':\r\n return { label: 'SVG', color: '#ffb13b' };\r\n default:\r\n return { label: '\\u00B7', color: '#6b7280' };\r\n }\r\n}\r\n\r\n// ─── Single tree node ────────────────────────────────────────\r\n\r\nfunction TreeItem({\r\n node,\r\n depth,\r\n activeFile,\r\n openFile,\r\n deleteFile,\r\n expanded,\r\n toggleFolder,\r\n}: {\r\n node: TreeNode;\r\n depth: number;\r\n activeFile: string;\r\n openFile: (p: string) => void;\r\n deleteFile: (p: string) => void;\r\n expanded: Set<string>;\r\n toggleFolder: (p: string) => void;\r\n}) {\r\n const pl = 12 + depth * 16;\r\n\r\n if (node.type === 'folder') {\r\n const isOpen = expanded.has(node.path);\r\n return (\r\n <>\r\n <div\r\n className=\"flex items-center py-1 px-2 cursor-pointer hover:bg-muted/50 text-[13px] group\"\r\n style={{ paddingLeft: pl }}\r\n onClick={() => toggleFolder(node.path)}\r\n >\r\n {isOpen ? (\r\n <ChevronDown className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\r\n ) : (\r\n <ChevronRight className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\r\n )}\r\n <Folder className=\"w-3.5 h-3.5 mr-1.5 shrink-0 text-amber-500\" />\r\n <span className=\"truncate\">{node.name}</span>\r\n </div>\r\n {isOpen &&\r\n node.children?.map((child) => (\r\n <TreeItem\r\n key={child.path}\r\n node={child}\r\n depth={depth + 1}\r\n activeFile={activeFile}\r\n openFile={openFile}\r\n deleteFile={deleteFile}\r\n expanded={expanded}\r\n toggleFolder={toggleFolder}\r\n />\r\n ))}\r\n </>\r\n );\r\n }\r\n\r\n const icon = getFileIcon(node.name);\r\n const isActive = node.path === activeFile;\r\n\r\n return (\r\n <div\r\n className={cn(\r\n 'flex items-center py-1 px-2 cursor-pointer text-[13px] group',\r\n isActive\r\n ? 'bg-primary/10 text-primary font-medium'\r\n : 'hover:bg-muted/50',\r\n )}\r\n style={{ paddingLeft: pl }}\r\n onClick={() => openFile(node.path)}\r\n >\r\n <span\r\n className=\"w-5 mr-1.5 shrink-0 text-[9px] font-bold text-center leading-none\"\r\n style={{ color: icon.color }}\r\n >\r\n {icon.label}\r\n </span>\r\n <span className=\"truncate flex-1\">{node.name}</span>\r\n <button\r\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger shrink-0 transition-opacity\"\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n deleteFile(node.path);\r\n }}\r\n >\r\n <Trash2 className=\"w-3 h-3\" />\r\n </button>\r\n </div>\r\n );\r\n}\r\n\r\n// ─── File Tree ───────────────────────────────────────────────\r\n\r\nexport function FileTree() {\r\n const { sandpack } = useSandpack();\r\n const { files, activeFile, openFile, addFile, deleteFile } = sandpack;\r\n\r\n const [expanded, setExpanded] = useState<Set<string>>(\r\n () =>\r\n new Set([\r\n '/',\r\n '/src',\r\n '/components',\r\n '/examples',\r\n '/public',\r\n ]),\r\n );\r\n const [creating, setCreating] = useState<'file' | 'folder' | null>(null);\r\n const [newName, setNewName] = useState('');\r\n\r\n const filePaths = Object.keys(files).filter((p) => !p.endsWith('.gitkeep'));\r\n const tree = useMemo(() => buildFileTree(filePaths), [filePaths]);\r\n\r\n const toggleFolder = (path: string) => {\r\n setExpanded((prev) => {\r\n const next = new Set(prev);\r\n if (next.has(path)) next.delete(path);\r\n else next.add(path);\r\n return next;\r\n });\r\n };\r\n\r\n const handleCreate = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n if (!newName.trim()) return;\r\n const path = newName.startsWith('/') ? newName : '/' + newName;\r\n if (creating === 'file') {\r\n addFile(path, '');\r\n openFile(path);\r\n } else {\r\n addFile(`${path}/.gitkeep`, '');\r\n setExpanded((prev) => new Set([...prev, path]));\r\n }\r\n setNewName('');\r\n setCreating(null);\r\n };\r\n\r\n return (\r\n <div className=\"flex flex-col h-full text-foreground select-none\">\r\n {/* Header */}\r\n <div className=\"flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0 group\">\r\n <span>Explorer</span>\r\n <div className=\"flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity\">\r\n <button\r\n onClick={() => setCreating('file')}\r\n className=\"p-1 hover:bg-muted rounded\"\r\n title=\"New File\"\r\n >\r\n <FilePlus className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n onClick={() => setCreating('folder')}\r\n className=\"p-1 hover:bg-muted rounded\"\r\n title=\"New Folder\"\r\n >\r\n <FolderPlus className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* Create input */}\r\n {creating && (\r\n <form onSubmit={handleCreate} className=\"px-3 pb-2\">\r\n <input\r\n autoFocus\r\n type=\"text\"\r\n placeholder={\r\n creating === 'file'\r\n ? '/src/NewFile.tsx'\r\n : '/src/newfolder'\r\n }\r\n className=\"w-full bg-muted border border-primary text-foreground text-[13px] px-2 py-1 rounded outline-none font-mono\"\r\n value={newName}\r\n onChange={(e) => setNewName(e.target.value)}\r\n onBlur={() => setCreating(null)}\r\n />\r\n </form>\r\n )}\r\n\r\n {/* Tree */}\r\n <div className=\"flex-1 overflow-y-auto\">\r\n {tree.map((node) => (\r\n <TreeItem\r\n key={node.path}\r\n node={node}\r\n depth={0}\r\n activeFile={activeFile}\r\n openFile={openFile}\r\n deleteFile={deleteFile}\r\n expanded={expanded}\r\n toggleFolder={toggleFolder}\r\n />\r\n ))}\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
254
254
  },
255
255
  {
256
256
  "path": "src/components/ui/code-sandbox/SandboxActivityBar.tsx",
257
- "content": "import React from 'react';\nimport { Files, Search, Package, Settings } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\nexport type SidebarTab = 'explorer' | 'search' | 'dependencies';\n\ninterface ActivityBarProps {\n activeTab: SidebarTab | null;\n onTabChange: (tab: SidebarTab | null) => void;\n}\n\nconst tabs: { id: SidebarTab; icon: React.ReactNode; label: string }[] = [\n { id: 'explorer', icon: <Files className=\"w-[18px] h-[18px]\" />, label: 'Explorer' },\n { id: 'search', icon: <Search className=\"w-[18px] h-[18px]\" />, label: 'Search' },\n { id: 'dependencies', icon: <Package className=\"w-[18px] h-[18px]\" />, label: 'Dependencies' },\n];\n\nexport function SandboxActivityBar({ activeTab, onTabChange }: ActivityBarProps) {\n return (\n <div className=\"w-11 bg-muted/30 border-r border-border flex flex-col items-center py-1.5 shrink-0\">\n {tabs.map((tab) => (\n <button\n key={tab.id}\n onClick={() => onTabChange(activeTab === tab.id ? null : tab.id)}\n className={cn(\n 'relative w-full h-9 flex items-center justify-center transition-colors',\n activeTab === tab.id\n ? 'text-foreground'\n : 'text-muted-foreground hover:text-foreground',\n )}\n title={tab.label}\n >\n {activeTab === tab.id && (\n <span className=\"absolute left-0 top-[20%] bottom-[20%] w-0.5 bg-primary rounded-r\" />\n )}\n {tab.icon}\n </button>\n ))}\n <div className=\"flex-1\" />\n <button\n className=\"w-full h-9 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors\"\n title=\"Settings\"\n >\n <Settings className=\"w-[18px] h-[18px]\" />\n </button>\n </div>\n );\n}\n"
257
+ "content": "import React from 'react';\r\nimport { Files, Search, Package, Settings } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nexport type SidebarTab = 'explorer' | 'search' | 'dependencies';\r\n\r\ninterface ActivityBarProps {\r\n activeTab: SidebarTab | null;\r\n onTabChange: (tab: SidebarTab | null) => void;\r\n}\r\n\r\nconst tabs: { id: SidebarTab; icon: React.ReactNode; label: string }[] = [\r\n { id: 'explorer', icon: <Files className=\"w-[18px] h-[18px]\" />, label: 'Explorer' },\r\n { id: 'search', icon: <Search className=\"w-[18px] h-[18px]\" />, label: 'Search' },\r\n { id: 'dependencies', icon: <Package className=\"w-[18px] h-[18px]\" />, label: 'Dependencies' },\r\n];\r\n\r\nexport function SandboxActivityBar({ activeTab, onTabChange }: ActivityBarProps) {\r\n return (\r\n <div className=\"w-11 bg-muted/30 border-r border-border flex flex-col items-center py-1.5 shrink-0\">\r\n {tabs.map((tab) => (\r\n <button\r\n key={tab.id}\r\n onClick={() => onTabChange(activeTab === tab.id ? null : tab.id)}\r\n className={cn(\r\n 'relative w-full h-9 flex items-center justify-center transition-colors',\r\n activeTab === tab.id\r\n ? 'text-foreground'\r\n : 'text-muted-foreground hover:text-foreground',\r\n )}\r\n title={tab.label}\r\n >\r\n {activeTab === tab.id && (\r\n <span className=\"absolute left-0 top-[20%] bottom-[20%] w-0.5 bg-primary rounded-r\" />\r\n )}\r\n {tab.icon}\r\n </button>\r\n ))}\r\n <div className=\"flex-1\" />\r\n <button\r\n className=\"w-full h-9 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors\"\r\n title=\"Settings\"\r\n >\r\n <Settings className=\"w-[18px] h-[18px]\" />\r\n </button>\r\n </div>\r\n );\r\n}\r\n"
258
258
  },
259
259
  {
260
260
  "path": "src/components/ui/code-sandbox/SandboxLayout.tsx",
@@ -262,23 +262,23 @@
262
262
  },
263
263
  {
264
264
  "path": "src/components/ui/code-sandbox/SandboxStatusBar.tsx",
265
- "content": "import React from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\n\nexport function SandboxStatusBar() {\n const { sandpack } = useSandpack();\n const fileName = sandpack.activeFile?.split('/').pop() || '';\n const ext = fileName.split('.').pop()?.toUpperCase() || '';\n\n return (\n <div className=\"h-6 bg-primary text-primary-foreground flex items-center px-3 text-[10px] font-medium shrink-0 gap-3\">\n <span className=\"flex items-center gap-1.5\">\n <span\n className={cn(\n 'w-1.5 h-1.5 rounded-full',\n sandpack.status === 'running'\n ? 'bg-green-400'\n : sandpack.status === 'idle'\n ? 'bg-yellow-400'\n : 'bg-white/50',\n )}\n />\n {sandpack.status === 'running'\n ? 'Running'\n : sandpack.status === 'idle'\n ? 'Ready'\n : 'Loading'}\n </span>\n\n {sandpack.error && (\n <span className=\"bg-white/20 px-1.5 py-0.5 rounded text-[9px]\">\n 1 Error\n </span>\n )}\n\n <div className=\"flex-1\" />\n\n <span className=\"opacity-75\">{fileName}</span>\n <span className=\"opacity-75\">UTF-8</span>\n {ext && <span className=\"opacity-75\">{ext}</span>}\n </div>\n );\n}\n"
265
+ "content": "import React from 'react';\r\nimport { useSandpack } from '@codesandbox/sandpack-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nexport function SandboxStatusBar() {\r\n const { sandpack } = useSandpack();\r\n const fileName = sandpack.activeFile?.split('/').pop() || '';\r\n const ext = fileName.split('.').pop()?.toUpperCase() || '';\r\n\r\n return (\r\n <div className=\"h-6 bg-primary text-primary-foreground flex items-center px-3 text-[10px] font-medium shrink-0 gap-3\">\r\n <span className=\"flex items-center gap-1.5\">\r\n <span\r\n className={cn(\r\n 'w-1.5 h-1.5 rounded-full',\r\n sandpack.status === 'running'\r\n ? 'bg-green-400'\r\n : sandpack.status === 'idle'\r\n ? 'bg-yellow-400'\r\n : 'bg-white/50',\r\n )}\r\n />\r\n {sandpack.status === 'running'\r\n ? 'Running'\r\n : sandpack.status === 'idle'\r\n ? 'Ready'\r\n : 'Loading'}\r\n </span>\r\n\r\n {sandpack.error && (\r\n <span className=\"bg-white/20 px-1.5 py-0.5 rounded text-[9px]\">\r\n 1 Error\r\n </span>\r\n )}\r\n\r\n <div className=\"flex-1\" />\r\n\r\n <span className=\"opacity-75\">{fileName}</span>\r\n <span className=\"opacity-75\">UTF-8</span>\r\n {ext && <span className=\"opacity-75\">{ext}</span>}\r\n </div>\r\n );\r\n}\r\n"
266
266
  },
267
267
  {
268
268
  "path": "src/components/ui/code-sandbox/SandboxToolbar.tsx",
269
- "content": "import React, { useState } from 'react';\nimport { ChevronDown } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { SANDBOX_TEMPLATES } from './templates';\n\ninterface ToolbarProps {\n templateId: string;\n onTemplateChange: (id: string) => void;\n}\n\nexport function SandboxToolbar({ templateId, onTemplateChange }: ToolbarProps) {\n const [open, setOpen] = useState(false);\n const current = SANDBOX_TEMPLATES.find((t) => t.id === templateId);\n\n return (\n <div className=\"h-10 bg-muted/30 border-b border-border flex items-center px-3 shrink-0 gap-3\">\n <div className=\"relative\">\n <button\n onClick={() => setOpen(!open)}\n className=\"flex items-center gap-2 bg-background border border-border px-2.5 py-1.5 rounded-md hover:bg-muted transition-colors text-sm\"\n >\n <span>{current?.icon}</span>\n <span className=\"font-medium text-foreground\">{current?.label}</span>\n <ChevronDown className=\"w-3.5 h-3.5 text-muted-foreground\" />\n </button>\n\n {open && (\n <>\n <div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\n <div className=\"absolute top-full left-0 mt-1 bg-background border border-border rounded-lg shadow-xl z-50 min-w-[260px] py-1 animate-in fade-in slide-in-from-top-2 duration-200\">\n {SANDBOX_TEMPLATES.map((t) => (\n <button\n key={t.id}\n onClick={() => {\n onTemplateChange(t.id);\n setOpen(false);\n }}\n className={cn(\n 'w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-3 transition-colors',\n t.id === templateId && 'bg-primary/5',\n )}\n >\n <span className=\"text-lg shrink-0\">{t.icon}</span>\n <div className=\"min-w-0\">\n <div className=\"text-sm font-medium text-foreground\">{t.label}</div>\n <div className=\"text-xs text-muted-foreground\">{t.description}</div>\n </div>\n </button>\n ))}\n </div>\n </>\n )}\n </div>\n\n <div className=\"flex-1\" />\n\n <div className=\"text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded hidden sm:block\">\n react-playground\n </div>\n </div>\n );\n}\n"
269
+ "content": "import React, { useState } from 'react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { SANDBOX_TEMPLATES } from './templates';\r\n\r\ninterface ToolbarProps {\r\n templateId: string;\r\n onTemplateChange: (id: string) => void;\r\n}\r\n\r\nexport function SandboxToolbar({ templateId, onTemplateChange }: ToolbarProps) {\r\n const [open, setOpen] = useState(false);\r\n const current = SANDBOX_TEMPLATES.find((t) => t.id === templateId);\r\n\r\n return (\r\n <div className=\"h-10 bg-muted/30 border-b border-border flex items-center px-3 shrink-0 gap-3\">\r\n <div className=\"relative\">\r\n <button\r\n onClick={() => setOpen(!open)}\r\n className=\"flex items-center gap-2 bg-background border border-border px-2.5 py-1.5 rounded-md hover:bg-muted transition-colors text-sm\"\r\n >\r\n <span>{current?.icon}</span>\r\n <span className=\"font-medium text-foreground\">{current?.label}</span>\r\n <ChevronDown className=\"w-3.5 h-3.5 text-muted-foreground\" />\r\n </button>\r\n\r\n {open && (\r\n <>\r\n <div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\r\n <div className=\"absolute top-full left-0 mt-1 bg-background border border-border rounded-lg shadow-xl z-50 min-w-[260px] py-1 animate-in fade-in slide-in-from-top-2 duration-200\">\r\n {SANDBOX_TEMPLATES.map((t) => (\r\n <button\r\n key={t.id}\r\n onClick={() => {\r\n onTemplateChange(t.id);\r\n setOpen(false);\r\n }}\r\n className={cn(\r\n 'w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-3 transition-colors',\r\n t.id === templateId && 'bg-primary/5',\r\n )}\r\n >\r\n <span className=\"text-lg shrink-0\">{t.icon}</span>\r\n <div className=\"min-w-0\">\r\n <div className=\"text-sm font-medium text-foreground\">{t.label}</div>\r\n <div className=\"text-xs text-muted-foreground\">{t.description}</div>\r\n </div>\r\n </button>\r\n ))}\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n\r\n <div className=\"flex-1\" />\r\n\r\n <div className=\"text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded hidden sm:block\">\r\n react-playground\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
270
270
  },
271
271
  {
272
272
  "path": "src/components/ui/code-sandbox/SearchPanel.tsx",
273
- "content": "import React, { useState, useMemo } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\n\nexport function SearchPanel() {\n const { sandpack } = useSandpack();\n const [query, setQuery] = useState('');\n\n const results = useMemo(() => {\n if (!query.trim()) return [];\n const q = query.toLowerCase();\n const matches: { path: string; line: number; text: string }[] = [];\n for (const [path, file] of Object.entries(sandpack.files)) {\n file.code.split('\\n').forEach((line, i) => {\n if (line.toLowerCase().includes(q)) {\n matches.push({ path, line: i + 1, text: line.trim() });\n }\n });\n }\n return matches.slice(0, 100);\n }, [query, sandpack.files]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Search\n </div>\n <div className=\"px-3 pb-2\">\n <input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search in files...\"\n className=\"w-full bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n </div>\n <div className=\"flex-1 overflow-y-auto\">\n {results.length > 0 && (\n <div className=\"px-3 pb-1 text-[10px] text-muted-foreground\">\n {results.length} result{results.length !== 1 ? 's' : ''}\n </div>\n )}\n {results.map((r, i) => (\n <div\n key={`${r.path}:${r.line}:${i}`}\n className=\"px-3 py-1.5 cursor-pointer hover:bg-muted/50 text-xs border-b border-border/30\"\n onClick={() => sandpack.openFile(r.path)}\n >\n <div className=\"font-medium truncate\">\n {r.path.split('/').pop()}\n <span className=\"text-muted-foreground ml-1 font-normal\">\n {r.path}\n </span>\n </div>\n <div className=\"text-muted-foreground truncate font-mono\">\n <span className=\"text-primary mr-1\">{r.line}:</span>\n {r.text}\n </div>\n </div>\n ))}\n {query && results.length === 0 && (\n <div className=\"px-3 py-6 text-xs text-muted-foreground text-center\">\n No results found\n </div>\n )}\n </div>\n </div>\n );\n}\n"
273
+ "content": "import React, { useState, useMemo } from 'react';\r\nimport { useSandpack } from '@codesandbox/sandpack-react';\r\n\r\nexport function SearchPanel() {\r\n const { sandpack } = useSandpack();\r\n const [query, setQuery] = useState('');\r\n\r\n const results = useMemo(() => {\r\n if (!query.trim()) return [];\r\n const q = query.toLowerCase();\r\n const matches: { path: string; line: number; text: string }[] = [];\r\n for (const [path, file] of Object.entries(sandpack.files)) {\r\n file.code.split('\\n').forEach((line, i) => {\r\n if (line.toLowerCase().includes(q)) {\r\n matches.push({ path, line: i + 1, text: line.trim() });\r\n }\r\n });\r\n }\r\n return matches.slice(0, 100);\r\n }, [query, sandpack.files]);\r\n\r\n return (\r\n <div className=\"flex flex-col h-full text-foreground\">\r\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\r\n Search\r\n </div>\r\n <div className=\"px-3 pb-2\">\r\n <input\r\n value={query}\r\n onChange={(e) => setQuery(e.target.value)}\r\n placeholder=\"Search in files...\"\r\n className=\"w-full bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\r\n />\r\n </div>\r\n <div className=\"flex-1 overflow-y-auto\">\r\n {results.length > 0 && (\r\n <div className=\"px-3 pb-1 text-[10px] text-muted-foreground\">\r\n {results.length} result{results.length !== 1 ? 's' : ''}\r\n </div>\r\n )}\r\n {results.map((r, i) => (\r\n <div\r\n key={`${r.path}:${r.line}:${i}`}\r\n className=\"px-3 py-1.5 cursor-pointer hover:bg-muted/50 text-xs border-b border-border/30\"\r\n onClick={() => sandpack.openFile(r.path)}\r\n >\r\n <div className=\"font-medium truncate\">\r\n {r.path.split('/').pop()}\r\n <span className=\"text-muted-foreground ml-1 font-normal\">\r\n {r.path}\r\n </span>\r\n </div>\r\n <div className=\"text-muted-foreground truncate font-mono\">\r\n <span className=\"text-primary mr-1\">{r.line}:</span>\r\n {r.text}\r\n </div>\r\n </div>\r\n ))}\r\n {query && results.length === 0 && (\r\n <div className=\"px-3 py-6 text-xs text-muted-foreground text-center\">\r\n No results found\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
274
274
  },
275
275
  {
276
276
  "path": "src/components/ui/code-sandbox/templates.ts",
277
- "content": "export interface SandboxTemplate {\n id: string;\n label: string;\n description: string;\n icon: string;\n template: 'react' | 'react-ts';\n files: Record<string, string>;\n dependencies?: Record<string, string>;\n}\n\nexport const SANDBOX_TEMPLATES: SandboxTemplate[] = [\n {\n id: 'react',\n label: 'React',\n description: 'React with JavaScript',\n icon: '\\u269B\\uFE0F',\n template: 'react',\n files: {\n '/App.js': `import React, { useState } from 'react';\nimport './styles.css';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"app\">\n <h1>React Playground</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n}\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'react-ts',\n label: 'React + TypeScript',\n description: 'React with TypeScript strict mode',\n icon: '\\uD83D\\uDC8E',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React, { useState } from 'react';\nimport './styles.css';\n\ninterface AppProps {\n title?: string;\n}\n\nconst App: React.FC<AppProps> = ({ title = 'React + TypeScript' }) => {\n const [count, setCount] = useState<number>(0);\n\n return (\n <div className=\"app\">\n <h1>{title}</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n};\n\nexport default App;\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'component',\n label: 'Component Workshop',\n description: 'Build & test React components',\n icon: '\\uD83E\\uDDE9',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Button } from './components/Button';\nimport { Card } from './components/Card';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>Component Workshop</h1>\n <div className=\"grid\">\n <Card title=\"Button Variants\">\n <div className=\"row\">\n <Button variant=\"primary\">Primary</Button>\n <Button variant=\"secondary\">Secondary</Button>\n <Button variant=\"outline\">Outline</Button>\n </div>\n </Card>\n <Card title=\"Button Sizes\">\n <div className=\"row\">\n <Button size=\"sm\">Small</Button>\n <Button size=\"md\">Medium</Button>\n <Button size=\"lg\">Large</Button>\n </div>\n </Card>\n <Card title=\"Your Component\">\n <p style={{ color: '#64748b' }}>Create your own component in /components!</p>\n </Card>\n </div>\n </div>\n );\n}\n`,\n '/components/Button.tsx': `import React from 'react';\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n variant?: 'primary' | 'secondary' | 'outline';\n size?: 'sm' | 'md' | 'lg';\n}\n\nconst styles: Record<string, React.CSSProperties> = {\n primary: { background: '#3b82f6', color: 'white', border: 'none' },\n secondary: { background: '#e2e8f0', color: '#334155', border: 'none' },\n outline: { background: 'transparent', color: '#3b82f6', border: '1.5px solid #3b82f6' },\n sm: { padding: '6px 12px', fontSize: 12 },\n md: { padding: '8px 16px', fontSize: 14 },\n lg: { padding: '12px 24px', fontSize: 16 },\n};\n\nexport const Button: React.FC<ButtonProps> = ({\n children,\n variant = 'primary',\n size = 'md',\n style,\n ...props\n}) => (\n <button\n style={{\n borderRadius: 8,\n fontWeight: 500,\n cursor: 'pointer',\n transition: 'opacity 0.15s',\n ...styles[variant],\n ...styles[size],\n ...style,\n }}\n {...props}\n >\n {children}\n </button>\n);\n`,\n '/components/Card.tsx': `import React from 'react';\n\ninterface CardProps {\n title: string;\n children: React.ReactNode;\n}\n\nexport const Card: React.FC<CardProps> = ({ title, children }) => (\n <div style={{\n background: 'white',\n border: '1px solid #e2e8f0',\n borderRadius: 12,\n padding: 20,\n }}>\n <h3 style={{ fontSize: 16, marginBottom: 12, color: '#334155' }}>{title}</h3>\n {children}\n </div>\n);\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 800px; margin: 24px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\n.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\n@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }\n.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n`,\n },\n },\n {\n id: 'hooks',\n label: 'Hooks Playground',\n description: 'Practice React Hooks patterns',\n icon: '\\uD83E\\uDE9D',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Counter } from './examples/Counter';\nimport { TodoList } from './examples/TodoList';\nimport { FetchData } from './examples/FetchData';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>React Hooks Playground</h1>\n <section>\n <h2>useState + useCallback</h2>\n <Counter />\n </section>\n <section>\n <h2>useState + useRef</h2>\n <TodoList />\n </section>\n <section>\n <h2>useEffect + fetch</h2>\n <FetchData />\n </section>\n </div>\n );\n}\n`,\n '/examples/Counter.tsx': `import React, { useState, useCallback } from 'react';\n\nexport const Counter = () => {\n const [count, setCount] = useState(0);\n\n const increment = useCallback(() => setCount(c => c + 1), []);\n const decrement = useCallback(() => setCount(c => c - 1), []);\n const reset = useCallback(() => setCount(0), []);\n\n return (\n <div className=\"card\">\n <p className=\"count\">{count}</p>\n <div className=\"row center\">\n <button onClick={decrement}>-</button>\n <button onClick={reset}>Reset</button>\n <button onClick={increment}>+</button>\n </div>\n </div>\n );\n};\n`,\n '/examples/TodoList.tsx': `import React, { useState, useRef } from 'react';\n\ninterface Todo {\n id: number;\n text: string;\n done: boolean;\n}\n\nexport const TodoList = () => {\n const [todos, setTodos] = useState<Todo[]>([]);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const addTodo = () => {\n const text = inputRef.current?.value.trim();\n if (!text) return;\n setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);\n inputRef.current!.value = '';\n inputRef.current!.focus();\n };\n\n const toggle = (id: number) =>\n setTodos(prev =>\n prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))\n );\n\n return (\n <div className=\"card\">\n <div className=\"row\" style={{ marginBottom: 12 }}>\n <input\n ref={inputRef}\n placeholder=\"Add todo...\"\n onKeyDown={e => e.key === 'Enter' && addTodo()}\n style={{\n flex: 1, padding: '8px 12px', border: '1px solid #e2e8f0',\n borderRadius: 6, outline: 'none', fontSize: 14,\n }}\n />\n <button onClick={addTodo}>Add</button>\n </div>\n {todos.map(t => (\n <div\n key={t.id}\n onClick={() => toggle(t.id)}\n style={{\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n cursor: 'pointer', textDecoration: t.done ? 'line-through' : 'none',\n color: t.done ? '#94a3b8' : 'inherit',\n }}\n >\n {t.text}\n </div>\n ))}\n {todos.length === 0 && (\n <p style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 16 }}>\n No todos yet. Add one above!\n </p>\n )}\n </div>\n );\n};\n`,\n '/examples/FetchData.tsx': `import React, { useState, useEffect } from 'react';\n\ninterface User {\n id: number;\n name: string;\n email: string;\n}\n\nexport const FetchData = () => {\n const [users, setUsers] = useState<User[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n fetch('https://jsonplaceholder.typicode.com/users')\n .then(res => {\n if (!res.ok) throw new Error('Failed to fetch');\n return res.json();\n })\n .then(data => {\n setUsers(data.slice(0, 5));\n setLoading(false);\n })\n .catch(err => {\n setError(err.message);\n setLoading(false);\n });\n }, []);\n\n if (loading) return <div className=\"card\">Loading...</div>;\n if (error) return <div className=\"card\" style={{ color: '#ef4444' }}>Error: {error}</div>;\n\n return (\n <div className=\"card\">\n {users.map(u => (\n <div key={u.id} style={{\n display: 'flex', justifyContent: 'space-between', alignItems: 'center',\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n }}>\n <strong style={{ fontSize: 14 }}>{u.name}</strong>\n <span style={{ fontSize: 13, color: '#64748b' }}>{u.email}</span>\n </div>\n ))}\n </div>\n );\n};\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 680px; margin: 20px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\nh2 { font-size: 12px; color: #64748b; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }\nsection { margin-bottom: 24px; }\n.card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }\n.count { font-size: 48px; font-weight: 700; text-align: center; color: #3b82f6; }\n.row { display: flex; gap: 8px; align-items: center; }\n.center { justify-content: center; margin-top: 12px; }\nbutton { padding: 8px 16px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 14px; transition: all 0.15s; }\nbutton:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'tailwind',\n label: 'React + Tailwind',\n description: 'React with Tailwind CSS via CDN',\n icon: '\\uD83C\\uDFA8',\n template: 'react',\n files: {\n '/public/index.html': `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>React + Tailwind</title>\n <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body>\n <div id=\"root\"></div>\n</body>\n</html>`,\n '/App.js': `import React, { useState } from 'react';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4\">\n <div className=\"bg-white rounded-2xl shadow-xl p-8 w-full max-w-md text-center\">\n <h1 className=\"text-2xl font-bold text-slate-800 mb-2\">\n React + Tailwind\n </h1>\n <p className=\"text-slate-500 mb-6\">Edit App.js to get started</p>\n\n <div className=\"text-6xl font-bold text-blue-500 mb-6\">{count}</div>\n\n <div className=\"flex gap-3 justify-center\">\n <button\n onClick={() => setCount(c => c - 1)}\n className=\"w-12 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-lg font-medium transition-colors\"\n >\n -\n </button>\n <button\n onClick={() => setCount(0)}\n className=\"px-6 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-sm font-medium transition-colors\"\n >\n Reset\n </button>\n <button\n onClick={() => setCount(c => c + 1)}\n className=\"w-12 h-12 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-lg font-medium transition-colors\"\n >\n +\n </button>\n </div>\n </div>\n </div>\n );\n}\n`,\n '/index.js': `import React, { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport App from \"./App\";\n\nconst root = createRoot(document.getElementById(\"root\"));\nroot.render(\n <StrictMode>\n <App />\n </StrictMode>\n);\n`,\n },\n },\n];\n"
277
+ "content": "export interface SandboxTemplate {\r\n id: string;\r\n label: string;\r\n description: string;\r\n icon: string;\r\n template: 'react' | 'react-ts';\r\n files: Record<string, string>;\r\n dependencies?: Record<string, string>;\r\n}\r\n\r\nexport const SANDBOX_TEMPLATES: SandboxTemplate[] = [\r\n {\r\n id: 'react',\r\n label: 'React',\r\n description: 'React with JavaScript',\r\n icon: '\\u269B\\uFE0F',\r\n template: 'react',\r\n files: {\r\n '/App.js': `import React, { useState } from 'react';\r\nimport './styles.css';\r\n\r\nexport default function App() {\r\n const [count, setCount] = useState(0);\r\n\r\n return (\r\n <div className=\"app\">\r\n <h1>React Playground</h1>\r\n <p className=\"count\">{count}</p>\r\n <div className=\"actions\">\r\n <button onClick={() => setCount(c => c - 1)}>-</button>\r\n <button onClick={() => setCount(0)}>Reset</button>\r\n <button onClick={() => setCount(c => c + 1)}>+</button>\r\n </div>\r\n </div>\r\n );\r\n}\r\n`,\r\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\r\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\r\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\r\nh1 { font-size: 24px; margin-bottom: 16px; }\r\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\r\n.actions { display: flex; gap: 8px; justify-content: center; }\r\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\r\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\r\n`,\r\n },\r\n },\r\n {\r\n id: 'react-ts',\r\n label: 'React + TypeScript',\r\n description: 'React with TypeScript strict mode',\r\n icon: '\\uD83D\\uDC8E',\r\n template: 'react-ts',\r\n files: {\r\n '/App.tsx': `import React, { useState } from 'react';\r\nimport './styles.css';\r\n\r\ninterface AppProps {\r\n title?: string;\r\n}\r\n\r\nconst App: React.FC<AppProps> = ({ title = 'React + TypeScript' }) => {\r\n const [count, setCount] = useState<number>(0);\r\n\r\n return (\r\n <div className=\"app\">\r\n <h1>{title}</h1>\r\n <p className=\"count\">{count}</p>\r\n <div className=\"actions\">\r\n <button onClick={() => setCount(c => c - 1)}>-</button>\r\n <button onClick={() => setCount(0)}>Reset</button>\r\n <button onClick={() => setCount(c => c + 1)}>+</button>\r\n </div>\r\n </div>\r\n );\r\n};\r\n\r\nexport default App;\r\n`,\r\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\r\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\r\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\r\nh1 { font-size: 24px; margin-bottom: 16px; }\r\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\r\n.actions { display: flex; gap: 8px; justify-content: center; }\r\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\r\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\r\n`,\r\n },\r\n },\r\n {\r\n id: 'component',\r\n label: 'Component Workshop',\r\n description: 'Build & test React components',\r\n icon: '\\uD83E\\uDDE9',\r\n template: 'react-ts',\r\n files: {\r\n '/App.tsx': `import React from 'react';\r\nimport { Button } from './components/Button';\r\nimport { Card } from './components/Card';\r\nimport './styles.css';\r\n\r\nexport default function App() {\r\n return (\r\n <div className=\"app\">\r\n <h1>Component Workshop</h1>\r\n <div className=\"grid\">\r\n <Card title=\"Button Variants\">\r\n <div className=\"row\">\r\n <Button variant=\"primary\">Primary</Button>\r\n <Button variant=\"secondary\">Secondary</Button>\r\n <Button variant=\"outline\">Outline</Button>\r\n </div>\r\n </Card>\r\n <Card title=\"Button Sizes\">\r\n <div className=\"row\">\r\n <Button size=\"sm\">Small</Button>\r\n <Button size=\"md\">Medium</Button>\r\n <Button size=\"lg\">Large</Button>\r\n </div>\r\n </Card>\r\n <Card title=\"Your Component\">\r\n <p style={{ color: '#64748b' }}>Create your own component in /components!</p>\r\n </Card>\r\n </div>\r\n </div>\r\n );\r\n}\r\n`,\r\n '/components/Button.tsx': `import React from 'react';\r\n\r\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n variant?: 'primary' | 'secondary' | 'outline';\r\n size?: 'sm' | 'md' | 'lg';\r\n}\r\n\r\nconst styles: Record<string, React.CSSProperties> = {\r\n primary: { background: '#3b82f6', color: 'white', border: 'none' },\r\n secondary: { background: '#e2e8f0', color: '#334155', border: 'none' },\r\n outline: { background: 'transparent', color: '#3b82f6', border: '1.5px solid #3b82f6' },\r\n sm: { padding: '6px 12px', fontSize: 12 },\r\n md: { padding: '8px 16px', fontSize: 14 },\r\n lg: { padding: '12px 24px', fontSize: 16 },\r\n};\r\n\r\nexport const Button: React.FC<ButtonProps> = ({\r\n children,\r\n variant = 'primary',\r\n size = 'md',\r\n style,\r\n ...props\r\n}) => (\r\n <button\r\n style={{\r\n borderRadius: 8,\r\n fontWeight: 500,\r\n cursor: 'pointer',\r\n transition: 'opacity 0.15s',\r\n ...styles[variant],\r\n ...styles[size],\r\n ...style,\r\n }}\r\n {...props}\r\n >\r\n {children}\r\n </button>\r\n);\r\n`,\r\n '/components/Card.tsx': `import React from 'react';\r\n\r\ninterface CardProps {\r\n title: string;\r\n children: React.ReactNode;\r\n}\r\n\r\nexport const Card: React.FC<CardProps> = ({ title, children }) => (\r\n <div style={{\r\n background: 'white',\r\n border: '1px solid #e2e8f0',\r\n borderRadius: 12,\r\n padding: 20,\r\n }}>\r\n <h3 style={{ fontSize: 16, marginBottom: 12, color: '#334155' }}>{title}</h3>\r\n {children}\r\n </div>\r\n);\r\n`,\r\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\r\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\r\n.app { max-width: 800px; margin: 24px auto; padding: 20px; }\r\nh1 { font-size: 22px; margin-bottom: 20px; }\r\n.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\r\n@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }\r\n.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\r\n`,\r\n },\r\n },\r\n {\r\n id: 'hooks',\r\n label: 'Hooks Playground',\r\n description: 'Practice React Hooks patterns',\r\n icon: '\\uD83E\\uDE9D',\r\n template: 'react-ts',\r\n files: {\r\n '/App.tsx': `import React from 'react';\r\nimport { Counter } from './examples/Counter';\r\nimport { TodoList } from './examples/TodoList';\r\nimport { FetchData } from './examples/FetchData';\r\nimport './styles.css';\r\n\r\nexport default function App() {\r\n return (\r\n <div className=\"app\">\r\n <h1>React Hooks Playground</h1>\r\n <section>\r\n <h2>useState + useCallback</h2>\r\n <Counter />\r\n </section>\r\n <section>\r\n <h2>useState + useRef</h2>\r\n <TodoList />\r\n </section>\r\n <section>\r\n <h2>useEffect + fetch</h2>\r\n <FetchData />\r\n </section>\r\n </div>\r\n );\r\n}\r\n`,\r\n '/examples/Counter.tsx': `import React, { useState, useCallback } from 'react';\r\n\r\nexport const Counter = () => {\r\n const [count, setCount] = useState(0);\r\n\r\n const increment = useCallback(() => setCount(c => c + 1), []);\r\n const decrement = useCallback(() => setCount(c => c - 1), []);\r\n const reset = useCallback(() => setCount(0), []);\r\n\r\n return (\r\n <div className=\"card\">\r\n <p className=\"count\">{count}</p>\r\n <div className=\"row center\">\r\n <button onClick={decrement}>-</button>\r\n <button onClick={reset}>Reset</button>\r\n <button onClick={increment}>+</button>\r\n </div>\r\n </div>\r\n );\r\n};\r\n`,\r\n '/examples/TodoList.tsx': `import React, { useState, useRef } from 'react';\r\n\r\ninterface Todo {\r\n id: number;\r\n text: string;\r\n done: boolean;\r\n}\r\n\r\nexport const TodoList = () => {\r\n const [todos, setTodos] = useState<Todo[]>([]);\r\n const inputRef = useRef<HTMLInputElement>(null);\r\n\r\n const addTodo = () => {\r\n const text = inputRef.current?.value.trim();\r\n if (!text) return;\r\n setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);\r\n inputRef.current!.value = '';\r\n inputRef.current!.focus();\r\n };\r\n\r\n const toggle = (id: number) =>\r\n setTodos(prev =>\r\n prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))\r\n );\r\n\r\n return (\r\n <div className=\"card\">\r\n <div className=\"row\" style={{ marginBottom: 12 }}>\r\n <input\r\n ref={inputRef}\r\n placeholder=\"Add todo...\"\r\n onKeyDown={e => e.key === 'Enter' && addTodo()}\r\n style={{\r\n flex: 1, padding: '8px 12px', border: '1px solid #e2e8f0',\r\n borderRadius: 6, outline: 'none', fontSize: 14,\r\n }}\r\n />\r\n <button onClick={addTodo}>Add</button>\r\n </div>\r\n {todos.map(t => (\r\n <div\r\n key={t.id}\r\n onClick={() => toggle(t.id)}\r\n style={{\r\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\r\n cursor: 'pointer', textDecoration: t.done ? 'line-through' : 'none',\r\n color: t.done ? '#94a3b8' : 'inherit',\r\n }}\r\n >\r\n {t.text}\r\n </div>\r\n ))}\r\n {todos.length === 0 && (\r\n <p style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 16 }}>\r\n No todos yet. Add one above!\r\n </p>\r\n )}\r\n </div>\r\n );\r\n};\r\n`,\r\n '/examples/FetchData.tsx': `import React, { useState, useEffect } from 'react';\r\n\r\ninterface User {\r\n id: number;\r\n name: string;\r\n email: string;\r\n}\r\n\r\nexport const FetchData = () => {\r\n const [users, setUsers] = useState<User[]>([]);\r\n const [loading, setLoading] = useState(true);\r\n const [error, setError] = useState<string | null>(null);\r\n\r\n useEffect(() => {\r\n fetch('https://jsonplaceholder.typicode.com/users')\r\n .then(res => {\r\n if (!res.ok) throw new Error('Failed to fetch');\r\n return res.json();\r\n })\r\n .then(data => {\r\n setUsers(data.slice(0, 5));\r\n setLoading(false);\r\n })\r\n .catch(err => {\r\n setError(err.message);\r\n setLoading(false);\r\n });\r\n }, []);\r\n\r\n if (loading) return <div className=\"card\">Loading...</div>;\r\n if (error) return <div className=\"card\" style={{ color: '#ef4444' }}>Error: {error}</div>;\r\n\r\n return (\r\n <div className=\"card\">\r\n {users.map(u => (\r\n <div key={u.id} style={{\r\n display: 'flex', justifyContent: 'space-between', alignItems: 'center',\r\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\r\n }}>\r\n <strong style={{ fontSize: 14 }}>{u.name}</strong>\r\n <span style={{ fontSize: 13, color: '#64748b' }}>{u.email}</span>\r\n </div>\r\n ))}\r\n </div>\r\n );\r\n};\r\n`,\r\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\r\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\r\n.app { max-width: 680px; margin: 20px auto; padding: 20px; }\r\nh1 { font-size: 22px; margin-bottom: 20px; }\r\nh2 { font-size: 12px; color: #64748b; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }\r\nsection { margin-bottom: 24px; }\r\n.card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }\r\n.count { font-size: 48px; font-weight: 700; text-align: center; color: #3b82f6; }\r\n.row { display: flex; gap: 8px; align-items: center; }\r\n.center { justify-content: center; margin-top: 12px; }\r\nbutton { padding: 8px 16px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 14px; transition: all 0.15s; }\r\nbutton:hover { background: #f1f5f9; border-color: #3b82f6; }\r\n`,\r\n },\r\n },\r\n {\r\n id: 'tailwind',\r\n label: 'React + Tailwind',\r\n description: 'React with Tailwind CSS via CDN',\r\n icon: '\\uD83C\\uDFA8',\r\n template: 'react',\r\n files: {\r\n '/public/index.html': `<!DOCTYPE html>\r\n<html lang=\"en\">\r\n<head>\r\n <meta charset=\"UTF-8\" />\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\r\n <title>React + Tailwind</title>\r\n <script src=\"https://cdn.tailwindcss.com\"></script>\r\n</head>\r\n<body>\r\n <div id=\"root\"></div>\r\n</body>\r\n</html>`,\r\n '/App.js': `import React, { useState } from 'react';\r\n\r\nexport default function App() {\r\n const [count, setCount] = useState(0);\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4\">\r\n <div className=\"bg-white rounded-2xl shadow-xl p-8 w-full max-w-md text-center\">\r\n <h1 className=\"text-2xl font-bold text-slate-800 mb-2\">\r\n React + Tailwind\r\n </h1>\r\n <p className=\"text-slate-500 mb-6\">Edit App.js to get started</p>\r\n\r\n <div className=\"text-6xl font-bold text-blue-500 mb-6\">{count}</div>\r\n\r\n <div className=\"flex gap-3 justify-center\">\r\n <button\r\n onClick={() => setCount(c => c - 1)}\r\n className=\"w-12 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-lg font-medium transition-colors\"\r\n >\r\n -\r\n </button>\r\n <button\r\n onClick={() => setCount(0)}\r\n className=\"px-6 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-sm font-medium transition-colors\"\r\n >\r\n Reset\r\n </button>\r\n <button\r\n onClick={() => setCount(c => c + 1)}\r\n className=\"w-12 h-12 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-lg font-medium transition-colors\"\r\n >\r\n +\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n );\r\n}\r\n`,\r\n '/index.js': `import React, { StrictMode } from \"react\";\r\nimport { createRoot } from \"react-dom/client\";\r\nimport App from \"./App\";\r\n\r\nconst root = createRoot(document.getElementById(\"root\"));\r\nroot.render(\r\n <StrictMode>\r\n <App />\r\n </StrictMode>\r\n);\r\n`,\r\n },\r\n },\r\n];\r\n"
278
278
  },
279
279
  {
280
280
  "path": "src/components/ui/code-sandbox/TerminalPane.tsx",
281
- "content": "import React, { useState } from 'react';\nimport { SandpackConsole, useSandpack } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\n\ntype TerminalTab = 'console' | 'problems';\n\nexport function TerminalPane() {\n const { sandpack } = useSandpack();\n const [tab, setTab] = useState<TerminalTab>('console');\n\n return (\n <div className=\"flex flex-col h-full bg-background\">\n <div className=\"h-7 shrink-0 bg-muted/30 border-t border-b border-border flex items-center px-3 gap-3 text-[10px] font-semibold tracking-wider\">\n {(['console', 'problems'] as TerminalTab[]).map((t) => (\n <button\n key={t}\n onClick={() => setTab(t)}\n className={cn(\n 'uppercase transition-colors pb-0.5 -mb-px border-b',\n tab === t\n ? 'text-primary border-primary'\n : 'text-muted-foreground border-transparent hover:text-foreground',\n )}\n >\n {t}\n {t === 'problems' && sandpack.error && (\n <span className=\"ml-1 text-danger font-bold\">1</span>\n )}\n </button>\n ))}\n </div>\n\n <div className=\"flex-1 overflow-auto min-h-0\">\n {tab === 'console' && (\n <SandpackConsole standalone style={{ height: '100%' }} />\n )}\n {tab === 'problems' && (\n <div className=\"p-3 text-xs\">\n {sandpack.error ? (\n <div className=\"text-danger font-mono whitespace-pre-wrap break-words\">\n {sandpack.error.message}\n </div>\n ) : (\n <div className=\"text-muted-foreground text-center py-4\">\n No problems detected\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n"
281
+ "content": "import React, { useState } from 'react';\r\nimport { SandpackConsole, useSandpack } from '@codesandbox/sandpack-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\ntype TerminalTab = 'console' | 'problems';\r\n\r\nexport function TerminalPane() {\r\n const { sandpack } = useSandpack();\r\n const [tab, setTab] = useState<TerminalTab>('console');\r\n\r\n return (\r\n <div className=\"flex flex-col h-full bg-background\">\r\n <div className=\"h-7 shrink-0 bg-muted/30 border-t border-b border-border flex items-center px-3 gap-3 text-[10px] font-semibold tracking-wider\">\r\n {(['console', 'problems'] as TerminalTab[]).map((t) => (\r\n <button\r\n key={t}\r\n onClick={() => setTab(t)}\r\n className={cn(\r\n 'uppercase transition-colors pb-0.5 -mb-px border-b',\r\n tab === t\r\n ? 'text-primary border-primary'\r\n : 'text-muted-foreground border-transparent hover:text-foreground',\r\n )}\r\n >\r\n {t}\r\n {t === 'problems' && sandpack.error && (\r\n <span className=\"ml-1 text-danger font-bold\">1</span>\r\n )}\r\n </button>\r\n ))}\r\n </div>\r\n\r\n <div className=\"flex-1 overflow-auto min-h-0\">\r\n {tab === 'console' && (\r\n <SandpackConsole standalone style={{ height: '100%' }} />\r\n )}\r\n {tab === 'problems' && (\r\n <div className=\"p-3 text-xs\">\r\n {sandpack.error ? (\r\n <div className=\"text-danger font-mono whitespace-pre-wrap break-words\">\r\n {sandpack.error.message}\r\n </div>\r\n ) : (\r\n <div className=\"text-muted-foreground text-center py-4\">\r\n No problems detected\r\n </div>\r\n )}\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
282
282
  }
283
283
  ]
284
284
  },
@@ -323,7 +323,7 @@
323
323
  "files": [
324
324
  {
325
325
  "path": "src/components/ui/command/Command.tsx",
326
- "content": "import * as React from 'react';\nimport { Dialog as BaseDialog } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Search } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst commandVariants = tv({\n slots: {\n overlay:\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',\n content: [\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'w-full max-w-lg rounded-xl border border-border bg-background shadow-2xl',\n 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]',\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n 'overflow-hidden flex flex-col max-h-[min(80vh,460px)]',\n ].join(' '),\n input: [\n 'flex h-12 w-full bg-transparent px-4 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n ].join(' '),\n list: 'overflow-y-auto overflow-x-hidden flex-1',\n group: 'p-1',\n groupLabel: 'px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n item: [\n 'relative flex cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-none',\n 'transition-colors',\n 'data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground',\n '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',\n ].join(' '),\n separator: '-mx-1 my-1 h-px bg-border',\n empty: 'py-8 text-center text-sm text-muted-foreground',\n shortcut: 'ml-auto text-xs tracking-widest text-muted-foreground/70',\n },\n});\n\nconst styles = commandVariants();\n\n// ─── Context ─────────────────────────────────────────────────────────────────\n\ninterface CommandContextValue {\n search: string;\n setSearch: React.Dispatch<React.SetStateAction<string>>;\n highlightedIndex: number;\n setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>;\n}\n\nconst CommandContext = React.createContext<CommandContextValue | null>(null);\n\nfunction useCommand() {\n const ctx = React.useContext(CommandContext);\n if (!ctx) throw new Error('useCommand must be used within <Command>');\n return ctx;\n}\n\n// ─── Command (Root) ──────────────────────────────────────────────────────────\n\nexport interface CommandProps {\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n children: React.ReactNode;\n className?: string;\n}\n\nconst Command: React.FC<CommandProps> = ({ open, onOpenChange, children, className }) => {\n const [search, setSearch] = React.useState('');\n const [highlightedIndex, setHighlightedIndex] = React.useState(0);\n\n React.useEffect(() => {\n if (open) {\n setSearch('');\n setHighlightedIndex(0);\n }\n }, [open]);\n\n return (\n <CommandContext.Provider value={{ search, setSearch, highlightedIndex, setHighlightedIndex }}>\n <BaseDialog.Root open={open} onOpenChange={onOpenChange}>\n <BaseDialog.Portal>\n <BaseDialog.Backdrop className={styles.overlay()} />\n <BaseDialog.Popup className={cn(styles.content(), className)}>\n {children}\n </BaseDialog.Popup>\n </BaseDialog.Portal>\n </BaseDialog.Root>\n </CommandContext.Provider>\n );\n};\nCommand.displayName = 'Command';\n\n// ─── CommandInput ────────────────────────────────────────────────────────────\n\nexport interface CommandInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {\n onValueChange?: (value: string) => void;\n}\n\nconst CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(\n ({ className, placeholder = 'Type a command or search...', onValueChange, ...props }, ref) => {\n const { search, setSearch, setHighlightedIndex } = useCommand();\n\n return (\n <div className=\"flex items-center border-b border-border px-3 shrink-0\">\n <Search className=\"mr-2 h-4 w-4 shrink-0 text-muted-foreground\" />\n <input\n ref={ref}\n value={search}\n onChange={(e) => {\n setSearch(e.target.value);\n setHighlightedIndex(0);\n onValueChange?.(e.target.value);\n }}\n placeholder={placeholder}\n className={cn(styles.input(), className)}\n {...props}\n />\n </div>\n );\n },\n);\nCommandInput.displayName = 'CommandInput';\n\n// ─── CommandList ─────────────────────────────────────────────────────────────\n\nconst CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, children, ...props }, ref) => (\n <div ref={ref} className={cn(styles.list(), className)} role=\"listbox\" {...props}>\n {children}\n </div>\n ),\n);\nCommandList.displayName = 'CommandList';\n\n// ─── CommandGroup ────────────────────────────────────────────────────────────\n\nexport interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n heading?: string;\n}\n\nconst CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(\n ({ className, heading, children, ...props }, ref) => (\n <div ref={ref} className={cn(styles.group(), className)} role=\"group\" {...props}>\n {heading && <div className={styles.groupLabel()}>{heading}</div>}\n {children}\n </div>\n ),\n);\nCommandGroup.displayName = 'CommandGroup';\n\n// ─── CommandItem ─────────────────────────────────────────────────────────────\n\nexport interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {\n disabled?: boolean;\n keywords?: string[];\n onSelect?: () => void;\n value?: string;\n}\n\nconst CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(\n ({ className, disabled, keywords = [], onSelect, value, children, ...props }, ref) => {\n const { search, highlightedIndex, setHighlightedIndex } = useCommand();\n const [itemIndex] = React.useState(() => Math.random());\n\n // Filter: check value, text content, and keywords\n const searchable = [value ?? '', ...(typeof children === 'string' ? [children] : []), ...keywords]\n .join(' ')\n .toLowerCase();\n const isVisible = !search || searchable.includes(search.toLowerCase());\n\n if (!isVisible) return null;\n\n return (\n <div\n ref={ref}\n role=\"option\"\n aria-disabled={disabled || undefined}\n className={cn(\n styles.item(),\n disabled && 'opacity-50 pointer-events-none',\n className,\n )}\n onClick={() => {\n if (!disabled) onSelect?.();\n }}\n onKeyDown={(e) => {\n if ((e.key === 'Enter' || e.key === ' ') && !disabled) {\n e.preventDefault();\n onSelect?.();\n }\n }}\n {...props}\n >\n {children}\n </div>\n );\n },\n);\nCommandItem.displayName = 'CommandItem';\n\n// ─── CommandEmpty ────────────────────────────────────────────────────────────\n\nconst CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, children = 'No results found.', ...props }, ref) => (\n <div ref={ref} className={cn(styles.empty(), className)} {...props}>\n {children}\n </div>\n ),\n);\nCommandEmpty.displayName = 'CommandEmpty';\n\n// ─── CommandSeparator ────────────────────────────────────────────────────────\n\nconst CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={cn(styles.separator(), className)} {...props} />\n ),\n);\nCommandSeparator.displayName = 'CommandSeparator';\n\n// ─── CommandShortcut ─────────────────────────────────────────────────────────\n\nconst CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={cn(styles.shortcut(), className)} {...props} />\n);\nCommandShortcut.displayName = 'CommandShortcut';\n\n// ─── Exports ─────────────────────────────────────────────────────────────────\n\nexport {\n Command,\n CommandInput,\n CommandList,\n CommandGroup,\n CommandItem,\n CommandEmpty,\n CommandSeparator,\n CommandShortcut,\n commandVariants,\n};\n"
326
+ "content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Search } from 'lucide-react';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst commandVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',\r\n content: [\r\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\r\n 'w-full max-w-lg rounded-xl border border-border bg-background shadow-2xl',\r\n 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 ',\r\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\r\n 'overflow-hidden flex flex-col max-h-[min(80vh,460px)]',\r\n ].join(' '),\r\n input: [\r\n 'flex h-12 w-full bg-transparent px-4 text-sm text-foreground outline-none',\r\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\r\n ].join(' '),\r\n list: 'overflow-y-auto overflow-x-hidden flex-1',\r\n group: 'p-1',\r\n groupLabel: 'px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\r\n item: [\r\n 'relative flex cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-none',\r\n 'transition-colors',\r\n 'data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground',\r\n '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',\r\n ].join(' '),\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n empty: 'py-8 text-center text-sm text-muted-foreground',\r\n shortcut: 'ml-auto text-xs tracking-widest text-muted-foreground/70',\r\n },\r\n});\r\n\r\nconst styles = commandVariants();\r\n\r\n// ─── Context ─────────────────────────────────────────────────────────────────\r\n\r\ninterface CommandContextValue {\r\n search: string;\r\n setSearch: React.Dispatch<React.SetStateAction<string>>;\r\n highlightedIndex: number;\r\n setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>;\r\n}\r\n\r\nconst CommandContext = React.createContext<CommandContextValue | null>(null);\r\n\r\nfunction useCommand() {\r\n const ctx = React.useContext(CommandContext);\r\n if (!ctx) throw new Error('useCommand must be used within <Command>');\r\n return ctx;\r\n}\r\n\r\n// ─── Command (Root) ──────────────────────────────────────────────────────────\r\n\r\nexport interface CommandProps {\r\n open?: boolean;\r\n onOpenChange?: (open: boolean) => void;\r\n children: React.ReactNode;\r\n className?: string;\r\n}\r\n\r\nconst Command: React.FC<CommandProps> = ({ open, onOpenChange, children, className }) => {\r\n const [search, setSearch] = React.useState('');\r\n const [highlightedIndex, setHighlightedIndex] = React.useState(0);\r\n\r\n React.useEffect(() => {\r\n if (open) {\r\n setSearch('');\r\n setHighlightedIndex(0);\r\n }\r\n }, [open]);\r\n\r\n return (\r\n <CommandContext.Provider value={{ search, setSearch, highlightedIndex, setHighlightedIndex }}>\r\n <BaseDialog.Root open={open} onOpenChange={onOpenChange}>\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={styles.overlay()} />\r\n <BaseDialog.Popup className={cn(styles.content(), className)}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n </BaseDialog.Root>\r\n </CommandContext.Provider>\r\n );\r\n};\r\nCommand.displayName = 'Command';\r\n\r\n// ─── CommandInput ────────────────────────────────────────────────────────────\r\n\r\nexport interface CommandInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {\r\n onValueChange?: (value: string) => void;\r\n}\r\n\r\nconst CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(\r\n ({ className, placeholder = 'Type a command or search...', onValueChange, ...props }, ref) => {\r\n const { search, setSearch, setHighlightedIndex } = useCommand();\r\n\r\n return (\r\n <div className=\"flex items-center border-b border-border px-3 shrink-0\">\r\n <Search className=\"mr-2 h-4 w-4 shrink-0 text-muted-foreground\" />\r\n <input\r\n ref={ref}\r\n value={search}\r\n onChange={(e) => {\r\n setSearch(e.target.value);\r\n setHighlightedIndex(0);\r\n onValueChange?.(e.target.value);\r\n }}\r\n placeholder={placeholder}\r\n className={cn(styles.input(), className)}\r\n {...props}\r\n />\r\n </div>\r\n );\r\n },\r\n);\r\nCommandInput.displayName = 'CommandInput';\r\n\r\n// ─── CommandList ─────────────────────────────────────────────────────────────\r\n\r\nconst CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, children, ...props }, ref) => (\r\n <div ref={ref} className={cn(styles.list(), className)} role=\"listbox\" {...props}>\r\n {children}\r\n </div>\r\n ),\r\n);\r\nCommandList.displayName = 'CommandList';\r\n\r\n// ─── CommandGroup ────────────────────────────────────────────────────────────\r\n\r\nexport interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {\r\n heading?: string;\r\n}\r\n\r\nconst CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(\r\n ({ className, heading, children, ...props }, ref) => (\r\n <div ref={ref} className={cn(styles.group(), className)} role=\"group\" {...props}>\r\n {heading && <div className={styles.groupLabel()}>{heading}</div>}\r\n {children}\r\n </div>\r\n ),\r\n);\r\nCommandGroup.displayName = 'CommandGroup';\r\n\r\n// ─── CommandItem ─────────────────────────────────────────────────────────────\r\n\r\nexport interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n disabled?: boolean;\r\n keywords?: string[];\r\n onSelect?: () => void;\r\n value?: string;\r\n}\r\n\r\nconst CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(\r\n ({ className, disabled, keywords = [], onSelect, value, children, ...props }, ref) => {\r\n const { search, highlightedIndex, setHighlightedIndex } = useCommand();\r\n const [itemIndex] = React.useState(() => Math.random());\r\n\r\n // Filter: check value, text content, and keywords\r\n const searchable = [value ?? '', ...(typeof children === 'string' ? [children] : []), ...keywords]\r\n .join(' ')\r\n .toLowerCase();\r\n const isVisible = !search || searchable.includes(search.toLowerCase());\r\n\r\n if (!isVisible) return null;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"option\"\r\n aria-disabled={disabled || undefined}\r\n className={cn(\r\n styles.item(),\r\n disabled && 'opacity-50 pointer-events-none',\r\n className,\r\n )}\r\n onClick={() => {\r\n if (!disabled) onSelect?.();\r\n }}\r\n onKeyDown={(e) => {\r\n if ((e.key === 'Enter' || e.key === ' ') && !disabled) {\r\n e.preventDefault();\r\n onSelect?.();\r\n }\r\n }}\r\n {...props}\r\n >\r\n {children}\r\n </div>\r\n );\r\n },\r\n);\r\nCommandItem.displayName = 'CommandItem';\r\n\r\n// ─── CommandEmpty ────────────────────────────────────────────────────────────\r\n\r\nconst CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, children = 'No results found.', ...props }, ref) => (\r\n <div ref={ref} className={cn(styles.empty(), className)} {...props}>\r\n {children}\r\n </div>\r\n ),\r\n);\r\nCommandEmpty.displayName = 'CommandEmpty';\r\n\r\n// ─── CommandSeparator ────────────────────────────────────────────────────────\r\n\r\nconst CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={cn(styles.separator(), className)} {...props} />\r\n ),\r\n);\r\nCommandSeparator.displayName = 'CommandSeparator';\r\n\r\n// ─── CommandShortcut ─────────────────────────────────────────────────────────\r\n\r\nconst CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={cn(styles.shortcut(), className)} {...props} />\r\n);\r\nCommandShortcut.displayName = 'CommandShortcut';\r\n\r\n// ─── Exports ─────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Command,\r\n CommandInput,\r\n CommandList,\r\n CommandGroup,\r\n CommandItem,\r\n CommandEmpty,\r\n CommandSeparator,\r\n CommandShortcut,\r\n};\r\n"
327
327
  }
328
328
  ]
329
329
  },
@@ -337,7 +337,7 @@
337
337
  "files": [
338
338
  {
339
339
  "path": "src/components/ui/context-menu/ContextMenu.tsx",
340
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport * as ReactDOM from 'react-dom';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, Circle } from 'lucide-react';\r\n\r\nconst contextMenuVariants = tv({\r\n slots: {\r\n content:\r\n 'fixed z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95',\r\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n checkboxItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\r\n radioItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\r\n label: 'px-2 py-1.5 text-sm font-semibold',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = contextMenuVariants();\r\n\r\n// ─── Context ─────────────────────────────────────────────────────────────────\r\n\r\ninterface ContextMenuState {\r\n open: boolean;\r\n position: { x: number; y: number };\r\n}\r\n\r\nconst ContextMenuContext = React.createContext<{\r\n state: ContextMenuState;\r\n close: () => void;\r\n}>({ state: { open: false, position: { x: 0, y: 0 } }, close: () => {} });\r\n\r\n// ─── Root ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuProps {\r\n children: React.ReactNode;\r\n}\r\n\r\nconst ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {\r\n const [state, setState] = React.useState<ContextMenuState>({\r\n open: false,\r\n position: { x: 0, y: 0 },\r\n });\r\n\r\n const close = React.useCallback(() => setState(s => ({ ...s, open: false })), []);\r\n\r\n // Close on outside click / second right-click / scroll\r\n React.useEffect(() => {\r\n if (!state.open) return;\r\n const handleClose = () => close();\r\n document.addEventListener('click', handleClose, { capture: true });\r\n document.addEventListener('contextmenu', handleClose, { capture: true });\r\n document.addEventListener('scroll', handleClose, { capture: true, passive: true });\r\n return () => {\r\n document.removeEventListener('click', handleClose, { capture: true });\r\n document.removeEventListener('contextmenu', handleClose, { capture: true });\r\n document.removeEventListener('scroll', handleClose, { capture: true });\r\n };\r\n }, [state.open, close]);\r\n\r\n return (\r\n <ContextMenuContext.Provider value={{ state, close }}>\r\n {React.Children.map(children, child => {\r\n if (React.isValidElement(child) && child.type === ContextMenuTrigger) {\r\n return React.cloneElement(\r\n child as React.ReactElement<{ onContextMenu?: (e: React.MouseEvent) => void }>,\r\n {\r\n onContextMenu: (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setState({ open: true, position: { x: e.clientX, y: e.clientY } });\r\n },\r\n }\r\n );\r\n }\r\n return child;\r\n })}\r\n </ContextMenuContext.Provider>\r\n );\r\n};\r\nContextMenu.displayName = 'ContextMenu';\r\n\r\n// ─── Trigger ──────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {}\r\n\r\nconst ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(\r\n ({ children, ...props }, ref) => (\r\n <div ref={ref} {...props}>{children}</div>\r\n )\r\n);\r\nContextMenuTrigger.displayName = 'ContextMenuTrigger';\r\n\r\n// ─── Content ──────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {}\r\n\r\nconst ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(\r\n ({ className, children, ...props }, ref) => {\r\n const { state, close } = React.useContext(ContextMenuContext);\r\n const contentRef = React.useRef<HTMLDivElement>(null);\r\n\r\n // Merge refs\r\n const mergedRef = React.useCallback(\r\n (node: HTMLDivElement | null) => {\r\n (contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\r\n },\r\n [ref]\r\n );\r\n\r\n // Focus first item on open\r\n React.useEffect(() => {\r\n if (state.open) {\r\n const first = contentRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]');\r\n first?.focus();\r\n }\r\n }, [state.open]);\r\n\r\n // Keyboard navigation\r\n const handleKeyDown = React.useCallback(\r\n (e: React.KeyboardEvent<HTMLDivElement>) => {\r\n const items = Array.from(\r\n contentRef.current?.querySelectorAll<HTMLElement>(\r\n '[role=\"menuitem\"]:not([disabled]),[role=\"menuitemcheckbox\"]:not([disabled]),[role=\"menuitemradio\"]:not([disabled])'\r\n ) ?? []\r\n );\r\n const current = document.activeElement as HTMLElement;\r\n const idx = items.indexOf(current);\r\n\r\n if (e.key === 'ArrowDown') {\r\n e.preventDefault();\r\n items[(idx + 1) % items.length]?.focus();\r\n } else if (e.key === 'ArrowUp') {\r\n e.preventDefault();\r\n items[(idx - 1 + items.length) % items.length]?.focus();\r\n } else if (e.key === 'Escape') {\r\n e.preventDefault();\r\n close();\r\n } else if (e.key === 'Tab') {\r\n e.preventDefault();\r\n close();\r\n } else if (e.key === 'Home') {\r\n e.preventDefault();\r\n items[0]?.focus();\r\n } else if (e.key === 'End') {\r\n e.preventDefault();\r\n items[items.length - 1]?.focus();\r\n }\r\n },\r\n [close]\r\n );\r\n\r\n if (!state.open) return null;\r\n\r\n // Clamp position to viewport\r\n const vw = typeof window !== 'undefined' ? window.innerWidth : 0;\r\n const vh = typeof window !== 'undefined' ? window.innerHeight : 0;\r\n const MENU_W = 192; // ~min-w-[8rem] generous estimate\r\n const MENU_H = 300;\r\n const x = Math.min(state.position.x, vw - MENU_W - 8);\r\n const y = Math.min(state.position.y, vh - MENU_H - 8);\r\n\r\n return ReactDOM.createPortal(\r\n <div\r\n ref={mergedRef}\r\n className={styles.content({ className })}\r\n style={{ top: y, left: x }}\r\n role=\"menu\"\r\n aria-orientation=\"vertical\"\r\n onKeyDown={handleKeyDown}\r\n {...props}\r\n >\r\n {children}\r\n </div>,\r\n document.body\r\n );\r\n }\r\n);\r\nContextMenuContent.displayName = 'ContextMenuContent';\r\n\r\n// ─── Item ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n inset?: boolean;\r\n disabled?: boolean;\r\n}\r\n\r\nconst ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(\r\n ({ className, inset, disabled, onClick, ...props }, ref) => {\r\n const { close } = React.useContext(ContextMenuContext);\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"menuitem\"\r\n tabIndex={disabled ? undefined : -1}\r\n aria-disabled={disabled || undefined}\r\n className={styles.item({\r\n className: `${inset ? 'pl-8' : ''} ${disabled ? 'opacity-50 pointer-events-none' : ''} ${className ?? ''}`,\r\n })}\r\n onClick={(e) => {\r\n if (disabled) return;\r\n onClick?.(e);\r\n close();\r\n }}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n if (!disabled) {\r\n onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);\r\n close();\r\n }\r\n }\r\n }}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nContextMenuItem.displayName = 'ContextMenuItem';\r\n\r\n// ─── CheckboxItem ─────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n checked?: boolean;\r\n onCheckedChange?: (checked: boolean) => void;\r\n}\r\n\r\nconst ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(\r\n ({ className, children, checked, onCheckedChange, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role=\"menuitemcheckbox\"\r\n tabIndex={-1}\r\n aria-checked={checked}\r\n className={styles.checkboxItem({ className })}\r\n onClick={() => onCheckedChange?.(!checked)}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n onCheckedChange?.(!checked);\r\n }\r\n }}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n {checked && <Check className=\"h-4 w-4\" />}\r\n </span>\r\n {children}\r\n </div>\r\n )\r\n);\r\nContextMenuCheckboxItem.displayName = 'ContextMenuCheckboxItem';\r\n\r\n// ─── RadioGroup + RadioItem ───────────────────────────────────────────────────\r\n\r\nconst ContextMenuRadioContext = React.createContext<{\r\n value?: string;\r\n onValueChange?: (value: string) => void;\r\n}>({});\r\n\r\nexport interface ContextMenuRadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {\r\n value?: string;\r\n onValueChange?: (value: string) => void;\r\n}\r\n\r\nconst ContextMenuRadioGroup = React.forwardRef<HTMLDivElement, ContextMenuRadioGroupProps>(\r\n ({ value, onValueChange, ...props }, ref) => (\r\n <ContextMenuRadioContext.Provider value={{ value, onValueChange }}>\r\n <div ref={ref} role=\"group\" {...props} />\r\n </ContextMenuRadioContext.Provider>\r\n )\r\n);\r\nContextMenuRadioGroup.displayName = 'ContextMenuRadioGroup';\r\n\r\nexport interface ContextMenuRadioItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n value: string;\r\n}\r\n\r\nconst ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(\r\n ({ className, children, value, ...props }, ref) => {\r\n const ctx = React.useContext(ContextMenuRadioContext);\r\n const isChecked = ctx.value === value;\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"menuitemradio\"\r\n tabIndex={-1}\r\n aria-checked={isChecked}\r\n className={styles.radioItem({ className })}\r\n onClick={() => ctx.onValueChange?.(value)}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n ctx.onValueChange?.(value);\r\n }\r\n }}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n {isChecked && <Circle className=\"h-2 w-2 fill-current\" />}\r\n </span>\r\n {children}\r\n </div>\r\n );\r\n }\r\n);\r\nContextMenuRadioItem.displayName = 'ContextMenuRadioItem';\r\n\r\n// ─── Label ────────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuLabel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className })} {...props} />\r\n )\r\n);\r\nContextMenuLabel.displayName = 'ContextMenuLabel';\r\n\r\n// ─── Separator ────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} role=\"separator\" className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nContextMenuSeparator.displayName = 'ContextMenuSeparator';\r\n\r\n// ─── Shortcut ─────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span aria-hidden=\"true\" className={styles.shortcut({ className })} {...props} />\r\n);\r\nContextMenuShortcut.displayName = 'ContextMenuShortcut';\r\n\r\nexport {\r\n ContextMenu,\r\n ContextMenuTrigger,\r\n ContextMenuContent,\r\n ContextMenuItem,\r\n ContextMenuCheckboxItem,\r\n ContextMenuRadioGroup,\r\n ContextMenuRadioItem,\r\n ContextMenuLabel,\r\n ContextMenuSeparator,\r\n ContextMenuShortcut,\r\n contextMenuVariants,\r\n};\r\n"
340
+ "content": "'use client';\n\nimport * as React from 'react';\nimport * as ReactDOM from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { Check, Circle } from 'lucide-react';\n\nconst contextMenuVariants = tv({\n slots: {\n content:\n 'fixed z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95',\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n checkboxItem:\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\n radioItem:\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\n label: 'px-2 py-1.5 text-sm font-semibold',\n separator: '-mx-1 my-1 h-px bg-border',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n },\n});\n\nconst styles = contextMenuVariants();\n\n// ─── Context ─────────────────────────────────────────────────────────────────\n\ninterface ContextMenuState {\n open: boolean;\n position: { x: number; y: number };\n}\n\nconst ContextMenuContext = React.createContext<{\n state: ContextMenuState;\n close: () => void;\n}>({ state: { open: false, position: { x: 0, y: 0 } }, close: () => {} });\n\n// ─── Root ─────────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuProps {\n children: React.ReactNode;\n}\n\nconst ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {\n const [state, setState] = React.useState<ContextMenuState>({\n open: false,\n position: { x: 0, y: 0 },\n });\n\n const close = React.useCallback(() => setState(s => ({ ...s, open: false })), []);\n\n // Close on outside click / second right-click / scroll\n React.useEffect(() => {\n if (!state.open) return;\n const handleClose = () => close();\n document.addEventListener('click', handleClose, { capture: true });\n document.addEventListener('contextmenu', handleClose, { capture: true });\n document.addEventListener('scroll', handleClose, { capture: true, passive: true });\n return () => {\n document.removeEventListener('click', handleClose, { capture: true });\n document.removeEventListener('contextmenu', handleClose, { capture: true });\n document.removeEventListener('scroll', handleClose, { capture: true });\n };\n }, [state.open, close]);\n\n return (\n <ContextMenuContext.Provider value={{ state, close }}>\n {React.Children.map(children, child => {\n if (React.isValidElement(child) && child.type === ContextMenuTrigger) {\n return React.cloneElement(\n child as React.ReactElement<{ onContextMenu?: (e: React.MouseEvent) => void }>,\n {\n onContextMenu: (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setState({ open: true, position: { x: e.clientX, y: e.clientY } });\n },\n }\n );\n }\n return child;\n })}\n </ContextMenuContext.Provider>\n );\n};\nContextMenu.displayName = 'ContextMenu';\n\n// ─── Trigger ──────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {}\n\nconst ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(\n ({ children, ...props }, ref) => (\n <div ref={ref} {...props}>{children}</div>\n )\n);\nContextMenuTrigger.displayName = 'ContextMenuTrigger';\n\n// ─── Content ──────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {}\n\nconst ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(\n ({ className, children, ...props }, ref) => {\n const { state, close } = React.useContext(ContextMenuContext);\n const contentRef = React.useRef<HTMLDivElement>(null);\n\n // Merge refs\n const mergedRef = React.useCallback(\n (node: HTMLDivElement | null) => {\n (contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n },\n [ref]\n );\n\n // Focus first item on open\n React.useEffect(() => {\n if (state.open) {\n const first = contentRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]');\n first?.focus();\n }\n }, [state.open]);\n\n // Keyboard navigation\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent<HTMLDivElement>) => {\n const items = Array.from(\n contentRef.current?.querySelectorAll<HTMLElement>(\n '[role=\"menuitem\"]:not([disabled]),[role=\"menuitemcheckbox\"]:not([disabled]),[role=\"menuitemradio\"]:not([disabled])'\n ) ?? []\n );\n const current = document.activeElement as HTMLElement;\n const idx = items.indexOf(current);\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n items[(idx + 1) % items.length]?.focus();\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n items[(idx - 1 + items.length) % items.length]?.focus();\n } else if (e.key === 'Escape') {\n e.preventDefault();\n close();\n } else if (e.key === 'Tab') {\n e.preventDefault();\n close();\n } else if (e.key === 'Home') {\n e.preventDefault();\n items[0]?.focus();\n } else if (e.key === 'End') {\n e.preventDefault();\n items[items.length - 1]?.focus();\n }\n },\n [close]\n );\n\n if (!state.open) return null;\n\n // Clamp position to viewport\n const vw = typeof window !== 'undefined' ? window.innerWidth : 0;\n const vh = typeof window !== 'undefined' ? window.innerHeight : 0;\n const MENU_W = 192; // ~min-w-[8rem] generous estimate\n const MENU_H = 300;\n const x = Math.min(state.position.x, vw - MENU_W - 8);\n const y = Math.min(state.position.y, vh - MENU_H - 8);\n\n return ReactDOM.createPortal(\n <div\n ref={mergedRef}\n className={styles.content({ className })}\n style={{ top: y, left: x }}\n role=\"menu\"\n aria-orientation=\"vertical\"\n onKeyDown={handleKeyDown}\n {...props}\n >\n {children}\n </div>,\n document.body\n );\n }\n);\nContextMenuContent.displayName = 'ContextMenuContent';\n\n// ─── Item ─────────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {\n inset?: boolean;\n disabled?: boolean;\n}\n\nconst ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(\n ({ className, inset, disabled, onClick, ...props }, ref) => {\n const { close } = React.useContext(ContextMenuContext);\n return (\n <div\n ref={ref}\n role=\"menuitem\"\n tabIndex={disabled ? undefined : -1}\n aria-disabled={disabled || undefined}\n className={styles.item({\n className: `${inset ? 'pl-8' : ''} ${disabled ? 'opacity-50 pointer-events-none' : ''} ${className ?? ''}`,\n })}\n onClick={(e) => {\n if (disabled) return;\n onClick?.(e);\n close();\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n if (!disabled) {\n onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);\n close();\n }\n }\n }}\n {...props}\n />\n );\n }\n);\nContextMenuItem.displayName = 'ContextMenuItem';\n\n// ─── CheckboxItem ─────────────────────────────────────────────────────────────\n\nexport interface ContextMenuCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement> {\n checked?: boolean;\n onCheckedChange?: (checked: boolean) => void;\n}\n\nconst ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(\n ({ className, children, checked, onCheckedChange, ...props }, ref) => (\n <div\n ref={ref}\n role=\"menuitemcheckbox\"\n tabIndex={-1}\n aria-checked={checked}\n className={styles.checkboxItem({ className })}\n onClick={() => onCheckedChange?.(!checked)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onCheckedChange?.(!checked);\n }\n }}\n {...props}\n >\n <span className={styles.indicatorWrapper()}>\n {checked && <Check className=\"h-4 w-4\" />}\n </span>\n {children}\n </div>\n )\n);\nContextMenuCheckboxItem.displayName = 'ContextMenuCheckboxItem';\n\n// ─── RadioGroup + RadioItem ───────────────────────────────────────────────────\n\nconst ContextMenuRadioContext = React.createContext<{\n value?: string;\n onValueChange?: (value: string) => void;\n}>({});\n\nexport interface ContextMenuRadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n value?: string;\n onValueChange?: (value: string) => void;\n}\n\nconst ContextMenuRadioGroup = React.forwardRef<HTMLDivElement, ContextMenuRadioGroupProps>(\n ({ value, onValueChange, ...props }, ref) => (\n <ContextMenuRadioContext.Provider value={{ value, onValueChange }}>\n <div ref={ref} role=\"group\" {...props} />\n </ContextMenuRadioContext.Provider>\n )\n);\nContextMenuRadioGroup.displayName = 'ContextMenuRadioGroup';\n\nexport interface ContextMenuRadioItemProps extends React.HTMLAttributes<HTMLDivElement> {\n value: string;\n}\n\nconst ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(\n ({ className, children, value, ...props }, ref) => {\n const ctx = React.useContext(ContextMenuRadioContext);\n const isChecked = ctx.value === value;\n return (\n <div\n ref={ref}\n role=\"menuitemradio\"\n tabIndex={-1}\n aria-checked={isChecked}\n className={styles.radioItem({ className })}\n onClick={() => ctx.onValueChange?.(value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n ctx.onValueChange?.(value);\n }\n }}\n {...props}\n >\n <span className={styles.indicatorWrapper()}>\n {isChecked && <Circle className=\"h-2 w-2 fill-current\" />}\n </span>\n {children}\n </div>\n );\n }\n);\nContextMenuRadioItem.displayName = 'ContextMenuRadioItem';\n\n// ─── Label ────────────────────────────────────────────────────────────────────\n\nconst ContextMenuLabel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nContextMenuLabel.displayName = 'ContextMenuLabel';\n\n// ─── Separator ────────────────────────────────────────────────────────────────\n\nconst ContextMenuSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} role=\"separator\" className={styles.separator({ className })} {...props} />\n )\n);\nContextMenuSeparator.displayName = 'ContextMenuSeparator';\n\n// ─── Shortcut ─────────────────────────────────────────────────────────────────\n\nconst ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span aria-hidden=\"true\" className={styles.shortcut({ className })} {...props} />\n);\nContextMenuShortcut.displayName = 'ContextMenuShortcut';\n\nexport {\n ContextMenu,\n ContextMenuTrigger,\n ContextMenuContent,\n ContextMenuItem,\n ContextMenuCheckboxItem,\n ContextMenuRadioGroup,\n ContextMenuRadioItem,\n ContextMenuLabel,\n ContextMenuSeparator,\n ContextMenuShortcut,\n contextMenuVariants,\n};\n"
341
341
  }
342
342
  ]
343
343
  },
@@ -360,11 +360,11 @@
360
360
  },
361
361
  {
362
362
  "path": "src/components/ui/datepicker/time-helpers.ts",
363
- "content": "import { format } from 'date-fns';\n\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\n\nexport interface TimeParts {\n h: string;\n m: string;\n s: string;\n}\n\nexport const DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\n\nexport function parseTimeParts(timeStr: string): TimeParts {\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\n return {\n h: h.padStart(2, '0'),\n m: m.padStart(2, '0'),\n s: s.padStart(2, '0'),\n };\n}\n\nexport function buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\n if (fmt === 'HH') return parts.h;\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\n return `${parts.h}:${parts.m}:${parts.s}`;\n}\n\nexport function applyTimeToDate(base: Date, parts: TimeParts): Date {\n const d = new Date(base);\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\n return d;\n}\n\nexport function dateToTimeParts(d: Date): TimeParts {\n return {\n h: d.getHours().toString().padStart(2, '0'),\n m: d.getMinutes().toString().padStart(2, '0'),\n s: d.getSeconds().toString().padStart(2, '0'),\n };\n}\n\nexport function formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\n const datePart = format(d, 'dd/MM/yyyy');\n if (!showTime) return datePart;\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\n}\n\nfunction padOptions(count: number) {\n return Array.from({ length: count }, (_, i) => ({\n label: i.toString().padStart(2, '0'),\n value: i.toString().padStart(2, '0'),\n }));\n}\n\nexport const hoursOptions = padOptions(24);\nexport const minutesOptions = padOptions(60);\nexport const secondsOptions = padOptions(60);\n"
363
+ "content": "import { format } from 'date-fns';\r\n\r\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\r\n\r\nexport interface TimeParts {\r\n h: string;\r\n m: string;\r\n s: string;\r\n}\r\n\r\nexport const DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\r\n\r\nexport function parseTimeParts(timeStr: string): TimeParts {\r\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\r\n return {\r\n h: h.padStart(2, '0'),\r\n m: m.padStart(2, '0'),\r\n s: s.padStart(2, '0'),\r\n };\r\n}\r\n\r\nexport function buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\r\n if (fmt === 'HH') return parts.h;\r\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\r\n return `${parts.h}:${parts.m}:${parts.s}`;\r\n}\r\n\r\nexport function applyTimeToDate(base: Date, parts: TimeParts): Date {\r\n const d = new Date(base);\r\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\r\n return d;\r\n}\r\n\r\nexport function dateToTimeParts(d: Date): TimeParts {\r\n return {\r\n h: d.getHours().toString().padStart(2, '0'),\r\n m: d.getMinutes().toString().padStart(2, '0'),\r\n s: d.getSeconds().toString().padStart(2, '0'),\r\n };\r\n}\r\n\r\nexport function formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\r\n const datePart = format(d, 'dd/MM/yyyy');\r\n if (!showTime) return datePart;\r\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\r\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\r\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\r\n}\r\n\r\nfunction padOptions(count: number) {\r\n return Array.from({ length: count }, (_, i) => ({\r\n label: i.toString().padStart(2, '0'),\r\n value: i.toString().padStart(2, '0'),\r\n }));\r\n}\r\n\r\nexport const hoursOptions = padOptions(24);\r\nexport const minutesOptions = padOptions(60);\r\nexport const secondsOptions = padOptions(60);\r\n"
364
364
  },
365
365
  {
366
366
  "path": "src/components/ui/datepicker/TimePicker.tsx",
367
- "content": "import React from 'react';\nimport type { TimeParts, TimeFormat } from './time-helpers';\nimport { hoursOptions, minutesOptions, secondsOptions } from './time-helpers';\n\nexport type TimePickerStyle = 'input' | 'select';\n\n// ─── NativeScrollSelect ───────��─────────────────────────────────────────────\n\ninterface NativeSelectProps {\n value: string;\n options: { label: string; value: string }[];\n onChange: (val: string) => void;\n 'aria-label'?: string;\n}\n\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\n <select\n aria-label={ariaLabel}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\n >\n {options.map((o) => (\n <option key={o.value} value={o.value}>{o.label}</option>\n ))}\n </select>\n);\n\n// ─── TimePicker ──────���──────────────────────────────────────────────────────\n\nexport interface TimePickerProps {\n parts: TimeParts;\n onChange: (parts: TimeParts) => void;\n timeFormat: TimeFormat;\n timePickerStyle: TimePickerStyle;\n}\n\nexport const TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\n const showSeconds = timeFormat === 'HH:mm:ss';\n\n if (timePickerStyle === 'input') {\n const step = showSeconds ? 1 : 60;\n const rawValue = showSeconds\n ? `${parts.h}:${parts.m}:${parts.s}`\n : `${parts.h}:${parts.m}`;\n\n return (\n <input\n type=\"time\"\n value={rawValue}\n step={step}\n onChange={(e) => {\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\n }}\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\n />\n );\n }\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Hours\"\n value={parts.h}\n options={hoursOptions}\n onChange={(val) => onChange({ ...parts, h: val })}\n />\n </div>\n {showMinutes && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Minutes\"\n value={parts.m}\n options={minutesOptions}\n onChange={(val) => onChange({ ...parts, m: val })}\n />\n </div>\n </>\n )}\n {showSeconds && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Seconds\"\n value={parts.s}\n options={secondsOptions}\n onChange={(val) => onChange({ ...parts, s: val })}\n />\n </div>\n </>\n )}\n </div>\n );\n};\n"
367
+ "content": "import React from 'react';\r\nimport type { TimeParts, TimeFormat } from './time-helpers';\r\nimport { hoursOptions, minutesOptions, secondsOptions } from './time-helpers';\r\n\r\nexport type TimePickerStyle = 'input' | 'select';\r\n\r\n// ─── NativeScrollSelect ───────��─────────────────────────────────────────────\r\n\r\ninterface NativeSelectProps {\r\n value: string;\r\n options: { label: string; value: string }[];\r\n onChange: (val: string) => void;\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\r\n <select\r\n aria-label={ariaLabel}\r\n value={value}\r\n onChange={(e) => onChange(e.target.value)}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n >\r\n {options.map((o) => (\r\n <option key={o.value} value={o.value}>{o.label}</option>\r\n ))}\r\n </select>\r\n);\r\n\r\n// ─── TimePicker ──────���──────────────────────────────────────────────────────\r\n\r\nexport interface TimePickerProps {\r\n parts: TimeParts;\r\n onChange: (parts: TimeParts) => void;\r\n timeFormat: TimeFormat;\r\n timePickerStyle: TimePickerStyle;\r\n}\r\n\r\nexport const TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\r\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\r\n const showSeconds = timeFormat === 'HH:mm:ss';\r\n\r\n if (timePickerStyle === 'input') {\r\n const step = showSeconds ? 1 : 60;\r\n const rawValue = showSeconds\r\n ? `${parts.h}:${parts.m}:${parts.s}`\r\n : `${parts.h}:${parts.m}`;\r\n\r\n return (\r\n <input\r\n type=\"time\"\r\n value={rawValue}\r\n step={step}\r\n onChange={(e) => {\r\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\r\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\r\n }}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n />\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"flex items-center gap-1.5\">\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Hours\"\r\n value={parts.h}\r\n options={hoursOptions}\r\n onChange={(val) => onChange({ ...parts, h: val })}\r\n />\r\n </div>\r\n {showMinutes && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Minutes\"\r\n value={parts.m}\r\n options={minutesOptions}\r\n onChange={(val) => onChange({ ...parts, m: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n {showSeconds && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Seconds\"\r\n value={parts.s}\r\n options={secondsOptions}\r\n onChange={(val) => onChange({ ...parts, s: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\r\n};\r\n"
368
368
  }
369
369
  ]
370
370
  },
@@ -379,7 +379,7 @@
379
379
  "files": [
380
380
  {
381
381
  "path": "src/components/ui/dialog/Dialog.tsx",
382
- "content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95',\r\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n close:\r\n 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground',\r\n },\r\n variants: {\r\n size: {\r\n default: {\r\n content: 'max-w-lg sm:rounded-lg',\r\n },\r\n fullScreen: {\r\n content:\r\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'default',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Dialog = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\r\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\r\n// để tránh nested button (<button><button>…</button></button>).\r\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseDialog.Trigger>;\r\n\r\ninterface DialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\r\n render?: BaseTriggerProps['render'];\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst DialogTrigger = React.forwardRef<HTMLElement, DialogTriggerProps>(\r\n ({ render: renderProp, children, ...props }, ref) => {\r\n const resolvedRender =\r\n renderProp ?? (React.isValidElement(children) ? children : undefined);\r\n\r\n return (\r\n <BaseDialog.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n render={resolvedRender}\r\n {...props}\r\n >\r\n {resolvedRender ? undefined : children}\r\n </BaseDialog.Trigger>\r\n );\r\n },\r\n);\r\nDialogTrigger.displayName = 'DialogTrigger';\r\n\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DialogClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\r\ninterface DialogContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof dialogVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\r\n ({ className, children, size, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n <BaseDialog.Close className={slots.close()}>\r\n <X className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Close</span>\r\n </BaseDialog.Close>\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDialogContent.displayName = 'DialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n },\r\n);\r\nDialogHeader.displayName = 'DialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDialogFooter.displayName = 'DialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst DialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDialogTitle.displayName = 'DialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDialogDescription.displayName = 'DialogDescription';\r\n\r\nexport {\r\n Dialog,\r\n DialogTrigger,\r\n DialogContent,\r\n DialogHeader,\r\n DialogFooter,\r\n DialogTitle,\r\n DialogDescription,\r\n DialogClose,\r\n};\r\n"
382
+ "content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',\r\n content: [\r\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 p-6',\r\n 'w-full max-w-lg rounded-xl border border-border bg-background shadow-2xl',\r\n 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 ',\r\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\r\n ].join(' '),\r\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n close:\r\n 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground',\r\n },\r\n variants: {\r\n size: {\r\n default: {\r\n content: 'max-w-lg sm:rounded-lg',\r\n },\r\n fullScreen: {\r\n content:\r\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'default',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Dialog = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\r\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\r\n// để tránh nested button (<button><button>…</button></button>).\r\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseDialog.Trigger>;\r\n\r\ninterface DialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\r\n render?: BaseTriggerProps['render'];\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst DialogTrigger = React.forwardRef<HTMLElement, DialogTriggerProps>(\r\n ({ render: renderProp, children, ...props }, ref) => {\r\n const resolvedRender =\r\n renderProp ?? (React.isValidElement(children) ? children : undefined);\r\n\r\n return (\r\n <BaseDialog.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n render={resolvedRender}\r\n {...props}\r\n >\r\n {resolvedRender ? undefined : children}\r\n </BaseDialog.Trigger>\r\n );\r\n },\r\n);\r\nDialogTrigger.displayName = 'DialogTrigger';\r\n\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DialogClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\r\ninterface DialogContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof dialogVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\r\n ({ className, children, size, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n <BaseDialog.Close className={slots.close()}>\r\n <X className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Close</span>\r\n </BaseDialog.Close>\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDialogContent.displayName = 'DialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n },\r\n);\r\nDialogHeader.displayName = 'DialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDialogFooter.displayName = 'DialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst DialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDialogTitle.displayName = 'DialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDialogDescription.displayName = 'DialogDescription';\r\n\r\nexport {\r\n Dialog,\r\n DialogTrigger,\r\n DialogContent,\r\n DialogHeader,\r\n DialogFooter,\r\n DialogTitle,\r\n DialogDescription,\r\n DialogClose,\r\n};\r\n"
383
383
  }
384
384
  ]
385
385
  },
@@ -422,7 +422,7 @@
422
422
  "files": [
423
423
  {
424
424
  "path": "src/components/ui/empty/Empty.tsx",
425
- "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\nimport {\n Users,\n FileText,\n Bell,\n SearchX,\n Database,\n FolderOpen,\n ShoppingCart,\n MessageSquare,\n ImageIcon,\n Inbox,\n Package,\n BarChart3,\n WifiOff,\n} from 'lucide-react';\n\n// ─── Preset definitions ───────────────────────────────────────────────────────\n\nexport type EmptyPreset =\n | 'users'\n | 'documents'\n | 'notifications'\n | 'search'\n | 'data'\n | 'inbox'\n | 'orders'\n | 'messages'\n | 'images'\n | 'folders'\n | 'stats'\n | 'offline'\n | 'general';\n\ninterface PresetConfig {\n icon: React.ElementType;\n title: string;\n description: string;\n /** Tailwind gradient classes for the icon circle background */\n bg: string;\n /** Tailwind bg class for decorative dot accents */\n dot: string;\n /** Tailwind text-color class for the icon */\n iconColor: string;\n}\n\nconst PRESETS: Record<EmptyPreset, PresetConfig> = {\n users: {\n icon: Users,\n title: 'Chưa có người dùng',\n description: 'Bắt đầu bằng cách mời thành viên đầu tiên vào hệ thống.',\n bg: 'from-blue-100 to-indigo-200 dark:from-blue-900/50 dark:to-indigo-800/50',\n dot: 'bg-indigo-400/50',\n iconColor: 'text-indigo-600 dark:text-indigo-300',\n },\n documents: {\n icon: FileText,\n title: 'Chưa có tài liệu',\n description: 'Tạo tài liệu đầu tiên để bắt đầu quản lý nội dung.',\n bg: 'from-amber-100 to-orange-200 dark:from-amber-900/50 dark:to-orange-800/50',\n dot: 'bg-orange-400/50',\n iconColor: 'text-orange-600 dark:text-orange-300',\n },\n notifications: {\n icon: Bell,\n title: 'Không có thông báo',\n description: 'Bạn đã đọc hết tất cả thông báo. Tuyệt vời!',\n bg: 'from-violet-100 to-purple-200 dark:from-violet-900/50 dark:to-purple-800/50',\n dot: 'bg-purple-400/50',\n iconColor: 'text-purple-600 dark:text-purple-300',\n },\n search: {\n icon: SearchX,\n title: 'Không tìm thấy kết quả',\n description: 'Thử thay đổi từ khóa hoặc điều chỉnh bộ lọc của bạn.',\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\n dot: 'bg-slate-400/50',\n iconColor: 'text-slate-500 dark:text-slate-400',\n },\n data: {\n icon: Database,\n title: 'Chưa có dữ liệu',\n description: 'Dữ liệu sẽ hiển thị tại đây sau khi bạn thêm thông tin.',\n bg: 'from-teal-100 to-emerald-200 dark:from-teal-900/50 dark:to-emerald-800/50',\n dot: 'bg-emerald-400/50',\n iconColor: 'text-emerald-600 dark:text-emerald-300',\n },\n inbox: {\n icon: Inbox,\n title: 'Hộp thư trống',\n description: 'Không có tin nhắn nào. Thưởng thức khoảnh khắc yên bình!',\n bg: 'from-pink-100 to-rose-200 dark:from-pink-900/50 dark:to-rose-800/50',\n dot: 'bg-rose-400/50',\n iconColor: 'text-rose-600 dark:text-rose-300',\n },\n orders: {\n icon: ShoppingCart,\n title: 'Chưa có đơn hàng',\n description: 'Các đơn hàng sẽ xuất hiện ở đây sau khi được tạo.',\n bg: 'from-emerald-100 to-green-200 dark:from-emerald-900/50 dark:to-green-800/50',\n dot: 'bg-green-400/50',\n iconColor: 'text-green-700 dark:text-green-300',\n },\n messages: {\n icon: MessageSquare,\n title: 'Chưa có tin nhắn',\n description: 'Bắt đầu cuộc trò chuyện mới để kết nối với mọi người.',\n bg: 'from-cyan-100 to-sky-200 dark:from-cyan-900/50 dark:to-sky-800/50',\n dot: 'bg-sky-400/50',\n iconColor: 'text-sky-600 dark:text-sky-300',\n },\n images: {\n icon: ImageIcon,\n title: 'Chưa có hình ảnh',\n description: 'Tải lên hình ảnh đầu tiên để xây dựng thư viện media.',\n bg: 'from-fuchsia-100 to-pink-200 dark:from-fuchsia-900/50 dark:to-pink-800/50',\n dot: 'bg-fuchsia-400/50',\n iconColor: 'text-fuchsia-600 dark:text-fuchsia-300',\n },\n folders: {\n icon: FolderOpen,\n title: 'Thư mục trống',\n description: 'Chưa có file nào. Kéo thả hoặc nhấn để thêm mới.',\n bg: 'from-yellow-100 to-amber-200 dark:from-yellow-900/50 dark:to-amber-800/50',\n dot: 'bg-amber-400/50',\n iconColor: 'text-amber-600 dark:text-amber-300',\n },\n stats: {\n icon: BarChart3,\n title: 'Chưa có thống kê',\n description: 'Dữ liệu phân tích sẽ hiển thị khi có hoạt động.',\n bg: 'from-blue-100 to-violet-200 dark:from-blue-900/50 dark:to-violet-800/50',\n dot: 'bg-violet-400/50',\n iconColor: 'text-blue-600 dark:text-blue-300',\n },\n offline: {\n icon: WifiOff,\n title: 'Mất kết nối',\n description: 'Không thể tải dữ liệu. Kiểm tra kết nối mạng và thử lại.',\n bg: 'from-red-100 to-rose-200 dark:from-red-900/50 dark:to-rose-800/50',\n dot: 'bg-red-400/50',\n iconColor: 'text-red-600 dark:text-red-300',\n },\n general: {\n icon: Package,\n title: 'Không có gì ở đây',\n description: 'Nội dung sẽ xuất hiện sau khi bạn thêm dữ liệu mới.',\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\n dot: 'bg-slate-400/50',\n iconColor: 'text-slate-500 dark:text-slate-400',\n },\n};\n\n// ─── Size config ──────────────────────────────────────────────────────────────\n\nexport type EmptyStateSize = 'sm' | 'md' | 'lg';\n\nconst SIZE = {\n sm: {\n root: 'py-8 px-6 gap-3',\n iconBg: 'w-16 h-16',\n outerRing: 'w-28 h-28',\n iconSize: 'w-7 h-7',\n dot: ['w-2 h-2', 'w-2.5 h-2.5', 'w-1.5 h-1.5'],\n title: 'text-base font-semibold',\n desc: 'text-xs max-w-xs',\n },\n md: {\n root: 'py-12 px-8 gap-4',\n iconBg: 'w-24 h-24',\n outerRing: 'w-40 h-40',\n iconSize: 'w-10 h-10',\n dot: ['w-3 h-3', 'w-3.5 h-3.5', 'w-2 h-2'],\n title: 'text-xl font-semibold',\n desc: 'text-sm max-w-sm',\n },\n lg: {\n root: 'py-20 px-12 gap-5',\n iconBg: 'w-32 h-32',\n outerRing: 'w-52 h-52',\n iconSize: 'w-14 h-14',\n dot: ['w-4 h-4', 'w-5 h-5', 'w-3 h-3'],\n title: 'text-2xl font-bold',\n desc: 'text-base max-w-md',\n },\n} satisfies Record<EmptyStateSize, object>;\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\nexport interface EmptyStateProps {\n /** Use a predefined empty state design */\n preset?: EmptyPreset;\n /** Override the icon (pass a lucide-react component) */\n icon?: React.ElementType;\n /** Override the title */\n title?: string;\n /** Override the description */\n description?: string;\n /** Action element(s) rendered below the description */\n action?: React.ReactNode;\n size?: EmptyStateSize;\n className?: string;\n /** Extra content rendered after the action */\n children?: React.ReactNode;\n}\n\nexport const EmptyState: React.FC<EmptyStateProps> = ({\n preset = 'general',\n icon: CustomIcon,\n title: customTitle,\n description: customDescription,\n action,\n size = 'md',\n className,\n children,\n}) => {\n const cfg = PRESETS[preset];\n const s = SIZE[size];\n const Icon = CustomIcon ?? cfg.icon;\n const title = customTitle ?? cfg.title;\n const description = customDescription ?? cfg.description;\n\n return (\n <div\n className={cn(\n 'flex flex-col items-center justify-center text-center',\n 'animate-in fade-in slide-in-from-bottom-4 duration-500',\n s.root,\n className,\n )}\n >\n {/* ── Icon area ──────────────────────────────────────────────────── */}\n <div className=\"relative inline-flex items-center justify-center\">\n {/* Outer glow halo */}\n <div\n className={cn(\n 'absolute rounded-full opacity-20 bg-gradient-to-br pointer-events-none',\n cfg.bg,\n s.outerRing,\n )}\n />\n\n {/* Icon circle */}\n <div\n className={cn(\n 'relative rounded-full flex items-center justify-center shadow-lg overflow-hidden',\n 'bg-gradient-to-br',\n cfg.bg,\n s.iconBg,\n )}\n >\n {/* Gloss highlight */}\n <div className=\"absolute inset-0 bg-gradient-to-b from-white/30 to-transparent pointer-events-none\" />\n <Icon className={cn(cfg.iconColor, s.iconSize)} strokeWidth={1.5} />\n </div>\n\n {/* Decorative dot — top-right */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse',\n cfg.dot,\n s.dot[0],\n 'top-0.5 right-0.5',\n )}\n />\n {/* Decorative dot — bottom-left */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse [animation-delay:450ms]',\n cfg.dot,\n s.dot[1],\n 'bottom-1 left-0.5',\n )}\n />\n {/* Decorative dot — left-middle */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse [animation-delay:900ms]',\n cfg.dot,\n s.dot[2],\n 'top-1/3 -left-1',\n )}\n />\n </div>\n\n {/* ── Text ───────────────────────────────────────────────────────── */}\n <div className=\"space-y-1.5\">\n <h3 className={cn('text-foreground', s.title)}>{title}</h3>\n <p className={cn('text-muted-foreground leading-relaxed mx-auto', s.desc)}>\n {description}\n </p>\n </div>\n\n {/* ── Actions ────────────────────────────────────────────────────── */}\n {action && (\n <div className=\"flex flex-wrap gap-3 justify-center\">{action}</div>\n )}\n\n {children}\n </div>\n );\n};\n\nEmptyState.displayName = 'EmptyState';\n"
425
+ "content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport {\r\n Users,\r\n FileText,\r\n Bell,\r\n SearchX,\r\n Database,\r\n FolderOpen,\r\n ShoppingCart,\r\n MessageSquare,\r\n ImageIcon,\r\n Inbox,\r\n Package,\r\n BarChart3,\r\n WifiOff,\r\n} from 'lucide-react';\r\n\r\n// ─── Preset definitions ───────────────────────────────────────────────────────\r\n\r\nexport type EmptyPreset =\r\n | 'users'\r\n | 'documents'\r\n | 'notifications'\r\n | 'search'\r\n | 'data'\r\n | 'inbox'\r\n | 'orders'\r\n | 'messages'\r\n | 'images'\r\n | 'folders'\r\n | 'stats'\r\n | 'offline'\r\n | 'general';\r\n\r\ninterface PresetConfig {\r\n icon: React.ElementType;\r\n title: string;\r\n description: string;\r\n /** Tailwind gradient classes for the icon circle background */\r\n bg: string;\r\n /** Tailwind bg class for decorative dot accents */\r\n dot: string;\r\n /** Tailwind text-color class for the icon */\r\n iconColor: string;\r\n}\r\n\r\nconst PRESETS: Record<EmptyPreset, PresetConfig> = {\r\n users: {\r\n icon: Users,\r\n title: 'Chưa có người dùng',\r\n description: 'Bắt đầu bằng cách mời thành viên đầu tiên vào hệ thống.',\r\n bg: 'from-blue-100 to-indigo-200 dark:from-blue-900/50 dark:to-indigo-800/50',\r\n dot: 'bg-indigo-400/50',\r\n iconColor: 'text-indigo-600 dark:text-indigo-300',\r\n },\r\n documents: {\r\n icon: FileText,\r\n title: 'Chưa có tài liệu',\r\n description: 'Tạo tài liệu đầu tiên để bắt đầu quản lý nội dung.',\r\n bg: 'from-amber-100 to-orange-200 dark:from-amber-900/50 dark:to-orange-800/50',\r\n dot: 'bg-orange-400/50',\r\n iconColor: 'text-orange-600 dark:text-orange-300',\r\n },\r\n notifications: {\r\n icon: Bell,\r\n title: 'Không có thông báo',\r\n description: 'Bạn đã đọc hết tất cả thông báo. Tuyệt vời!',\r\n bg: 'from-violet-100 to-purple-200 dark:from-violet-900/50 dark:to-purple-800/50',\r\n dot: 'bg-purple-400/50',\r\n iconColor: 'text-purple-600 dark:text-purple-300',\r\n },\r\n search: {\r\n icon: SearchX,\r\n title: 'Không tìm thấy kết quả',\r\n description: 'Thử thay đổi từ khóa hoặc điều chỉnh bộ lọc của bạn.',\r\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\r\n dot: 'bg-slate-400/50',\r\n iconColor: 'text-slate-500 dark:text-slate-400',\r\n },\r\n data: {\r\n icon: Database,\r\n title: 'Chưa có dữ liệu',\r\n description: 'Dữ liệu sẽ hiển thị tại đây sau khi bạn thêm thông tin.',\r\n bg: 'from-teal-100 to-emerald-200 dark:from-teal-900/50 dark:to-emerald-800/50',\r\n dot: 'bg-emerald-400/50',\r\n iconColor: 'text-emerald-600 dark:text-emerald-300',\r\n },\r\n inbox: {\r\n icon: Inbox,\r\n title: 'Hộp thư trống',\r\n description: 'Không có tin nhắn nào. Thưởng thức khoảnh khắc yên bình!',\r\n bg: 'from-pink-100 to-rose-200 dark:from-pink-900/50 dark:to-rose-800/50',\r\n dot: 'bg-rose-400/50',\r\n iconColor: 'text-rose-600 dark:text-rose-300',\r\n },\r\n orders: {\r\n icon: ShoppingCart,\r\n title: 'Chưa có đơn hàng',\r\n description: 'Các đơn hàng sẽ xuất hiện ở đây sau khi được tạo.',\r\n bg: 'from-emerald-100 to-green-200 dark:from-emerald-900/50 dark:to-green-800/50',\r\n dot: 'bg-green-400/50',\r\n iconColor: 'text-green-700 dark:text-green-300',\r\n },\r\n messages: {\r\n icon: MessageSquare,\r\n title: 'Chưa có tin nhắn',\r\n description: 'Bắt đầu cuộc trò chuyện mới để kết nối với mọi người.',\r\n bg: 'from-cyan-100 to-sky-200 dark:from-cyan-900/50 dark:to-sky-800/50',\r\n dot: 'bg-sky-400/50',\r\n iconColor: 'text-sky-600 dark:text-sky-300',\r\n },\r\n images: {\r\n icon: ImageIcon,\r\n title: 'Chưa có hình ảnh',\r\n description: 'Tải lên hình ảnh đầu tiên để xây dựng thư viện media.',\r\n bg: 'from-fuchsia-100 to-pink-200 dark:from-fuchsia-900/50 dark:to-pink-800/50',\r\n dot: 'bg-fuchsia-400/50',\r\n iconColor: 'text-fuchsia-600 dark:text-fuchsia-300',\r\n },\r\n folders: {\r\n icon: FolderOpen,\r\n title: 'Thư mục trống',\r\n description: 'Chưa có file nào. Kéo thả hoặc nhấn để thêm mới.',\r\n bg: 'from-yellow-100 to-amber-200 dark:from-yellow-900/50 dark:to-amber-800/50',\r\n dot: 'bg-amber-400/50',\r\n iconColor: 'text-amber-600 dark:text-amber-300',\r\n },\r\n stats: {\r\n icon: BarChart3,\r\n title: 'Chưa có thống kê',\r\n description: 'Dữ liệu phân tích sẽ hiển thị khi có hoạt động.',\r\n bg: 'from-blue-100 to-violet-200 dark:from-blue-900/50 dark:to-violet-800/50',\r\n dot: 'bg-violet-400/50',\r\n iconColor: 'text-blue-600 dark:text-blue-300',\r\n },\r\n offline: {\r\n icon: WifiOff,\r\n title: 'Mất kết nối',\r\n description: 'Không thể tải dữ liệu. Kiểm tra kết nối mạng và thử lại.',\r\n bg: 'from-red-100 to-rose-200 dark:from-red-900/50 dark:to-rose-800/50',\r\n dot: 'bg-red-400/50',\r\n iconColor: 'text-red-600 dark:text-red-300',\r\n },\r\n general: {\r\n icon: Package,\r\n title: 'Không có gì ở đây',\r\n description: 'Nội dung sẽ xuất hiện sau khi bạn thêm dữ liệu mới.',\r\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\r\n dot: 'bg-slate-400/50',\r\n iconColor: 'text-slate-500 dark:text-slate-400',\r\n },\r\n};\r\n\r\n// ─── Size config ──────────────────────────────────────────────────────────────\r\n\r\nexport type EmptyStateSize = 'sm' | 'md' | 'lg';\r\n\r\nconst SIZE = {\r\n sm: {\r\n root: 'py-8 px-6 gap-3',\r\n iconBg: 'w-16 h-16',\r\n outerRing: 'w-28 h-28',\r\n iconSize: 'w-7 h-7',\r\n dot: ['w-2 h-2', 'w-2.5 h-2.5', 'w-1.5 h-1.5'],\r\n title: 'text-base font-semibold',\r\n desc: 'text-xs max-w-xs',\r\n },\r\n md: {\r\n root: 'py-12 px-8 gap-4',\r\n iconBg: 'w-24 h-24',\r\n outerRing: 'w-40 h-40',\r\n iconSize: 'w-10 h-10',\r\n dot: ['w-3 h-3', 'w-3.5 h-3.5', 'w-2 h-2'],\r\n title: 'text-xl font-semibold',\r\n desc: 'text-sm max-w-sm',\r\n },\r\n lg: {\r\n root: 'py-20 px-12 gap-5',\r\n iconBg: 'w-32 h-32',\r\n outerRing: 'w-52 h-52',\r\n iconSize: 'w-14 h-14',\r\n dot: ['w-4 h-4', 'w-5 h-5', 'w-3 h-3'],\r\n title: 'text-2xl font-bold',\r\n desc: 'text-base max-w-md',\r\n },\r\n} satisfies Record<EmptyStateSize, object>;\r\n\r\n// ─── Component ────────────────────────────────────────────────────────────────\r\n\r\nexport interface EmptyStateProps {\r\n /** Use a predefined empty state design */\r\n preset?: EmptyPreset;\r\n /** Override the icon (pass a lucide-react component) */\r\n icon?: React.ElementType;\r\n /** Override the title */\r\n title?: string;\r\n /** Override the description */\r\n description?: string;\r\n /** Action element(s) rendered below the description */\r\n action?: React.ReactNode;\r\n size?: EmptyStateSize;\r\n className?: string;\r\n /** Extra content rendered after the action */\r\n children?: React.ReactNode;\r\n}\r\n\r\nexport const EmptyState: React.FC<EmptyStateProps> = ({\r\n preset = 'general',\r\n icon: CustomIcon,\r\n title: customTitle,\r\n description: customDescription,\r\n action,\r\n size = 'md',\r\n className,\r\n children,\r\n}) => {\r\n const cfg = PRESETS[preset];\r\n const s = SIZE[size];\r\n const Icon = CustomIcon ?? cfg.icon;\r\n const title = customTitle ?? cfg.title;\r\n const description = customDescription ?? cfg.description;\r\n\r\n return (\r\n <div\r\n className={cn(\r\n 'flex flex-col items-center justify-center text-center',\r\n 'animate-in fade-in slide-in-from-bottom-4 duration-500',\r\n s.root,\r\n className,\r\n )}\r\n >\r\n {/* ── Icon area ──────────────────────────────────────────────────── */}\r\n <div className=\"relative inline-flex items-center justify-center\">\r\n {/* Outer glow halo */}\r\n <div\r\n className={cn(\r\n 'absolute rounded-full opacity-20 bg-gradient-to-br pointer-events-none',\r\n cfg.bg,\r\n s.outerRing,\r\n )}\r\n />\r\n\r\n {/* Icon circle */}\r\n <div\r\n className={cn(\r\n 'relative rounded-full flex items-center justify-center shadow-lg overflow-hidden',\r\n 'bg-gradient-to-br',\r\n cfg.bg,\r\n s.iconBg,\r\n )}\r\n >\r\n {/* Gloss highlight */}\r\n <div className=\"absolute inset-0 bg-gradient-to-b from-white/30 to-transparent pointer-events-none\" />\r\n <Icon className={cn(cfg.iconColor, s.iconSize)} strokeWidth={1.5} />\r\n </div>\r\n\r\n {/* Decorative dot — top-right */}\r\n <span\r\n className={cn(\r\n 'absolute rounded-full animate-pulse',\r\n cfg.dot,\r\n s.dot[0],\r\n 'top-0.5 right-0.5',\r\n )}\r\n />\r\n {/* Decorative dot — bottom-left */}\r\n <span\r\n className={cn(\r\n 'absolute rounded-full animate-pulse [animation-delay:450ms]',\r\n cfg.dot,\r\n s.dot[1],\r\n 'bottom-1 left-0.5',\r\n )}\r\n />\r\n {/* Decorative dot — left-middle */}\r\n <span\r\n className={cn(\r\n 'absolute rounded-full animate-pulse [animation-delay:900ms]',\r\n cfg.dot,\r\n s.dot[2],\r\n 'top-1/3 -left-1',\r\n )}\r\n />\r\n </div>\r\n\r\n {/* ── Text ───────────────────────────────────────────────────────── */}\r\n <div className=\"space-y-1.5\">\r\n <h3 className={cn('text-foreground', s.title)}>{title}</h3>\r\n <p className={cn('text-muted-foreground leading-relaxed mx-auto', s.desc)}>\r\n {description}\r\n </p>\r\n </div>\r\n\r\n {/* ── Actions ────────────────────────────────────────────────────── */}\r\n {action && (\r\n <div className=\"flex flex-wrap gap-3 justify-center\">{action}</div>\r\n )}\r\n\r\n {children}\r\n </div>\r\n );\r\n};\r\n\r\nEmptyState.displayName = 'EmptyState';\r\n"
426
426
  }
427
427
  ]
428
428
  },
@@ -436,7 +436,7 @@
436
436
  "files": [
437
437
  {
438
438
  "path": "src/components/ui/file-upload/FileUpload.tsx",
439
- "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Upload, X, FileIcon, ImageIcon, FileText, FileArchive } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst fileUploadVariants = tv({\n slots: {\n root: 'flex flex-col gap-3',\n dropzone: [\n 'relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed',\n 'cursor-pointer transition-all duration-200',\n 'hover:border-primary/50 hover:bg-primary/5',\n ].join(' '),\n fileList: 'flex flex-col gap-2',\n fileItem: [\n 'flex items-center gap-3 rounded-lg border border-border bg-background p-3',\n 'transition-colors hover:bg-muted/50 group',\n ].join(' '),\n removeBtn: [\n 'shrink-0 p-1 rounded-md text-muted-foreground',\n 'hover:text-danger hover:bg-danger/10 transition-colors',\n 'opacity-0 group-hover:opacity-100',\n ].join(' '),\n },\n variants: {\n size: {\n sm: { dropzone: 'px-4 py-6 text-xs' },\n md: { dropzone: 'px-6 py-10 text-sm' },\n lg: { dropzone: 'px-8 py-14 text-base' },\n },\n isDragActive: {\n true: { dropzone: 'border-primary bg-primary/10 scale-[1.01]' },\n false: { dropzone: 'border-border' },\n },\n isError: {\n true: { dropzone: 'border-danger bg-danger/5' },\n },\n disabled: {\n true: { dropzone: 'opacity-50 cursor-not-allowed hover:border-border hover:bg-transparent' },\n },\n },\n defaultVariants: {\n size: 'md',\n isDragActive: false,\n },\n});\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return '0 B';\n const k = 1024;\n const sizes = ['B', 'KB', 'MB', 'GB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\n}\n\nfunction getFileIcon(type: string) {\n if (type.startsWith('image/')) return ImageIcon;\n if (type.includes('pdf') || type.includes('document')) return FileText;\n if (type.includes('zip') || type.includes('archive') || type.includes('rar')) return FileArchive;\n return FileIcon;\n}\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface FileUploadProps extends VariantProps<typeof fileUploadVariants> {\n /** Accepted file types (e.g. \"image/*,.pdf\") */\n accept?: string;\n /** Allow multiple files */\n multiple?: boolean;\n /** Max file size in bytes */\n maxSize?: number;\n /** Max number of files */\n maxFiles?: number;\n /** Current files (controlled) */\n value?: File[];\n /** Called when files change */\n onChange?: (files: File[]) => void;\n /** Called on validation error */\n onError?: (message: string) => void;\n /** Disable the dropzone */\n disabled?: boolean;\n /** Error message */\n error?: string;\n /** Label */\n label?: string;\n /** Description */\n description?: string;\n className?: string;\n children?: React.ReactNode;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(\n (\n {\n accept,\n multiple = false,\n maxSize,\n maxFiles,\n value = [],\n onChange,\n onError,\n disabled = false,\n error,\n label,\n description,\n size = 'md',\n className,\n children,\n },\n ref,\n ) => {\n const [isDragActive, setIsDragActive] = React.useState(false);\n const inputRef = React.useRef<HTMLInputElement>(null);\n const rootId = React.useId();\n\n const styles = fileUploadVariants({\n size,\n isDragActive,\n isError: !!error,\n disabled,\n });\n\n const validateFiles = React.useCallback(\n (fileList: File[]): File[] => {\n const valid: File[] = [];\n for (const file of fileList) {\n if (maxSize && file.size > maxSize) {\n onError?.(`\"${file.name}\" exceeds ${formatFileSize(maxSize)} limit`);\n continue;\n }\n valid.push(file);\n }\n if (maxFiles && value.length + valid.length > maxFiles) {\n onError?.(`Maximum ${maxFiles} file${maxFiles > 1 ? 's' : ''} allowed`);\n return valid.slice(0, maxFiles - value.length);\n }\n return valid;\n },\n [maxSize, maxFiles, onError, value.length],\n );\n\n const addFiles = React.useCallback(\n (newFiles: File[]) => {\n const validated = validateFiles(newFiles);\n if (validated.length === 0) return;\n onChange?.(multiple ? [...value, ...validated] : [validated[0]]);\n },\n [validateFiles, onChange, multiple, value],\n );\n\n const removeFile = React.useCallback(\n (index: number) => {\n const next = [...value];\n next.splice(index, 1);\n onChange?.(next);\n },\n [value, onChange],\n );\n\n const handleDrop = React.useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragActive(false);\n if (disabled) return;\n const files = Array.from(e.dataTransfer.files);\n addFiles(files);\n },\n [disabled, addFiles],\n );\n\n const handleDragOver = React.useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n if (!disabled) setIsDragActive(true);\n },\n [disabled],\n );\n\n const handleDragLeave = React.useCallback(() => {\n setIsDragActive(false);\n }, []);\n\n const handleInputChange = React.useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n const files = Array.from(e.target.files || []);\n addFiles(files);\n e.target.value = '';\n },\n [addFiles],\n );\n\n return (\n <div ref={ref} className={cn(styles.root(), className)}>\n {label && (\n <label htmlFor={rootId} className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <div\n className={styles.dropzone()}\n onClick={() => !disabled && inputRef.current?.click()}\n onDrop={handleDrop}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n >\n <input\n ref={inputRef}\n id={rootId}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={handleInputChange}\n disabled={disabled}\n className=\"sr-only\"\n />\n\n {children ?? (\n <>\n <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-muted\">\n <Upload className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n <div className=\"text-center\">\n <p className=\"font-medium text-foreground\">\n Drop files here or <span className=\"text-primary\">browse</span>\n </p>\n {description && (\n <p className=\"mt-1 text-muted-foreground text-xs\">{description}</p>\n )}\n </div>\n </>\n )}\n </div>\n\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n\n {value.length > 0 && (\n <div className={styles.fileList()}>\n {value.map((file, i) => {\n const Icon = getFileIcon(file.type);\n return (\n <div key={`${file.name}-${i}`} className={styles.fileItem()}>\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted\">\n <Icon className=\"h-4 w-4 text-muted-foreground\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm font-medium text-foreground truncate\">{file.name}</p>\n <p className=\"text-xs text-muted-foreground\">{formatFileSize(file.size)}</p>\n </div>\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n removeFile(i);\n }}\n className={styles.removeBtn()}\n aria-label={`Remove ${file.name}`}\n >\n <X className=\"h-4 w-4\" />\n </button>\n </div>\n );\n })}\n </div>\n )}\n </div>\n );\n },\n);\n\nFileUpload.displayName = 'FileUpload';\n\nexport { FileUpload, fileUploadVariants };\n"
439
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Upload, X, FileIcon, ImageIcon, FileText, FileArchive } from 'lucide-react';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst fileUploadVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-3',\r\n dropzone: [\r\n 'relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed',\r\n 'cursor-pointer transition-all duration-200',\r\n 'hover:border-primary/50 hover:bg-primary/5',\r\n ].join(' '),\r\n fileList: 'flex flex-col gap-2',\r\n fileItem: [\r\n 'flex items-center gap-3 rounded-lg border border-border bg-background p-3',\r\n 'transition-colors hover:bg-muted/50 group',\r\n ].join(' '),\r\n removeBtn: [\r\n 'shrink-0 p-1 rounded-md text-muted-foreground',\r\n 'hover:text-danger hover:bg-danger/10 transition-colors',\r\n 'opacity-0 group-hover:opacity-100',\r\n ].join(' '),\r\n },\r\n variants: {\r\n size: {\r\n sm: { dropzone: 'px-4 py-6 text-xs' },\r\n md: { dropzone: 'px-6 py-10 text-sm' },\r\n lg: { dropzone: 'px-8 py-14 text-base' },\r\n },\r\n isDragActive: {\r\n true: { dropzone: 'border-primary bg-primary/10 scale-[1.01]' },\r\n false: { dropzone: 'border-border' },\r\n },\r\n isError: {\r\n true: { dropzone: 'border-danger bg-danger/5' },\r\n },\r\n disabled: {\r\n true: { dropzone: 'opacity-50 cursor-not-allowed hover:border-border hover:bg-transparent' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n isDragActive: false,\r\n },\r\n});\r\n\r\n// ─── Helpers ─────────────────────────────────────────────────────────────────\r\n\r\nfunction formatFileSize(bytes: number): string {\r\n if (bytes === 0) return '0 B';\r\n const k = 1024;\r\n const sizes = ['B', 'KB', 'MB', 'GB'];\r\n const i = Math.floor(Math.log(bytes) / Math.log(k));\r\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\r\n}\r\n\r\nfunction getFileIcon(type: string) {\r\n if (type.startsWith('image/')) return ImageIcon;\r\n if (type.includes('pdf') || type.includes('document')) return FileText;\r\n if (type.includes('zip') || type.includes('archive') || type.includes('rar')) return FileArchive;\r\n return FileIcon;\r\n}\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\nexport interface FileUploadProps extends VariantProps<typeof fileUploadVariants> {\r\n /** Accepted file types (e.g. \"image/*,.pdf\") */\r\n accept?: string;\r\n /** Allow multiple files */\r\n multiple?: boolean;\r\n /** Max file size in bytes */\r\n maxSize?: number;\r\n /** Max number of files */\r\n maxFiles?: number;\r\n /** Current files (controlled) */\r\n value?: File[];\r\n /** Called when files change */\r\n onChange?: (files: File[]) => void;\r\n /** Called on validation error */\r\n onError?: (message: string) => void;\r\n /** Disable the dropzone */\r\n disabled?: boolean;\r\n /** Error message */\r\n error?: string;\r\n /** Label */\r\n label?: string;\r\n /** Description */\r\n description?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────────────────────\r\n\r\nconst FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(\r\n (\r\n {\r\n accept,\r\n multiple = false,\r\n maxSize,\r\n maxFiles,\r\n value = [],\r\n onChange,\r\n onError,\r\n disabled = false,\r\n error,\r\n label,\r\n description,\r\n size = 'md',\r\n className,\r\n children,\r\n },\r\n ref,\r\n ) => {\r\n const [isDragActive, setIsDragActive] = React.useState(false);\r\n const inputRef = React.useRef<HTMLInputElement>(null);\r\n const rootId = React.useId();\r\n\r\n const styles = fileUploadVariants({\r\n size,\r\n isDragActive,\r\n isError: !!error,\r\n disabled,\r\n });\r\n\r\n const validateFiles = React.useCallback(\r\n (fileList: File[]): File[] => {\r\n const valid: File[] = [];\r\n for (const file of fileList) {\r\n if (maxSize && file.size > maxSize) {\r\n onError?.(`\"${file.name}\" exceeds ${formatFileSize(maxSize)} limit`);\r\n continue;\r\n }\r\n valid.push(file);\r\n }\r\n if (maxFiles && value.length + valid.length > maxFiles) {\r\n onError?.(`Maximum ${maxFiles} file${maxFiles > 1 ? 's' : ''} allowed`);\r\n return valid.slice(0, maxFiles - value.length);\r\n }\r\n return valid;\r\n },\r\n [maxSize, maxFiles, onError, value.length],\r\n );\r\n\r\n const addFiles = React.useCallback(\r\n (newFiles: File[]) => {\r\n const validated = validateFiles(newFiles);\r\n if (validated.length === 0) return;\r\n onChange?.(multiple ? [...value, ...validated] : [validated[0]]);\r\n },\r\n [validateFiles, onChange, multiple, value],\r\n );\r\n\r\n const removeFile = React.useCallback(\r\n (index: number) => {\r\n const next = [...value];\r\n next.splice(index, 1);\r\n onChange?.(next);\r\n },\r\n [value, onChange],\r\n );\r\n\r\n const handleDrop = React.useCallback(\r\n (e: React.DragEvent) => {\r\n e.preventDefault();\r\n setIsDragActive(false);\r\n if (disabled) return;\r\n const files = Array.from(e.dataTransfer.files);\r\n addFiles(files);\r\n },\r\n [disabled, addFiles],\r\n );\r\n\r\n const handleDragOver = React.useCallback(\r\n (e: React.DragEvent) => {\r\n e.preventDefault();\r\n if (!disabled) setIsDragActive(true);\r\n },\r\n [disabled],\r\n );\r\n\r\n const handleDragLeave = React.useCallback(() => {\r\n setIsDragActive(false);\r\n }, []);\r\n\r\n const handleInputChange = React.useCallback(\r\n (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const files = Array.from(e.target.files || []);\r\n addFiles(files);\r\n e.target.value = '';\r\n },\r\n [addFiles],\r\n );\r\n\r\n return (\r\n <div ref={ref} className={cn(styles.root(), className)}>\r\n {label && (\r\n <label htmlFor={rootId} className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <div\r\n className={styles.dropzone()}\r\n onClick={() => !disabled && inputRef.current?.click()}\r\n onDrop={handleDrop}\r\n onDragOver={handleDragOver}\r\n onDragLeave={handleDragLeave}\r\n >\r\n <input\r\n ref={inputRef}\r\n id={rootId}\r\n type=\"file\"\r\n accept={accept}\r\n multiple={multiple}\r\n onChange={handleInputChange}\r\n disabled={disabled}\r\n className=\"sr-only\"\r\n />\r\n\r\n {children ?? (\r\n <>\r\n <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-muted\">\r\n <Upload className=\"h-5 w-5 text-muted-foreground\" />\r\n </div>\r\n <div className=\"text-center\">\r\n <p className=\"font-medium text-foreground\">\r\n Drop files here or <span className=\"text-primary\">browse</span>\r\n </p>\r\n {description && (\r\n <p className=\"mt-1 text-muted-foreground text-xs\">{description}</p>\r\n )}\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n\r\n {value.length > 0 && (\r\n <div className={styles.fileList()}>\r\n {value.map((file, i) => {\r\n const Icon = getFileIcon(file.type);\r\n return (\r\n <div key={`${file.name}-${i}`} className={styles.fileItem()}>\r\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted\">\r\n <Icon className=\"h-4 w-4 text-muted-foreground\" />\r\n </div>\r\n <div className=\"min-w-0 flex-1\">\r\n <p className=\"text-sm font-medium text-foreground truncate\">{file.name}</p>\r\n <p className=\"text-xs text-muted-foreground\">{formatFileSize(file.size)}</p>\r\n </div>\r\n <button\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n removeFile(i);\r\n }}\r\n className={styles.removeBtn()}\r\n aria-label={`Remove ${file.name}`}\r\n >\r\n <X className=\"h-4 w-4\" />\r\n </button>\r\n </div>\r\n );\r\n })}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nFileUpload.displayName = 'FileUpload';\r\n\r\nexport { FileUpload, fileUploadVariants };\r\n"
440
440
  }
441
441
  ]
442
442
  },
@@ -482,7 +482,7 @@
482
482
  },
483
483
  {
484
484
  "path": "src/components/ui/input-otp/useInputOTP.ts",
485
- "content": "import * as React from 'react';\n\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\n\nconst INPUT_PATTERNS: Record<Exclude<InputMode, 'custom'>, RegExp> = {\n numeric: /^[0-9]$/,\n alphanumeric: /^[a-zA-Z0-9]$/,\n alpha: /^[a-zA-Z]$/,\n};\n\nfunction getPattern(mode: InputMode, custom?: RegExp): RegExp {\n if (mode === 'custom' && custom) return custom;\n return INPUT_PATTERNS[mode as Exclude<InputMode, 'custom'>] ?? INPUT_PATTERNS.numeric;\n}\n\nexport function getSeparatorPositions(config: number[] | number | undefined, length: number): Set<number> {\n if (!config) return new Set();\n if (Array.isArray(config)) return new Set(config);\n const positions = new Set<number>();\n for (let i = config - 1; i < length - 1; i += config) {\n positions.add(i);\n }\n return positions;\n}\n\nexport interface UseInputOTPOptions {\n length: number;\n controlledValue?: string;\n defaultValue: string;\n onChange?: (value: string) => void;\n onComplete?: (value: string) => void;\n inputMode: InputMode;\n pattern?: RegExp;\n autoFocus: boolean;\n autoSubmit: boolean;\n}\n\nexport function useInputOTP({\n length,\n controlledValue,\n defaultValue,\n onChange,\n onComplete,\n inputMode,\n pattern: customPattern,\n autoFocus,\n autoSubmit,\n}: UseInputOTPOptions) {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const currentValue = isControlled ? controlledValue : internalValue;\n\n const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);\n\n const pat = getPattern(inputMode, customPattern);\n\n const chars = React.useMemo(() => {\n const arr = currentValue.split('');\n while (arr.length < length) arr.push('');\n return arr.slice(0, length);\n }, [currentValue, length]);\n\n const isComplete = chars.every((c) => c !== '');\n\n const updateValue = React.useCallback(\n (newChars: string[]) => {\n const val = newChars.join('');\n if (!isControlled) setInternalValue(val);\n onChange?.(val);\n if (val.length === length && newChars.every((c) => c !== '')) {\n onComplete?.(val);\n }\n },\n [isControlled, onChange, onComplete, length],\n );\n\n const focusSlot = React.useCallback((index: number) => {\n const clamped = Math.max(0, Math.min(index, length - 1));\n inputRefs.current[clamped]?.focus();\n }, [length]);\n\n const handleInput = React.useCallback(\n (index: number, char: string) => {\n if (!pat.test(char)) return;\n const next = [...chars];\n next[index] = char;\n updateValue(next);\n if (index < length - 1) {\n focusSlot(index + 1);\n }\n },\n [chars, pat, updateValue, length, focusSlot],\n );\n\n const handleKeyDown = React.useCallback(\n (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {\n switch (e.key) {\n case 'Backspace': {\n e.preventDefault();\n const next = [...chars];\n if (chars[index] !== '') {\n next[index] = '';\n updateValue(next);\n } else if (index > 0) {\n next[index - 1] = '';\n updateValue(next);\n focusSlot(index - 1);\n }\n break;\n }\n case 'Delete': {\n e.preventDefault();\n const next = [...chars];\n next[index] = '';\n updateValue(next);\n break;\n }\n case 'ArrowLeft':\n e.preventDefault();\n if (index > 0) focusSlot(index - 1);\n break;\n case 'ArrowRight':\n e.preventDefault();\n if (index < length - 1) focusSlot(index + 1);\n break;\n case 'Home':\n e.preventDefault();\n focusSlot(0);\n break;\n case 'End':\n e.preventDefault();\n focusSlot(length - 1);\n break;\n }\n },\n [chars, updateValue, focusSlot, length],\n );\n\n const handlePaste = React.useCallback(\n (e: React.ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const pasted = e.clipboardData.getData('text/plain').trim();\n const next = [...chars];\n let cursor = focusedIndex ?? 0;\n for (const ch of pasted) {\n if (cursor >= length) break;\n if (pat.test(ch)) {\n next[cursor] = ch;\n cursor++;\n }\n }\n updateValue(next);\n focusSlot(Math.min(cursor, length - 1));\n },\n [chars, focusedIndex, length, pat, updateValue, focusSlot],\n );\n\n // Auto focus\n React.useEffect(() => {\n if (autoFocus) {\n const firstEmpty = chars.findIndex((c) => c === '');\n focusSlot(firstEmpty === -1 ? 0 : firstEmpty);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Auto submit\n React.useEffect(() => {\n if (autoSubmit && isComplete) {\n const form = inputRefs.current[0]?.closest('form');\n if (form) {\n form.requestSubmit();\n }\n }\n }, [autoSubmit, isComplete]);\n\n return {\n chars,\n isComplete,\n focusedIndex,\n setFocusedIndex,\n inputRefs,\n focusSlot,\n handleInput,\n handleKeyDown,\n handlePaste,\n };\n}\n"
485
+ "content": "import * as React from 'react';\r\n\r\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\r\n\r\nconst INPUT_PATTERNS: Record<Exclude<InputMode, 'custom'>, RegExp> = {\r\n numeric: /^[0-9]$/,\r\n alphanumeric: /^[a-zA-Z0-9]$/,\r\n alpha: /^[a-zA-Z]$/,\r\n};\r\n\r\nfunction getPattern(mode: InputMode, custom?: RegExp): RegExp {\r\n if (mode === 'custom' && custom) return custom;\r\n return INPUT_PATTERNS[mode as Exclude<InputMode, 'custom'>] ?? INPUT_PATTERNS.numeric;\r\n}\r\n\r\nexport function getSeparatorPositions(config: number[] | number | undefined, length: number): Set<number> {\r\n if (!config) return new Set();\r\n if (Array.isArray(config)) return new Set(config);\r\n const positions = new Set<number>();\r\n for (let i = config - 1; i < length - 1; i += config) {\r\n positions.add(i);\r\n }\r\n return positions;\r\n}\r\n\r\nexport interface UseInputOTPOptions {\r\n length: number;\r\n controlledValue?: string;\r\n defaultValue: string;\r\n onChange?: (value: string) => void;\r\n onComplete?: (value: string) => void;\r\n inputMode: InputMode;\r\n pattern?: RegExp;\r\n autoFocus: boolean;\r\n autoSubmit: boolean;\r\n}\r\n\r\nexport function useInputOTP({\r\n length,\r\n controlledValue,\r\n defaultValue,\r\n onChange,\r\n onComplete,\r\n inputMode,\r\n pattern: customPattern,\r\n autoFocus,\r\n autoSubmit,\r\n}: UseInputOTPOptions) {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState(defaultValue);\r\n const currentValue = isControlled ? controlledValue : internalValue;\r\n\r\n const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\r\n const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);\r\n\r\n const pat = getPattern(inputMode, customPattern);\r\n\r\n const chars = React.useMemo(() => {\r\n const arr = currentValue.split('');\r\n while (arr.length < length) arr.push('');\r\n return arr.slice(0, length);\r\n }, [currentValue, length]);\r\n\r\n const isComplete = chars.every((c) => c !== '');\r\n\r\n const updateValue = React.useCallback(\r\n (newChars: string[]) => {\r\n const val = newChars.join('');\r\n if (!isControlled) setInternalValue(val);\r\n onChange?.(val);\r\n if (val.length === length && newChars.every((c) => c !== '')) {\r\n onComplete?.(val);\r\n }\r\n },\r\n [isControlled, onChange, onComplete, length],\r\n );\r\n\r\n const focusSlot = React.useCallback((index: number) => {\r\n const clamped = Math.max(0, Math.min(index, length - 1));\r\n inputRefs.current[clamped]?.focus();\r\n }, [length]);\r\n\r\n const handleInput = React.useCallback(\r\n (index: number, char: string) => {\r\n if (!pat.test(char)) return;\r\n const next = [...chars];\r\n next[index] = char;\r\n updateValue(next);\r\n if (index < length - 1) {\r\n focusSlot(index + 1);\r\n }\r\n },\r\n [chars, pat, updateValue, length, focusSlot],\r\n );\r\n\r\n const handleKeyDown = React.useCallback(\r\n (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {\r\n switch (e.key) {\r\n case 'Backspace': {\r\n e.preventDefault();\r\n const next = [...chars];\r\n if (chars[index] !== '') {\r\n next[index] = '';\r\n updateValue(next);\r\n } else if (index > 0) {\r\n next[index - 1] = '';\r\n updateValue(next);\r\n focusSlot(index - 1);\r\n }\r\n break;\r\n }\r\n case 'Delete': {\r\n e.preventDefault();\r\n const next = [...chars];\r\n next[index] = '';\r\n updateValue(next);\r\n break;\r\n }\r\n case 'ArrowLeft':\r\n e.preventDefault();\r\n if (index > 0) focusSlot(index - 1);\r\n break;\r\n case 'ArrowRight':\r\n e.preventDefault();\r\n if (index < length - 1) focusSlot(index + 1);\r\n break;\r\n case 'Home':\r\n e.preventDefault();\r\n focusSlot(0);\r\n break;\r\n case 'End':\r\n e.preventDefault();\r\n focusSlot(length - 1);\r\n break;\r\n }\r\n },\r\n [chars, updateValue, focusSlot, length],\r\n );\r\n\r\n const handlePaste = React.useCallback(\r\n (e: React.ClipboardEvent<HTMLInputElement>) => {\r\n e.preventDefault();\r\n const pasted = e.clipboardData.getData('text/plain').trim();\r\n const next = [...chars];\r\n let cursor = focusedIndex ?? 0;\r\n for (const ch of pasted) {\r\n if (cursor >= length) break;\r\n if (pat.test(ch)) {\r\n next[cursor] = ch;\r\n cursor++;\r\n }\r\n }\r\n updateValue(next);\r\n focusSlot(Math.min(cursor, length - 1));\r\n },\r\n [chars, focusedIndex, length, pat, updateValue, focusSlot],\r\n );\r\n\r\n // Auto focus\r\n React.useEffect(() => {\r\n if (autoFocus) {\r\n const firstEmpty = chars.findIndex((c) => c === '');\r\n focusSlot(firstEmpty === -1 ? 0 : firstEmpty);\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n // Auto submit\r\n React.useEffect(() => {\r\n if (autoSubmit && isComplete) {\r\n const form = inputRefs.current[0]?.closest('form');\r\n if (form) {\r\n form.requestSubmit();\r\n }\r\n }\r\n }, [autoSubmit, isComplete]);\r\n\r\n return {\r\n chars,\r\n isComplete,\r\n focusedIndex,\r\n setFocusedIndex,\r\n inputRefs,\r\n focusSlot,\r\n handleInput,\r\n handleKeyDown,\r\n handlePaste,\r\n };\r\n}\r\n"
486
486
  }
487
487
  ]
488
488
  },
@@ -513,7 +513,7 @@
513
513
  "files": [
514
514
  {
515
515
  "path": "src/components/ui/number-input/NumberInput.tsx",
516
- "content": "import * as React from 'react';\nimport { NumberField } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Minus, Plus } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst numberInputVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5',\n group: 'inline-flex items-center border border-border rounded-lg bg-background overflow-hidden transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n input: [\n 'h-full bg-transparent text-center text-sm font-medium text-foreground outline-none',\n 'placeholder:text-muted-foreground',\n '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',\n ].join(' '),\n button: [\n 'inline-flex items-center justify-center shrink-0 border-0 bg-transparent text-muted-foreground',\n 'transition-colors hover:bg-muted hover:text-foreground',\n 'disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\n ].join(' '),\n label: 'text-sm font-medium text-foreground leading-none',\n description: 'text-[0.8rem] text-muted-foreground',\n error: 'text-[0.8rem] font-medium text-danger',\n },\n variants: {\n size: {\n sm: {\n group: 'h-8',\n input: 'w-12 text-xs',\n button: 'w-8 h-8',\n },\n md: {\n group: 'h-10',\n input: 'w-14 text-sm',\n button: 'w-10 h-10',\n },\n lg: {\n group: 'h-12',\n input: 'w-16 text-base',\n button: 'w-12 h-12',\n },\n },\n isError: {\n true: { group: 'border-danger focus-within:border-danger focus-within:ring-danger/20' },\n },\n disabled: {\n true: { group: 'opacity-50 cursor-not-allowed' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface NumberInputProps extends VariantProps<typeof numberInputVariants> {\n value?: number | null;\n defaultValue?: number;\n onChange?: (value: number | null) => void;\n min?: number;\n max?: number;\n step?: number;\n disabled?: boolean;\n label?: string;\n description?: string;\n error?: string;\n placeholder?: string;\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>(\n (\n {\n value,\n defaultValue = 0,\n onChange,\n min,\n max,\n step = 1,\n disabled = false,\n label,\n description,\n error,\n placeholder = '0',\n size = 'md',\n className,\n },\n ref,\n ) => {\n const styles = numberInputVariants({ size, isError: !!error, disabled });\n const rootId = React.useId();\n\n return (\n <NumberField.Root\n ref={ref}\n value={value ?? undefined}\n defaultValue={defaultValue}\n onValueChange={(val) => onChange?.(val)}\n min={min}\n max={max}\n step={step}\n disabled={disabled}\n className={cn(styles.root(), className)}\n >\n {label && (\n <label htmlFor={rootId} className={styles.label()}>\n {label}\n </label>\n )}\n\n <NumberField.Group className={styles.group()}>\n <NumberField.Decrement\n className={cn(styles.button(), 'border-r border-border')}\n aria-label=\"Decrease\"\n >\n <Minus className=\"h-3.5 w-3.5\" />\n </NumberField.Decrement>\n\n <NumberField.Input\n id={rootId}\n placeholder={placeholder}\n className={styles.input()}\n />\n\n <NumberField.Increment\n className={cn(styles.button(), 'border-l border-border')}\n aria-label=\"Increase\"\n >\n <Plus className=\"h-3.5 w-3.5\" />\n </NumberField.Increment>\n </NumberField.Group>\n\n {description && !error && (\n <p className={styles.description()}>{description}</p>\n )}\n {error && (\n <p className={styles.error()}>{error}</p>\n )}\n </NumberField.Root>\n );\n },\n);\n\nNumberInput.displayName = 'NumberInput';\n\nexport { NumberInput, numberInputVariants };\n"
516
+ "content": "import * as React from 'react';\r\nimport { NumberField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Minus, Plus } from 'lucide-react';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst numberInputVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5',\r\n group: 'inline-flex items-center border border-border rounded-lg bg-background overflow-hidden transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\r\n input: [\r\n 'h-full bg-transparent text-center text-sm font-medium text-foreground outline-none',\r\n 'placeholder:text-muted-foreground',\r\n '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',\r\n ].join(' '),\r\n button: [\r\n 'inline-flex items-center justify-center shrink-0 border-0 bg-transparent text-muted-foreground',\r\n 'transition-colors hover:bg-muted hover:text-foreground',\r\n 'disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\r\n ].join(' '),\r\n label: 'text-sm font-medium text-foreground leading-none',\r\n description: 'text-[0.8rem] text-muted-foreground',\r\n error: 'text-[0.8rem] font-medium text-danger',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n group: 'h-8',\r\n input: 'w-12 text-xs',\r\n button: 'w-8 h-8',\r\n },\r\n md: {\r\n group: 'h-10',\r\n input: 'w-14 text-sm',\r\n button: 'w-10 h-10',\r\n },\r\n lg: {\r\n group: 'h-12',\r\n input: 'w-16 text-base',\r\n button: 'w-12 h-12',\r\n },\r\n },\r\n isError: {\r\n true: { group: 'border-danger focus-within:border-danger focus-within:ring-danger/20' },\r\n },\r\n disabled: {\r\n true: { group: 'opacity-50 cursor-not-allowed' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\nexport interface NumberInputProps extends VariantProps<typeof numberInputVariants> {\r\n value?: number | null;\r\n defaultValue?: number;\r\n onChange?: (value: number | null) => void;\r\n min?: number;\r\n max?: number;\r\n step?: number;\r\n disabled?: boolean;\r\n label?: string;\r\n description?: string;\r\n error?: string;\r\n placeholder?: string;\r\n className?: string;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────────────────────\r\n\r\nconst NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>(\r\n (\r\n {\r\n value,\r\n defaultValue = 0,\r\n onChange,\r\n min,\r\n max,\r\n step = 1,\r\n disabled = false,\r\n label,\r\n description,\r\n error,\r\n placeholder = '0',\r\n size = 'md',\r\n className,\r\n },\r\n ref,\r\n ) => {\r\n const styles = numberInputVariants({ size, isError: !!error, disabled });\r\n const rootId = React.useId();\r\n\r\n return (\r\n <NumberField.Root\r\n ref={ref}\r\n value={value ?? undefined}\r\n defaultValue={defaultValue}\r\n onValueChange={(val) => onChange?.(val)}\r\n min={min}\r\n max={max}\r\n step={step}\r\n disabled={disabled}\r\n className={cn(styles.root(), className)}\r\n >\r\n {label && (\r\n <label htmlFor={rootId} className={styles.label()}>\r\n {label}\r\n </label>\r\n )}\r\n\r\n <NumberField.Group className={styles.group()}>\r\n <NumberField.Decrement\r\n className={cn(styles.button(), 'border-r border-border')}\r\n aria-label=\"Decrease\"\r\n >\r\n <Minus className=\"h-3.5 w-3.5\" />\r\n </NumberField.Decrement>\r\n\r\n <NumberField.Input\r\n id={rootId}\r\n placeholder={placeholder}\r\n className={styles.input()}\r\n />\r\n\r\n <NumberField.Increment\r\n className={cn(styles.button(), 'border-l border-border')}\r\n aria-label=\"Increase\"\r\n >\r\n <Plus className=\"h-3.5 w-3.5\" />\r\n </NumberField.Increment>\r\n </NumberField.Group>\r\n\r\n {description && !error && (\r\n <p className={styles.description()}>{description}</p>\r\n )}\r\n {error && (\r\n <p className={styles.error()}>{error}</p>\r\n )}\r\n </NumberField.Root>\r\n );\r\n },\r\n);\r\n\r\nNumberInput.displayName = 'NumberInput';\r\n\r\nexport { NumberInput, numberInputVariants };\r\n"
517
517
  }
518
518
  ]
519
519
  },
@@ -541,7 +541,7 @@
541
541
  "files": [
542
542
  {
543
543
  "path": "src/components/ui/popover/Popover.tsx",
544
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst popoverVariants = tv({\r\n slots: {\r\n popup: 'z-50 w-72 rounded-md border border-border bg-background p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-ending:animate-out data-ending:fade-out-0 data-ending: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',\r\n arrow: 'fill-popover stroke-border stroke-[1px]',\r\n },\r\n});\r\n\r\nconst { popup, arrow } = popoverVariants();\r\n\r\n// ─── Compound Components ─────────────────────────────────────────────────────\r\n\r\nconst Popover = BasePopover.Root;\r\n\r\nconst PopoverTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ComponentPropsWithoutRef<typeof BasePopover.Trigger>\r\n>(({ children, render, ...props }, ref) => (\r\n <BasePopover.Trigger\r\n ref={ref}\r\n render={render ?? (React.isValidElement(children) ? children : undefined)}\r\n {...props}\r\n >\r\n {React.isValidElement(children) ? undefined : children}\r\n </BasePopover.Trigger>\r\n));\r\nPopoverTrigger.displayName = 'PopoverTrigger';\r\n\r\nexport interface PopoverContentProps\r\n extends React.ComponentPropsWithoutRef<typeof BasePopover.Popup> {\r\n /** Side offset from the trigger (default: 4) */\r\n sideOffset?: number;\r\n /** Side to display the popover (default: 'bottom') */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger (default: 'center') */\r\n align?: 'start' | 'center' | 'end';\r\n /** Show the arrow indicator */\r\n showArrow?: boolean;\r\n}\r\n\r\nconst PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(\r\n ({ className, sideOffset = 4, side = 'bottom', align = 'center', showArrow = true, children, ...props }, ref) => (\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner sideOffset={sideOffset} side={side} align={align}>\r\n <BasePopover.Popup ref={ref} className={cn(popup(), className)} {...props}>\r\n {showArrow && <BasePopover.Arrow className={arrow()} />}\r\n {children}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n )\r\n);\r\nPopoverContent.displayName = 'PopoverContent';\r\n\r\nconst PopoverClose = BasePopover.Close;\r\n\r\nexport { Popover, PopoverTrigger, PopoverContent, PopoverClose, popoverVariants };\r\n"
544
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst popoverVariants = tv({\n slots: {\n popup: 'z-50 w-72 rounded-md border border-border bg-background p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-ending:animate-out data-ending:fade-out-0 data-ending: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',\n arrow: 'fill-popover stroke-border stroke-[1px]',\n },\n});\n\nconst { popup, arrow } = popoverVariants();\n\n// ─── Compound Components ─────────────────────────────────────────────────────\n\nconst Popover = BasePopover.Root;\n\nconst PopoverTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BasePopover.Trigger>\n>(({ children, render: renderProp, ...props }, ref) => {\n // Wrap pattern: <PopoverTrigger><Button /></PopoverTrigger>\n // → forward the element as `render` so Base UI merges trigger props into it (no nested <button>)\n const isElement = React.isValidElement(children);\n return (\n <BasePopover.Trigger\n ref={ref}\n render={renderProp ?? (isElement ? (children as React.ReactElement) : undefined)}\n {...props}\n >\n {isElement ? undefined : children}\n </BasePopover.Trigger>\n );\n});\nPopoverTrigger.displayName = 'PopoverTrigger';\n\nexport interface PopoverContentProps\n extends React.ComponentPropsWithoutRef<typeof BasePopover.Popup> {\n /** Side offset from the trigger (default: 4) */\n sideOffset?: number;\n /** Side to display the popover (default: 'bottom') */\n side?: 'top' | 'right' | 'bottom' | 'left';\n /** Alignment relative to the trigger (default: 'center') */\n align?: 'start' | 'center' | 'end';\n /** Show the arrow indicator */\n showArrow?: boolean;\n}\n\nconst PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(\n ({ className, sideOffset = 4, side = 'bottom', align = 'center', showArrow = true, children, ...props }, ref) => (\n <BasePopover.Portal>\n <BasePopover.Positioner sideOffset={sideOffset} side={side} align={align}>\n <BasePopover.Popup ref={ref} className={cn(popup(), className)} {...props}>\n {showArrow && <BasePopover.Arrow className={arrow()} />}\n {children}\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n )\n);\nPopoverContent.displayName = 'PopoverContent';\n\nconst PopoverClose = BasePopover.Close;\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverClose };\n"
545
545
  }
546
546
  ]
547
547
  },
@@ -558,7 +558,7 @@
558
558
  "files": [
559
559
  {
560
560
  "path": "src/components/ui/pretty-code/PrettyCode.tsx",
561
- "content": "import React, { useState, useEffect, useCallback } from 'react';\r\nimport { createHighlighter, type Highlighter } from 'shiki';\r\nimport { unified } from 'unified';\r\nimport rehypeParse from 'rehype-parse';\r\nimport rehypeReact from 'rehype-react';\r\nimport * as prod from 'react/jsx-runtime';\r\nimport { Copy, Check } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Singleton highlighter ────────────────────────────────────────────────────\r\nlet globalHighlighter: Highlighter | null = null;\r\n\r\nconst getHighlighter = async () => {\r\n if (globalHighlighter) return globalHighlighter;\r\n globalHighlighter = await createHighlighter({\r\n themes: ['nord'],\r\n langs: ['tsx', 'typescript', 'javascript', 'bash', 'json'],\r\n });\r\n return globalHighlighter;\r\n};\r\n\r\n// ─── Helpers ──────────────────────────────────────────────────────────────────\r\nconst LANG_LABELS: Record<string, string> = {\r\n tsx: 'TSX',\r\n typescript: 'TypeScript',\r\n javascript: 'JavaScript',\r\n bash: 'Bash',\r\n json: 'JSON',\r\n};\r\n\r\n// Fixed widths for loading skeleton rows\r\nconst SKELETON_WIDTHS = ['68%', '82%', '54%', '76%', '45%', '60%'];\r\n\r\n// ─── Types ────────────────────────────────────────────────────────────────────\r\ninterface PrettyCodeProps {\r\n code: string;\r\n lang?: string;\r\n /** Optional filename shown in the header bar */\r\n filename?: string;\r\n className?: string;\r\n}\r\n\r\n// ─── Component ────────────────────────────────────────────────────────────────\r\nexport const PrettyCode: React.FC<PrettyCodeProps> = ({\r\n code,\r\n lang = 'tsx',\r\n filename,\r\n className,\r\n}) => {\r\n const [nodes, setNodes] = useState<React.ReactNode>(null);\r\n const [loading, setLoading] = useState(true);\r\n const [copied, setCopied] = useState(false);\r\n\r\n useEffect(() => {\r\n let isMounted = true;\r\n setLoading(true);\r\n setNodes(null);\r\n\r\n const highlight = async () => {\r\n try {\r\n const highlighter = await getHighlighter();\r\n const html = highlighter.codeToHtml(code, { lang, theme: 'nord' });\r\n\r\n const file = await unified()\r\n .use(rehypeParse, { fragment: true })\r\n .use(rehypeReact, { ...prod })\r\n .process(html);\r\n\r\n if (isMounted) {\r\n setNodes(file.result as React.ReactNode);\r\n setLoading(false);\r\n }\r\n } catch (err) {\r\n console.error('Failed to highlight code:', err);\r\n if (isMounted) setLoading(false);\r\n }\r\n };\r\n\r\n highlight();\r\n return () => { isMounted = false; };\r\n }, [code, lang]);\r\n\r\n const handleCopy = useCallback(async () => {\r\n try {\r\n await navigator.clipboard.writeText(code);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), 2000);\r\n } catch {\r\n // clipboard not available\r\n }\r\n }, [code]);\r\n\r\n const langLabel = LANG_LABELS[lang] ?? lang.toUpperCase();\r\n\r\n return (\r\n <div className={cn('rounded-xl overflow-hidden border border-white/[0.07] bg-[#2e3440] shadow-2xl', className)}>\r\n\r\n {/* ── Header bar ── */}\r\n <div className=\"flex items-center justify-between px-4 py-2.5 bg-[#252b37] border-b border-white/[0.07] select-none\">\r\n\r\n {/* Left: window dots + filename */}\r\n <div className=\"flex items-center gap-3 min-w-0\">\r\n <div className=\"flex items-center gap-1.5 shrink-0\">\r\n <span className=\"w-3 h-3 rounded-full bg-[#ff5f57]\" />\r\n <span className=\"w-3 h-3 rounded-full bg-[#febc2e]\" />\r\n <span className=\"w-3 h-3 rounded-full bg-[#28c840]\" />\r\n </div>\r\n {filename && (\r\n <span className=\"text-[11px] text-zinc-400 font-mono truncate leading-none\">\r\n {filename}\r\n </span>\r\n )}\r\n </div>\r\n\r\n {/* Right: language badge + copy button */}\r\n <div className=\"flex items-center gap-2 shrink-0 ml-4\">\r\n <span className=\"hidden sm:block text-[10px] font-bold tracking-widest text-zinc-600 uppercase\">\r\n {langLabel}\r\n </span>\r\n\r\n <button\r\n onClick={handleCopy}\r\n aria-label=\"Copy code\"\r\n className={cn(\r\n 'flex items-center gap-1.5 px-2 py-1 rounded-md',\r\n 'text-xs font-medium transition-all duration-150 active:scale-95',\r\n copied\r\n ? 'text-emerald-400 bg-emerald-400/10'\r\n : 'text-zinc-400 hover:text-zinc-100 hover:bg-white/10',\r\n )}\r\n >\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 shrink-0\" />\r\n : <Copy className=\"w-3.5 h-3.5 shrink-0\" />\r\n }\r\n <span className=\"hidden sm:inline w-[42px]\">\r\n {copied ? 'Copied!' : 'Copy'}\r\n </span>\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* ── Code area ── */}\r\n {loading ? (\r\n <div className=\"p-5 space-y-3 animate-pulse\" aria-hidden>\r\n {SKELETON_WIDTHS.map((w, i) => (\r\n <div\r\n key={i}\r\n className=\"h-3.5 rounded-full bg-white/[0.08]\"\r\n style={{ width: w }}\r\n />\r\n ))}\r\n </div>\r\n ) : (\r\n <div\r\n className=\"overflow-x-auto\r\n [&_pre]:!bg-transparent [&_pre]:m-0\r\n [&_pre]:px-5 [&_pre]:py-4\r\n [&_pre]:text-[13px] [&_pre]:leading-[1.7]\r\n [&_code]:font-mono [&_code]:text-[13px]\"\r\n >\r\n {nodes}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n};\r\n"
561
+ "content": "import React, { useState, useEffect, useCallback } from 'react';\nimport { createHighlighter, type Highlighter } from 'shiki';\nimport { unified } from 'unified';\nimport rehypeParse from 'rehype-parse';\nimport rehypeReact from 'rehype-react';\nimport * as prod from 'react/jsx-runtime';\nimport { Copy, Check } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Singleton highlighter ────────────────────────────────────────────────────\nlet globalHighlighter: Highlighter | null = null;\n\nconst getHighlighter = async () => {\n if (globalHighlighter) return globalHighlighter;\n globalHighlighter = await createHighlighter({\n themes: ['nord'],\n langs: ['tsx', 'typescript', 'javascript', 'bash', 'json'],\n });\n return globalHighlighter;\n};\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\nconst LANG_LABELS: Record<string, string> = {\n tsx: 'TSX',\n typescript: 'TypeScript',\n javascript: 'JavaScript',\n bash: 'Bash',\n json: 'JSON',\n};\n\n// Fixed widths for loading skeleton rows\nconst SKELETON_WIDTHS = ['68%', '82%', '54%', '76%', '45%', '60%'];\n\n// ─── Types ────────────────────────────────────────────────────────────────────\ninterface PrettyCodeProps {\n code: string;\n lang?: string;\n /** Optional filename shown in the header bar */\n filename?: string;\n className?: string;\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\nexport const PrettyCode: React.FC<PrettyCodeProps> = ({\n code,\n lang = 'tsx',\n filename,\n className,\n}) => {\n const [nodes, setNodes] = useState<React.ReactNode>(null);\n const [loading, setLoading] = useState(true);\n const [copied, setCopied] = useState(false);\n\n useEffect(() => {\n let isMounted = true;\n setLoading(true);\n setNodes(null);\n\n const highlight = async () => {\n try {\n const highlighter = await getHighlighter();\n const html = highlighter.codeToHtml(code, { lang, theme: 'nord' });\n\n const file = await unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeReact, { ...prod })\n .process(html);\n\n if (isMounted) {\n setNodes(file.result as React.ReactNode);\n setLoading(false);\n }\n } catch (err) {\n console.error('Failed to highlight code:', err);\n if (isMounted) setLoading(false);\n }\n };\n\n highlight();\n return () => { isMounted = false; };\n }, [code, lang]);\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch {\n // clipboard not available\n }\n }, [code]);\n\n const langLabel = LANG_LABELS[lang] ?? lang.toUpperCase();\n\n return (\n <div className={cn('rounded-xl overflow-hidden border border-white/[0.07] bg-[#2e3440] shadow-2xl', className)}>\n\n {/* ── Header bar ── */}\n <div className=\"flex items-center justify-between px-4 py-2.5 bg-[#252b37] border-b border-white/[0.07] select-none\">\n\n {/* Left: window dots + filename */}\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"flex items-center gap-1.5 shrink-0\">\n <span className=\"w-3 h-3 rounded-full bg-[#ff5f57]\" />\n <span className=\"w-3 h-3 rounded-full bg-[#febc2e]\" />\n <span className=\"w-3 h-3 rounded-full bg-[#28c840]\" />\n </div>\n {filename && (\n <span className=\"text-[11px] text-zinc-400 font-mono truncate leading-none\">\n {filename}\n </span>\n )}\n </div>\n\n {/* Right: language badge + copy button */}\n <div className=\"flex items-center gap-2 shrink-0 ml-4\">\n <span className=\"hidden sm:block text-[10px] font-bold tracking-widest text-zinc-600 uppercase\">\n {langLabel}\n </span>\n\n <button\n onClick={handleCopy}\n aria-label=\"Copy code\"\n className={cn(\n 'flex items-center gap-1.5 px-2 py-1 rounded-md',\n 'text-xs font-medium transition-all duration-150 active:scale-95',\n copied\n ? 'text-emerald-400 bg-emerald-400/10'\n : 'text-zinc-400 hover:text-zinc-100 hover:bg-white/10',\n )}\n >\n {copied\n ? <Check className=\"w-3.5 h-3.5 shrink-0\" />\n : <Copy className=\"w-3.5 h-3.5 shrink-0\" />\n }\n <span className=\"hidden sm:inline w-[42px]\">\n {copied ? 'Copied!' : 'Copy'}\n </span>\n </button>\n </div>\n </div>\n\n {/* ── Code area ── */}\n {loading ? (\n <div className=\"p-5 space-y-3 animate-pulse\" aria-hidden>\n {SKELETON_WIDTHS.map((w, i) => (\n <div\n key={i}\n className=\"h-3.5 rounded-full bg-white/[0.08]\"\n style={{ width: w }}\n />\n ))}\n </div>\n ) : (\n <div\n className=\"overflow-x-auto\n [&_pre]:!bg-transparent [&_pre]:m-0\n [&_pre]:px-5 [&_pre]:py-4\n [&_pre]:text-[13px] [&_pre]:leading-[1.7]\n [&_code]:font-mono [&_code]:text-[13px]\"\n >\n {nodes}\n </div>\n )}\n </div>\n );\n};\n"
562
562
  }
563
563
  ]
564
564
  },
@@ -602,7 +602,7 @@
602
602
  "files": [
603
603
  {
604
604
  "path": "src/components/ui/radio/Radio.tsx",
605
- "content": "import * as React from 'react';\nimport { Radio as BaseRadio } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst radioVariants = tv({\n slots: {\n root: 'group flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:border-primary data-checked:border-primary',\n indicator: 'flex items-center justify-center',\n dot: 'rounded-full bg-primary',\n card: 'group/card relative flex flex-row items-start gap-4 cursor-pointer rounded-xl border border-border bg-card p-4 w-full shadow-sm outline-none transition-all hover:bg-accent/50 hover:text-accent-foreground data-[checked]:border-primary data-[checked]:bg-primary/5 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 overflow-hidden',\n cardCircle: 'flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all group-data-[checked]/card:border-primary group-data-[checked]/card:text-primary mt-0.5',\n },\n variants: {\n size: {\n sm: { root: 'h-4 w-4', cardCircle: 'h-4 w-4', dot: 'h-1.5 w-1.5' },\n md: { root: 'h-5 w-5', cardCircle: 'h-5 w-5', dot: 'h-2 w-2' },\n lg: { root: 'h-6 w-6', cardCircle: 'h-6 w-6', dot: 'h-2.5 w-2.5' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nexport interface RadioProps\n extends Omit<BaseRadio.Root.Props, 'className'>,\n VariantProps<typeof radioVariants> {\n variant?: 'default' | 'card';\n label?: string;\n className?: string;\n /** Hiện/ẩn indicator (circle). Card variant mặc định ẩn. */\n showIndicator?: boolean;\n}\n\nconst Radio = React.forwardRef<React.ElementRef<typeof BaseRadio.Root>, RadioProps>(\n ({ variant = 'default', className, size, label, id, children, showIndicator, ...props }, ref) => {\n const defaultId = React.useId();\n const radioId = id || defaultId;\n\n const { root, indicator, dot, card, cardCircle } = radioVariants({ size });\n\n if (variant === 'card') {\n return (\n <BaseRadio.Root\n ref={ref}\n id={radioId}\n className={card({ className })}\n {...props}\n >\n {showIndicator && (\n <div className={cardCircle()}>\n <BaseRadio.Indicator className={indicator()}>\n <div className={dot()} />\n </BaseRadio.Indicator>\n </div>\n )}\n {children}\n </BaseRadio.Root>\n );\n }\n\n return (\n <div className=\"flex items-center gap-2 w-fit\">\n <BaseRadio.Root\n ref={ref}\n id={radioId}\n className={root({ className })}\n {...props}\n >\n <BaseRadio.Indicator className={indicator()}>\n <div className={dot()} />\n </BaseRadio.Indicator>\n </BaseRadio.Root>\n {children}\n {label && (\n <label\n htmlFor={radioId}\n className=\"text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n >\n {label}\n </label>\n )}\n </div>\n );\n }\n);\n\nRadio.displayName = 'Radio';\n\nexport { Radio };\n"
605
+ "content": "import * as React from 'react';\r\nimport { Radio as BaseRadio } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst radioVariants = tv({\r\n slots: {\r\n root: 'group flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:border-primary data-checked:border-primary',\r\n indicator: 'flex items-center justify-center',\r\n dot: 'rounded-full bg-primary',\r\n card: 'group/card relative flex flex-row items-start gap-4 cursor-pointer rounded-xl border border-border bg-card p-4 w-full shadow-sm outline-none transition-all hover:bg-accent/50 hover:text-accent-foreground data-[checked]:border-primary data-[checked]:bg-primary/5 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 overflow-hidden',\r\n cardCircle: 'flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all group-data-[checked]/card:border-primary group-data-[checked]/card:text-primary mt-0.5',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-4 w-4', cardCircle: 'h-4 w-4', dot: 'h-1.5 w-1.5' },\r\n md: { root: 'h-5 w-5', cardCircle: 'h-5 w-5', dot: 'h-2 w-2' },\r\n lg: { root: 'h-6 w-6', cardCircle: 'h-6 w-6', dot: 'h-2.5 w-2.5' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\nexport interface RadioProps\r\n extends Omit<BaseRadio.Root.Props, 'className'>,\r\n VariantProps<typeof radioVariants> {\r\n variant?: 'default' | 'card';\r\n label?: string;\r\n className?: string;\r\n /** Hiện/ẩn indicator (circle). Card variant mặc định ẩn. */\r\n showIndicator?: boolean;\r\n}\r\n\r\nconst Radio = React.forwardRef<React.ElementRef<typeof BaseRadio.Root>, RadioProps>(\r\n ({ variant = 'default', className, size, label, id, children, showIndicator, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const radioId = id || defaultId;\r\n\r\n const { root, indicator, dot, card, cardCircle } = radioVariants({ size });\r\n\r\n if (variant === 'card') {\r\n return (\r\n <BaseRadio.Root\r\n ref={ref}\r\n id={radioId}\r\n className={card({ className })}\r\n {...props}\r\n >\r\n {showIndicator && (\r\n <div className={cardCircle()}>\r\n <BaseRadio.Indicator className={indicator()}>\r\n <div className={dot()} />\r\n </BaseRadio.Indicator>\r\n </div>\r\n )}\r\n {children}\r\n </BaseRadio.Root>\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"flex items-center gap-2 w-fit\">\r\n <BaseRadio.Root\r\n ref={ref}\r\n id={radioId}\r\n className={root({ className })}\r\n {...props}\r\n >\r\n <BaseRadio.Indicator className={indicator()}>\r\n <div className={dot()} />\r\n </BaseRadio.Indicator>\r\n </BaseRadio.Root>\r\n {children}\r\n {label && (\r\n <label\r\n htmlFor={radioId}\r\n className=\"text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nRadio.displayName = 'Radio';\r\n\r\nexport { Radio };\r\n"
606
606
  }
607
607
  ]
608
608
  },
@@ -630,7 +630,7 @@
630
630
  "files": [
631
631
  {
632
632
  "path": "src/components/ui/rate/Rate.tsx",
633
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Star } from 'lucide-react';\r\n\r\nconst rateVariants = tv({\r\n slots: {\r\n root: 'inline-flex items-center gap-0.5',\r\n star: 'relative cursor-pointer transition-transform duration-100 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-sm',\r\n starIcon: 'transition-colors duration-150',\r\n },\r\n variants: {\r\n size: {\r\n sm: { star: 'w-4 h-4', starIcon: 'w-4 h-4' },\r\n md: { star: 'w-6 h-6', starIcon: 'w-6 h-6' },\r\n lg: { star: 'w-8 h-8', starIcon: 'w-8 h-8' },\r\n xl: { star: 'w-10 h-10', starIcon: 'w-10 h-10' },\r\n },\r\n readonly: {\r\n true: { star: 'cursor-default hover:scale-100' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Rate component */\r\nexport interface RateProps extends VariantProps<typeof rateVariants> {\r\n /** Controlled rating value */\r\n value?: number;\r\n /** Default rating value (uncontrolled) */\r\n defaultValue?: number;\r\n /** Callback fired when the rating changes */\r\n onChange?: (value: number) => void;\r\n /** Total number of stars */\r\n count?: number;\r\n /** Allow half-star precision */\r\n allowHalf?: boolean;\r\n /** Allow clicking the current value to reset to 0 */\r\n allowClear?: boolean;\r\n /** Display as read-only (no interaction) */\r\n readonly?: boolean;\r\n /** Disable the rating component */\r\n disabled?: boolean;\r\n /** Custom element to render instead of the default star icon */\r\n character?: React.ReactNode;\r\n /** Tailwind text color class for filled stars */\r\n activeColor?: string;\r\n /** Tailwind text color class for empty stars */\r\n inactiveColor?: string;\r\n className?: string;\r\n /** Accessible label for the rating group */\r\n 'aria-label'?: string;\r\n /**\r\n * Custom label for individual stars.\r\n * Receives the star index (1-based) and returns a string.\r\n * Defaults to \"1 star\", \"2 stars\", etc.\r\n */\r\n getStarLabel?: (index: number) => string;\r\n}\r\n\r\nconst Rate = React.forwardRef<HTMLDivElement, RateProps>(({\r\n value: controlledValue,\r\n defaultValue = 0,\r\n onChange,\r\n count = 5,\r\n allowHalf = false,\r\n allowClear = true,\r\n readonly = false,\r\n disabled = false,\r\n character,\r\n activeColor = 'text-amber-400',\r\n inactiveColor = 'text-muted-foreground/30',\r\n size,\r\n className,\r\n 'aria-label': ariaLabel,\r\n getStarLabel,\r\n}, ref) => {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState(defaultValue);\r\n const [hoverValue, setHoverValue] = React.useState<number | null>(null);\r\n\r\n const value = isControlled ? controlledValue! : internalValue;\r\n\r\n const handleChange = (newVal: number) => {\r\n if (readonly || disabled) return;\r\n const next = allowClear && newVal === value ? 0 : newVal;\r\n if (!isControlled) setInternalValue(next);\r\n onChange?.(next);\r\n };\r\n\r\n const defaultGetStarLabel = (index: number) =>\r\n index === 1 ? '1 star' : `${index} stars`;\r\n\r\n const resolveLabel = getStarLabel ?? defaultGetStarLabel;\r\n\r\n const getStarFraction = (starIndex: number, displayValue: number): number => {\r\n const full = starIndex + 1;\r\n const half = starIndex + 0.5;\r\n if (displayValue >= full) return 1;\r\n if (allowHalf && displayValue >= half) return 0.5;\r\n return 0;\r\n };\r\n\r\n const slots = rateVariants({ size, readonly: readonly || disabled });\r\n const display = hoverValue ?? value;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n className={slots.root({ className })}\r\n role=\"radiogroup\"\r\n aria-label={ariaLabel || 'Rating'}\r\n >\r\n {Array.from({ length: count }, (_, i) => {\r\n const fraction = getStarFraction(i, display);\r\n const full = i + 1;\r\n const half = i + 0.5;\r\n\r\n const renderStar = (frac: number) => {\r\n if (character) {\r\n if (frac === 0.5) {\r\n return (\r\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\r\n <span className={`absolute inset-0 overflow-hidden ${inactiveColor}`}>{character}</span>\r\n <span className=\"absolute inset-0 overflow-hidden w-1/2\" style={{ color: 'inherit' }}>\r\n <span className={activeColor}>{character}</span>\r\n </span>\r\n </span>\r\n );\r\n }\r\n return <span className={frac === 1 ? activeColor : inactiveColor}>{character}</span>;\r\n }\r\n\r\n if (frac === 0.5) {\r\n return (\r\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\r\n <Star className={`${slots.starIcon()} ${inactiveColor}`} fill=\"currentColor\" />\r\n <span\r\n className=\"absolute inset-0 overflow-hidden\"\r\n style={{ width: '50%' }}\r\n aria-hidden=\"true\"\r\n >\r\n <Star className={`${slots.starIcon()} ${activeColor}`} fill=\"currentColor\" />\r\n </span>\r\n </span>\r\n );\r\n }\r\n\r\n return (\r\n <Star\r\n className={`${slots.starIcon()} ${frac === 1 ? activeColor : inactiveColor}`}\r\n fill=\"currentColor\"\r\n />\r\n );\r\n };\r\n\r\n return (\r\n <button\r\n key={i}\r\n type=\"button\"\r\n role=\"radio\"\r\n aria-checked={full <= value}\r\n aria-label={resolveLabel(full)}\r\n disabled={disabled}\r\n className={slots.star()}\r\n onMouseMove={(e) => {\r\n if (readonly || disabled) return;\r\n if (allowHalf) {\r\n const rect = e.currentTarget.getBoundingClientRect();\r\n const isLeft = e.clientX - rect.left < rect.width / 2;\r\n setHoverValue(isLeft ? half : full);\r\n } else {\r\n setHoverValue(full);\r\n }\r\n }}\r\n onMouseLeave={() => setHoverValue(null)}\r\n onClick={(e) => {\r\n if (readonly || disabled) return;\r\n if (allowHalf) {\r\n const rect = e.currentTarget.getBoundingClientRect();\r\n const isLeft = e.clientX - rect.left < rect.width / 2;\r\n handleChange(isLeft ? half : full);\r\n } else {\r\n handleChange(full);\r\n }\r\n }}\r\n onKeyDown={(e) => {\r\n if (readonly || disabled) return;\r\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\r\n e.preventDefault();\r\n handleChange(Math.min(count, value + (allowHalf ? 0.5 : 1)));\r\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\r\n e.preventDefault();\r\n handleChange(Math.max(0, value - (allowHalf ? 0.5 : 1)));\r\n }\r\n }}\r\n >\r\n {renderStar(fraction)}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n );\r\n});\r\n\r\nRate.displayName = 'Rate';\r\n\r\nexport { Rate };\r\n"
633
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Star } from 'lucide-react';\n\nconst rateVariants = tv({\n slots: {\n root: 'inline-flex items-center gap-0.5',\n star: 'relative cursor-pointer transition-transform duration-100 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-sm',\n starIcon: 'transition-colors duration-150',\n },\n variants: {\n size: {\n sm: { star: 'w-4 h-4', starIcon: 'w-4 h-4' },\n md: { star: 'w-6 h-6', starIcon: 'w-6 h-6' },\n lg: { star: 'w-8 h-8', starIcon: 'w-8 h-8' },\n xl: { star: 'w-10 h-10', starIcon: 'w-10 h-10' },\n },\n readonly: {\n true: { star: 'cursor-default hover:scale-100' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\n/** Props for the Rate component */\nexport interface RateProps extends VariantProps<typeof rateVariants> {\n /** Controlled rating value */\n value?: number;\n /** Default rating value (uncontrolled) */\n defaultValue?: number;\n /** Callback fired when the rating changes */\n onChange?: (value: number) => void;\n /** Total number of stars */\n count?: number;\n /** Allow half-star precision */\n allowHalf?: boolean;\n /** Allow clicking the current value to reset to 0 */\n allowClear?: boolean;\n /** Display as read-only (no interaction) */\n readonly?: boolean;\n /** Disable the rating component */\n disabled?: boolean;\n /** Custom element to render instead of the default star icon */\n character?: React.ReactNode;\n /** Tailwind text color class for filled stars */\n activeColor?: string;\n /** Tailwind text color class for empty stars */\n inactiveColor?: string;\n className?: string;\n /** Accessible label for the rating group */\n 'aria-label'?: string;\n /**\n * Custom label for individual stars.\n * Receives the star index (1-based) and returns a string.\n * Defaults to \"1 star\", \"2 stars\", etc.\n */\n getStarLabel?: (index: number) => string;\n}\n\nconst Rate = React.forwardRef<HTMLDivElement, RateProps>(({\n value: controlledValue,\n defaultValue = 0,\n onChange,\n count = 5,\n allowHalf = false,\n allowClear = true,\n readonly = false,\n disabled = false,\n character,\n activeColor = 'text-amber-400',\n inactiveColor = 'text-muted-foreground/30',\n size,\n className,\n 'aria-label': ariaLabel,\n getStarLabel,\n}, ref) => {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const [hoverValue, setHoverValue] = React.useState<number | null>(null);\n\n const value = isControlled ? controlledValue! : internalValue;\n\n const handleChange = (newVal: number) => {\n if (readonly || disabled) return;\n const next = allowClear && newVal === value ? 0 : newVal;\n if (!isControlled) setInternalValue(next);\n onChange?.(next);\n };\n\n const defaultGetStarLabel = (index: number) =>\n index === 1 ? '1 star' : `${index} stars`;\n\n const resolveLabel = getStarLabel ?? defaultGetStarLabel;\n\n const getStarFraction = (starIndex: number, displayValue: number): number => {\n const full = starIndex + 1;\n const half = starIndex + 0.5;\n if (displayValue >= full) return 1;\n if (allowHalf && displayValue >= half) return 0.5;\n return 0;\n };\n\n const slots = rateVariants({ size, readonly: readonly || disabled });\n const display = hoverValue ?? value;\n\n return (\n <div\n ref={ref}\n className={slots.root({ className })}\n role=\"radiogroup\"\n aria-label={ariaLabel || 'Rating'}\n >\n {Array.from({ length: count }, (_, i) => {\n const fraction = getStarFraction(i, display);\n const full = i + 1;\n const half = i + 0.5;\n\n const renderStar = (frac: number) => {\n if (character) {\n if (frac === 0.5) {\n return (\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\n <span className={`absolute inset-0 overflow-hidden ${inactiveColor}`}>{character}</span>\n <span className=\"absolute inset-0 overflow-hidden w-1/2\" style={{ color: 'inherit' }}>\n <span className={activeColor}>{character}</span>\n </span>\n </span>\n );\n }\n return <span className={frac === 1 ? activeColor : inactiveColor}>{character}</span>;\n }\n\n if (frac === 0.5) {\n return (\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\n <Star className={`${slots.starIcon()} ${inactiveColor}`} fill=\"currentColor\" />\n <span\n className=\"absolute inset-0 overflow-hidden\"\n style={{ width: '50%' }}\n aria-hidden=\"true\"\n >\n <Star className={`${slots.starIcon()} ${activeColor}`} fill=\"currentColor\" />\n </span>\n </span>\n );\n }\n\n return (\n <Star\n className={`${slots.starIcon()} ${frac === 1 ? activeColor : inactiveColor}`}\n fill=\"currentColor\"\n />\n );\n };\n\n return (\n <button\n key={i}\n type=\"button\"\n role=\"radio\"\n aria-checked={full <= value}\n aria-label={resolveLabel(full)}\n disabled={disabled}\n className={slots.star()}\n onMouseMove={(e) => {\n if (readonly || disabled) return;\n if (allowHalf) {\n const rect = e.currentTarget.getBoundingClientRect();\n const isLeft = e.clientX - rect.left < rect.width / 2;\n setHoverValue(isLeft ? half : full);\n } else {\n setHoverValue(full);\n }\n }}\n onMouseLeave={() => setHoverValue(null)}\n onClick={(e) => {\n if (readonly || disabled) return;\n if (allowHalf) {\n const rect = e.currentTarget.getBoundingClientRect();\n const isLeft = e.clientX - rect.left < rect.width / 2;\n handleChange(isLeft ? half : full);\n } else {\n handleChange(full);\n }\n }}\n onKeyDown={(e) => {\n if (readonly || disabled) return;\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n handleChange(Math.min(count, value + (allowHalf ? 0.5 : 1)));\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n handleChange(Math.max(0, value - (allowHalf ? 0.5 : 1)));\n }\n }}\n >\n {renderStar(fraction)}\n </button>\n );\n })}\n </div>\n );\n});\n\nRate.displayName = 'Rate';\n\nexport { Rate };\n"
634
634
  }
635
635
  ]
636
636
  },
@@ -644,7 +644,7 @@
644
644
  "files": [
645
645
  {
646
646
  "path": "src/components/ui/resizable/Resizable.tsx",
647
- "content": "import * as React from 'react';\nimport { Group, Panel, Separator } from 'react-resizable-panels';\nimport type { GroupProps, PanelProps, SeparatorProps } from 'react-resizable-panels';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Direction Context ────────────────────────────────────────\n\ninterface ResizableContextValue {\n direction: 'horizontal' | 'vertical';\n}\n\nconst ResizableContext = React.createContext<ResizableContextValue>({\n direction: 'horizontal',\n});\n\n// ─── Grip Dots ────────────────────────────────────────────────\n\nfunction GripDots({ isVerticalHandle, className }: { isVerticalHandle: boolean; className?: string }) {\n if (isVerticalHandle) {\n return (\n <div className={cn('flex flex-col gap-[3px]', className)}>\n {[0, 1, 2].map((r) => (\n <div key={r} className=\"flex gap-[3px]\">\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n </div>\n ))}\n </div>\n );\n }\n return (\n <div className={cn('flex gap-[3px]', className)}>\n {[0, 1, 2].map((c) => (\n <div key={c} className=\"flex flex-col gap-[3px]\">\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n </div>\n ))}\n </div>\n );\n}\n\n// ─── Handle Variants ─────────────────────────────────────────\n\nconst handleVariants = tv({\n base: [\n 'relative flex items-center justify-center shrink-0',\n 'select-none touch-none outline-none',\n 'group/handle',\n ].join(' '),\n variants: {\n variant: {\n line: '',\n bar: '',\n handle: '',\n ghost: '',\n },\n },\n defaultVariants: { variant: 'line' },\n});\n\n// ─── Types ────────────────────────────────────────────────────\n\nexport type ResizablePanelGroupProps = Omit<GroupProps, 'orientation'> & {\n direction?: 'horizontal' | 'vertical';\n};\n\nexport type ResizablePanelProps = PanelProps;\n\nexport type ResizableHandleProps = Omit<SeparatorProps, 'className'> &\n VariantProps<typeof handleVariants> & {\n withGrip?: boolean;\n className?: string;\n };\n\n// ─── ResizablePanelGroup ──────────────────────────────────────\n\nfunction ResizablePanelGroup({\n direction = 'horizontal',\n className,\n children,\n ...props\n}: ResizablePanelGroupProps) {\n return (\n <ResizableContext.Provider value={{ direction }}>\n <Group\n orientation={direction}\n className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}\n {...props}\n >\n {children}\n </Group>\n </ResizableContext.Provider>\n );\n}\nResizablePanelGroup.displayName = 'ResizablePanelGroup';\n\n// ─── ResizablePanel ───────────────────────────────────────────\n\nfunction ResizablePanel({ className, ...props }: ResizablePanelProps) {\n return <Panel className={cn('overflow-auto', className)} {...props} />;\n}\nResizablePanel.displayName = 'ResizablePanel';\n\n// ─── ResizableHandle ─────────────────────────────────────────\n\nfunction ResizableHandle({\n variant = 'line',\n withGrip = false,\n className,\n disabled,\n ...props\n}: ResizableHandleProps) {\n const { direction } = React.useContext(ResizableContext);\n const [isDragging, setIsDragging] = React.useState(false);\n\n const isVerticalHandle = direction === 'horizontal';\n\n // Shared thin-line indicator position styles\n const linePos = isVerticalHandle\n ? 'absolute inset-y-0 left-1/2 -translate-x-1/2 w-px'\n : 'absolute inset-x-0 top-1/2 -translate-y-1/2 h-px';\n\n return (\n <Separator\n disabled={disabled}\n onPointerDown={() => setIsDragging(true)}\n onPointerUp={() => setIsDragging(false)}\n onPointerLeave={() => setIsDragging(false)}\n className={cn(\n handleVariants({ variant }),\n\n // ── orientation & cursor ──────────────────────────────\n isVerticalHandle\n ? 'h-full cursor-col-resize'\n : 'w-full cursor-row-resize',\n\n // ── per-variant zone width ────────────────────────────\n // line: 4 px transparent zone\n variant === 'line' && (isVerticalHandle ? 'w-1' : 'h-1'),\n\n // bar: 8 px transparent zone — easy to grab anywhere along sidebar edge\n variant === 'bar' && (isVerticalHandle ? 'w-2' : 'h-2'),\n\n // handle: 12 px zone with visible pill on hover\n variant === 'handle' && (isVerticalHandle ? 'w-3' : 'h-3'),\n\n // ghost: 8 px invisible zone\n variant === 'ghost' && (isVerticalHandle ? 'w-2' : 'h-2'),\n\n // all non-ghost: transparent background\n variant !== 'ghost' && 'bg-transparent',\n\n // drag shadow — secondary tint\n isDragging && variant !== 'ghost' && 'shadow-[0_0_0_1px] shadow-secondary/30',\n\n className,\n )}\n {...props}\n >\n {/* ── LINE: thin border-colored line ────────────────── */}\n {variant === 'line' && (\n <div\n className={cn(\n linePos,\n 'transition-colors duration-150 rounded-full',\n isDragging\n ? 'bg-secondary/70'\n : 'bg-border group-hover/handle:bg-secondary/50',\n )}\n />\n )}\n\n {/* ── BAR: full-height thin line + centered grip pill ── */}\n {variant === 'bar' && (\n <>\n {/* full-height thin line */}\n <div\n className={cn(\n linePos,\n 'transition-colors duration-150',\n isDragging\n ? 'bg-secondary/60'\n : 'bg-border/70 group-hover/handle:bg-secondary/40',\n )}\n />\n {/* centered grip pill */}\n <div\n className={cn(\n 'relative z-10 rounded-full transition-colors duration-150',\n isVerticalHandle ? 'w-[3px] h-6' : 'h-[3px] w-6',\n isDragging\n ? 'bg-secondary/70'\n : 'bg-muted-foreground/25 group-hover/handle:bg-secondary/50',\n )}\n />\n </>\n )}\n\n {/* ── HANDLE: bordered pill on hover ────────────────── */}\n {variant === 'handle' && (\n <div\n className={cn(\n 'z-10 flex items-center justify-center rounded-sm border border-border bg-background',\n 'opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150',\n isDragging && 'opacity-100 border-secondary/60',\n 'shadow-sm',\n isVerticalHandle ? 'w-5 h-8' : 'h-5 w-8',\n )}\n >\n <GripDots\n isVerticalHandle={isVerticalHandle}\n className={isDragging ? 'text-secondary/70' : 'text-muted-foreground/70'}\n />\n </div>\n )}\n\n {/* ── withGrip: overlay dots on line / ghost ─────────── */}\n {withGrip && variant !== 'handle' && variant !== 'bar' && (\n <div className=\"z-10 opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150\">\n <GripDots\n isVerticalHandle={isVerticalHandle}\n className=\"text-muted-foreground/50 group-hover/handle:text-muted-foreground/70 transition-colors\"\n />\n </div>\n )}\n </Separator>\n );\n}\nResizableHandle.displayName = 'ResizableHandle';\n\n// ─── Exports ─────────────────────────────────────────────────\n\nexport {\n ResizablePanelGroup,\n ResizablePanel,\n ResizableHandle,\n handleVariants,\n};\n"
647
+ "content": "import * as React from 'react';\r\nimport { Group, Panel, Separator } from 'react-resizable-panels';\r\nimport type { GroupProps, PanelProps, SeparatorProps } from 'react-resizable-panels';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Direction Context ────────────────────────────────────────\r\n\r\ninterface ResizableContextValue {\r\n direction: 'horizontal' | 'vertical';\r\n}\r\n\r\nconst ResizableContext = React.createContext<ResizableContextValue>({\r\n direction: 'horizontal',\r\n});\r\n\r\n// ─── Grip Dots ────────────────────────────────────────────────\r\n\r\nfunction GripDots({ isVerticalHandle, className }: { isVerticalHandle: boolean; className?: string }) {\r\n if (isVerticalHandle) {\r\n return (\r\n <div className={cn('flex flex-col gap-[3px]', className)}>\r\n {[0, 1, 2].map((r) => (\r\n <div key={r} className=\"flex gap-[3px]\">\r\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\r\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\r\n </div>\r\n ))}\r\n </div>\r\n );\r\n }\r\n return (\r\n <div className={cn('flex gap-[3px]', className)}>\r\n {[0, 1, 2].map((c) => (\r\n <div key={c} className=\"flex flex-col gap-[3px]\">\r\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\r\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\r\n </div>\r\n ))}\r\n </div>\r\n );\r\n}\r\n\r\n// ─── Handle Variants ─────────────────────────────────────────\r\n\r\nconst handleVariants = tv({\r\n base: [\r\n 'relative flex items-center justify-center shrink-0',\r\n 'select-none touch-none outline-none',\r\n 'group/handle',\r\n ].join(' '),\r\n variants: {\r\n variant: {\r\n line: '',\r\n bar: '',\r\n handle: '',\r\n ghost: '',\r\n },\r\n },\r\n defaultVariants: { variant: 'line' },\r\n});\r\n\r\n// ─── Types ────────────────────────────────────────────────────\r\n\r\nexport type ResizablePanelGroupProps = Omit<GroupProps, 'orientation'> & {\r\n direction?: 'horizontal' | 'vertical';\r\n};\r\n\r\nexport type ResizablePanelProps = PanelProps;\r\n\r\nexport type ResizableHandleProps = Omit<SeparatorProps, 'className'> &\r\n VariantProps<typeof handleVariants> & {\r\n withGrip?: boolean;\r\n className?: string;\r\n };\r\n\r\n// ─── ResizablePanelGroup ──────────────────────────────────────\r\n\r\nfunction ResizablePanelGroup({\r\n direction = 'horizontal',\r\n className,\r\n children,\r\n ...props\r\n}: ResizablePanelGroupProps) {\r\n return (\r\n <ResizableContext.Provider value={{ direction }}>\r\n <Group\r\n orientation={direction}\r\n className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}\r\n {...props}\r\n >\r\n {children}\r\n </Group>\r\n </ResizableContext.Provider>\r\n );\r\n}\r\nResizablePanelGroup.displayName = 'ResizablePanelGroup';\r\n\r\n// ─── ResizablePanel ───────────────────────────────────────────\r\n\r\nfunction ResizablePanel({ className, ...props }: ResizablePanelProps) {\r\n return <Panel className={cn('overflow-auto', className)} {...props} />;\r\n}\r\nResizablePanel.displayName = 'ResizablePanel';\r\n\r\n// ─── ResizableHandle ─────────────────────────────────────────\r\n\r\nfunction ResizableHandle({\r\n variant = 'line',\r\n withGrip = false,\r\n className,\r\n disabled,\r\n ...props\r\n}: ResizableHandleProps) {\r\n const { direction } = React.useContext(ResizableContext);\r\n const [isDragging, setIsDragging] = React.useState(false);\r\n\r\n const isVerticalHandle = direction === 'horizontal';\r\n\r\n // Shared thin-line indicator position styles\r\n const linePos = isVerticalHandle\r\n ? 'absolute inset-y-0 left-1/2 -translate-x-1/2 w-px'\r\n : 'absolute inset-x-0 top-1/2 -translate-y-1/2 h-px';\r\n\r\n return (\r\n <Separator\r\n disabled={disabled}\r\n onPointerDown={() => setIsDragging(true)}\r\n onPointerUp={() => setIsDragging(false)}\r\n onPointerLeave={() => setIsDragging(false)}\r\n className={cn(\r\n handleVariants({ variant }),\r\n\r\n // ── orientation & cursor ──────────────────────────────\r\n isVerticalHandle\r\n ? 'h-full cursor-col-resize'\r\n : 'w-full cursor-row-resize',\r\n\r\n // ── per-variant zone width ────────────────────────────\r\n // line: 4 px transparent zone\r\n variant === 'line' && (isVerticalHandle ? 'w-1' : 'h-1'),\r\n\r\n // bar: 8 px transparent zone — easy to grab anywhere along sidebar edge\r\n variant === 'bar' && (isVerticalHandle ? 'w-2' : 'h-2'),\r\n\r\n // handle: 12 px zone with visible pill on hover\r\n variant === 'handle' && (isVerticalHandle ? 'w-3' : 'h-3'),\r\n\r\n // ghost: 8 px invisible zone\r\n variant === 'ghost' && (isVerticalHandle ? 'w-2' : 'h-2'),\r\n\r\n // all non-ghost: transparent background\r\n variant !== 'ghost' && 'bg-transparent',\r\n\r\n // drag shadow — secondary tint\r\n isDragging && variant !== 'ghost' && 'shadow-[0_0_0_1px] shadow-secondary/30',\r\n\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {/* ── LINE: thin border-colored line ────────────────── */}\r\n {variant === 'line' && (\r\n <div\r\n className={cn(\r\n linePos,\r\n 'transition-colors duration-150 rounded-full',\r\n isDragging\r\n ? 'bg-secondary/70'\r\n : 'bg-border group-hover/handle:bg-secondary/50',\r\n )}\r\n />\r\n )}\r\n\r\n {/* ── BAR: full-height thin line + centered grip pill ── */}\r\n {variant === 'bar' && (\r\n <>\r\n {/* full-height thin line */}\r\n <div\r\n className={cn(\r\n linePos,\r\n 'transition-colors duration-150',\r\n isDragging\r\n ? 'bg-secondary/60'\r\n : 'bg-border/70 group-hover/handle:bg-secondary/40',\r\n )}\r\n />\r\n {/* centered grip pill */}\r\n <div\r\n className={cn(\r\n 'relative z-10 rounded-full transition-colors duration-150',\r\n isVerticalHandle ? 'w-[3px] h-6' : 'h-[3px] w-6',\r\n isDragging\r\n ? 'bg-secondary/70'\r\n : 'bg-muted-foreground/25 group-hover/handle:bg-secondary/50',\r\n )}\r\n />\r\n </>\r\n )}\r\n\r\n {/* ── HANDLE: bordered pill on hover ────────────────── */}\r\n {variant === 'handle' && (\r\n <div\r\n className={cn(\r\n 'z-10 flex items-center justify-center rounded-sm border border-border bg-background',\r\n 'opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150',\r\n isDragging && 'opacity-100 border-secondary/60',\r\n 'shadow-sm',\r\n isVerticalHandle ? 'w-5 h-8' : 'h-5 w-8',\r\n )}\r\n >\r\n <GripDots\r\n isVerticalHandle={isVerticalHandle}\r\n className={isDragging ? 'text-secondary/70' : 'text-muted-foreground/70'}\r\n />\r\n </div>\r\n )}\r\n\r\n {/* ── withGrip: overlay dots on line / ghost ─────────── */}\r\n {withGrip && variant !== 'handle' && variant !== 'bar' && (\r\n <div className=\"z-10 opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150\">\r\n <GripDots\r\n isVerticalHandle={isVerticalHandle}\r\n className=\"text-muted-foreground/50 group-hover/handle:text-muted-foreground/70 transition-colors\"\r\n />\r\n </div>\r\n )}\r\n </Separator>\r\n );\r\n}\r\nResizableHandle.displayName = 'ResizableHandle';\r\n\r\n// ─── Exports ─────────────────────────────────────────────────\r\n\r\nexport {\r\n ResizablePanelGroup,\r\n ResizablePanel,\r\n ResizableHandle,\r\n handleVariants,\r\n};\r\n"
648
648
  }
649
649
  ]
650
650
  },
@@ -657,7 +657,7 @@
657
657
  "files": [
658
658
  {
659
659
  "path": "src/components/ui/scroll-area/ScrollArea.tsx",
660
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst scrollAreaVariants = tv({\r\n slots: {\r\n root: 'relative overflow-hidden',\r\n viewport: 'h-full w-full rounded-[inherit] [&>div]:!block',\r\n scrollbar: 'flex touch-none select-none transition-colors',\r\n thumb: 'relative rounded-full bg-border hover:bg-muted-foreground/30 transition-colors',\r\n },\r\n variants: {\r\n size: {\r\n sm: { scrollbar: '', thumb: '' },\r\n md: {},\r\n lg: {},\r\n },\r\n orientation: {\r\n vertical: {\r\n scrollbar: 'h-full w-2.5 border-l border-l-transparent p-[1px]',\r\n thumb: 'flex-1',\r\n },\r\n horizontal: {\r\n scrollbar: 'h-2.5 flex-col border-t border-t-transparent p-[1px]',\r\n thumb: '',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n orientation: 'vertical',\r\n },\r\n});\r\n\r\n/** Props for the ScrollArea component */\r\nexport interface ScrollAreaProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n Omit<VariantProps<typeof scrollAreaVariants>, 'orientation'> {\r\n /** Scroll direction: vertical, horizontal, or both */\r\n orientation?: 'vertical' | 'horizontal' | 'both';\r\n /** Accessible label for the scrollable region */\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(\r\n ({ className, children, orientation = 'vertical', size, 'aria-label': ariaLabel, ...props }, ref) => {\r\n const { root, viewport } = scrollAreaVariants({ size });\r\n\r\n const overflowClass =\r\n orientation === 'both'\r\n ? 'overflow-auto'\r\n : orientation === 'horizontal'\r\n ? 'overflow-x-auto overflow-y-hidden'\r\n : 'overflow-y-auto overflow-x-hidden';\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n className={root({ className })}\r\n role=\"region\"\r\n aria-label={ariaLabel}\r\n {...props}\r\n >\r\n <div\r\n className={viewport({\r\n className: `${overflowClass} scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent`,\r\n })}\r\n >\r\n {children}\r\n </div>\r\n </div>\r\n );\r\n }\r\n);\r\nScrollArea.displayName = 'ScrollArea';\r\n\r\n/** Props for the ScrollBar component */\r\nexport interface ScrollBarProps extends React.HTMLAttributes<HTMLDivElement> {\r\n /** Scrollbar axis direction */\r\n orientation?: 'vertical' | 'horizontal';\r\n}\r\n\r\nconst ScrollBar = React.forwardRef<HTMLDivElement, ScrollBarProps>(\r\n ({ className, orientation = 'vertical', ...props }, ref) => {\r\n const { scrollbar, thumb } = scrollAreaVariants({ orientation });\r\n return (\r\n <div ref={ref} className={scrollbar({ className })} {...props}>\r\n <div className={thumb()} />\r\n </div>\r\n );\r\n }\r\n);\r\nScrollBar.displayName = 'ScrollBar';\r\n\r\nexport { ScrollArea, ScrollBar, scrollAreaVariants };\r\n"
660
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst scrollAreaVariants = tv({\n slots: {\n root: 'relative overflow-hidden',\n viewport: 'h-full w-full rounded-[inherit] [&>div]:!block',\n scrollbar: 'flex touch-none select-none transition-colors',\n thumb: 'relative rounded-full bg-border hover:bg-muted-foreground/30 transition-colors',\n },\n variants: {\n size: {\n sm: { scrollbar: '', thumb: '' },\n md: {},\n lg: {},\n },\n orientation: {\n vertical: {\n scrollbar: 'h-full w-2.5 border-l border-l-transparent p-[1px]',\n thumb: 'flex-1',\n },\n horizontal: {\n scrollbar: 'h-2.5 flex-col border-t border-t-transparent p-[1px]',\n thumb: '',\n },\n },\n },\n defaultVariants: {\n size: 'md',\n orientation: 'vertical',\n },\n});\n\n/** Props for the ScrollArea component */\nexport interface ScrollAreaProps\n extends React.HTMLAttributes<HTMLDivElement>,\n Omit<VariantProps<typeof scrollAreaVariants>, 'orientation'> {\n /** Scroll direction: vertical, horizontal, or both */\n orientation?: 'vertical' | 'horizontal' | 'both';\n /** Accessible label for the scrollable region */\n 'aria-label'?: string;\n}\n\nconst ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(\n ({ className, children, orientation = 'vertical', size, 'aria-label': ariaLabel, ...props }, ref) => {\n const { root, viewport } = scrollAreaVariants({ size });\n\n const overflowClass =\n orientation === 'both'\n ? 'overflow-auto'\n : orientation === 'horizontal'\n ? 'overflow-x-auto overflow-y-hidden'\n : 'overflow-y-auto overflow-x-hidden';\n\n return (\n <div\n ref={ref}\n className={root({ className })}\n role=\"region\"\n aria-label={ariaLabel}\n {...props}\n >\n <div\n className={viewport({\n className: `${overflowClass} scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent`,\n })}\n >\n {children}\n </div>\n </div>\n );\n }\n);\nScrollArea.displayName = 'ScrollArea';\n\n/** Props for the ScrollBar component */\nexport interface ScrollBarProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Scrollbar axis direction */\n orientation?: 'vertical' | 'horizontal';\n}\n\nconst ScrollBar = React.forwardRef<HTMLDivElement, ScrollBarProps>(\n ({ className, orientation = 'vertical', ...props }, ref) => {\n const { scrollbar, thumb } = scrollAreaVariants({ orientation });\n return (\n <div ref={ref} className={scrollbar({ className })} {...props}>\n <div className={thumb()} />\n </div>\n );\n }\n);\nScrollBar.displayName = 'ScrollBar';\n\nexport { ScrollArea, ScrollBar, scrollAreaVariants };\n"
661
661
  }
662
662
  ]
663
663
  },
@@ -720,11 +720,11 @@
720
720
  "files": [
721
721
  {
722
722
  "path": "src/components/ui/sidebar/Sidebar.tsx",
723
- "content": "// Sidebar — barrel re-export\r\n// Split into: SidebarContext, SidebarLayout, SidebarMenu, SidebarUserMenu\r\n\r\nexport { useSidebar, SidebarProvider } from './SidebarContext';\r\nexport type { SidebarProviderProps } from './SidebarContext';\r\n\r\nexport {\r\n SidebarTrigger,\r\n Sidebar,\r\n SidebarRail,\r\n SidebarInset,\r\n SidebarHeader,\r\n SidebarFooter,\r\n SidebarContent,\r\n SidebarSeparator,\r\n} from './SidebarLayout';\r\nexport type { SidebarProps } from './SidebarLayout';\r\n\r\nexport {\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarNavLink,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSub,\r\n SidebarMenuSubItem,\r\n SidebarMenuBadge,\r\n SidebarMenuSkeleton,\r\n} from './SidebarMenu';\r\nexport type { SidebarMenuButtonProps, SidebarNavLinkProps, SidebarMenuCollapsibleProps } from './SidebarMenu';\r\n\r\nexport { UserMenuPopover, UserMenuItem } from './SidebarUserMenu';\r\n"
723
+ "content": "// Sidebar — barrel re-export\n// Split into: SidebarContext, SidebarLayout, SidebarMenu, SidebarUserMenu\n\nexport { useSidebar, SidebarProvider } from './SidebarContext';\nexport type { SidebarProviderProps } from './SidebarContext';\n\nexport {\n SidebarTrigger,\n Sidebar,\n SidebarRail,\n SidebarInset,\n SidebarHeader,\n SidebarFooter,\n SidebarContent,\n SidebarSeparator,\n} from './SidebarLayout';\nexport type { SidebarProps } from './SidebarLayout';\n\nexport {\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n SidebarNavLink,\n SidebarMenuCollapsible,\n SidebarMenuSub,\n SidebarMenuSubItem,\n SidebarMenuBadge,\n SidebarMenuSkeleton,\n} from './SidebarMenu';\nexport type { SidebarMenuButtonProps, SidebarNavLinkProps, SidebarMenuCollapsibleProps } from './SidebarMenu';\n\nexport { UserMenuPopover, UserMenuItem } from './SidebarUserMenu';\n"
724
724
  },
725
725
  {
726
726
  "path": "src/components/ui/sidebar/SidebarContext.tsx",
727
- "content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Constants ────────────────────────────────────────────────────────────────\r\n\r\nexport const SIDEBAR_WIDTH_DEFAULT = 256; // px\r\nexport const SIDEBAR_WIDTH_MIN = 160; // px\r\nexport const SIDEBAR_WIDTH_MAX = 480; // px\r\nexport const SIDEBAR_WIDTH_ICON = '4rem';\r\nexport const MOBILE_BREAKPOINT = 768;\r\n\r\n// ─── Context ──────────────────────────────────────────────────────────────────\r\n\r\nexport type SidebarState = 'expanded' | 'collapsed';\r\n\r\nexport interface SidebarContextValue {\r\n state: SidebarState;\r\n open: boolean;\r\n setOpen: (open: boolean) => void;\r\n toggleSidebar: () => void;\r\n isMobile: boolean;\r\n openMobile: boolean;\r\n setOpenMobile: (open: boolean) => void;\r\n sidebarWidth: number;\r\n setSidebarWidth: (w: number) => void;\r\n}\r\n\r\nexport const SidebarContext = React.createContext<SidebarContextValue | null>(null);\r\n\r\nexport function useSidebar() {\r\n const ctx = React.useContext(SidebarContext);\r\n if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');\r\n return ctx;\r\n}\r\n\r\n// ─── Provider ─────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the SidebarProvider that manages sidebar state (open/collapsed, mobile, width) */\r\nexport interface SidebarProviderProps {\r\n children: React.ReactNode;\r\n /** Initial open state for uncontrolled usage (default: true) */\r\n defaultOpen?: boolean;\r\n /** Controlled open state */\r\n open?: boolean;\r\n /** Callback fired when the sidebar opens or closes */\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n style?: React.CSSProperties;\r\n}\r\n\r\nexport const SidebarProvider = React.forwardRef<HTMLDivElement, SidebarProviderProps>(\r\n ({ children, defaultOpen = true, open: controlledOpen, onOpenChange, className, style }, ref) => {\r\n const [isMobile, setIsMobile] = React.useState(false);\r\n const [openMobile, setOpenMobile] = React.useState(false);\r\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\r\n const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT);\r\n\r\n const isControlled = controlledOpen !== undefined;\r\n const open = isControlled ? controlledOpen! : internalOpen;\r\n\r\n React.useEffect(() => {\r\n const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n check();\r\n window.addEventListener('resize', check);\r\n return () => window.removeEventListener('resize', check);\r\n }, []);\r\n\r\n const setOpen = React.useCallback(\r\n (val: boolean) => {\r\n if (!isControlled) setInternalOpen(val);\r\n onOpenChange?.(val);\r\n },\r\n [isControlled, onOpenChange]\r\n );\r\n\r\n const toggleSidebar = React.useCallback(() => {\r\n if (isMobile) setOpenMobile((v) => !v);\r\n else setOpen(!open);\r\n }, [isMobile, open, setOpen]);\r\n\r\n React.useEffect(() => {\r\n const onKey = (e: KeyboardEvent) => {\r\n if ((e.metaKey || e.ctrlKey) && e.key === 'b') {\r\n e.preventDefault();\r\n toggleSidebar();\r\n }\r\n };\r\n window.addEventListener('keydown', onKey);\r\n return () => window.removeEventListener('keydown', onKey);\r\n }, [toggleSidebar]);\r\n\r\n const state: SidebarState = open ? 'expanded' : 'collapsed';\r\n\r\n return (\r\n <SidebarContext.Provider\r\n value={{ state, open, setOpen, toggleSidebar, isMobile, openMobile, setOpenMobile, sidebarWidth, setSidebarWidth }}\r\n >\r\n <div\r\n ref={ref}\r\n data-sidebar-state={state}\r\n style={\r\n {\r\n '--sidebar-width': `${sidebarWidth}px`,\r\n '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\r\n ...style,\r\n } as React.CSSProperties\r\n }\r\n className={cn('group/sidebar-wrapper flex min-h-screen w-full has-data-[variant=inset]:bg-muted/30', className)}\r\n >\r\n {children}\r\n </div>\r\n </SidebarContext.Provider>\r\n );\r\n }\r\n);\r\nSidebarProvider.displayName = 'SidebarProvider';\r\n"
727
+ "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Constants ────────────────────────────────────────────────────────────────\n\nexport const SIDEBAR_WIDTH_DEFAULT = 256; // px\nexport const SIDEBAR_WIDTH_MIN = 160; // px\nexport const SIDEBAR_WIDTH_MAX = 480; // px\nexport const SIDEBAR_WIDTH_ICON = '4rem';\nexport const MOBILE_BREAKPOINT = 768;\n\n// ─── Context ──────────────────────────────────────────────────────────────────\n\nexport type SidebarState = 'expanded' | 'collapsed';\n\nexport interface SidebarContextValue {\n state: SidebarState;\n open: boolean;\n setOpen: (open: boolean) => void;\n toggleSidebar: () => void;\n isMobile: boolean;\n openMobile: boolean;\n setOpenMobile: (open: boolean) => void;\n sidebarWidth: number;\n setSidebarWidth: (w: number) => void;\n}\n\nexport const SidebarContext = React.createContext<SidebarContextValue | null>(null);\n\nexport function useSidebar() {\n const ctx = React.useContext(SidebarContext);\n if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');\n return ctx;\n}\n\n// ─── Provider ─────────────────────────────────────────────────────────────────\n\n/** Props for the SidebarProvider that manages sidebar state (open/collapsed, mobile, width) */\nexport interface SidebarProviderProps {\n children: React.ReactNode;\n /** Initial open state for uncontrolled usage (default: true) */\n defaultOpen?: boolean;\n /** Controlled open state */\n open?: boolean;\n /** Callback fired when the sidebar opens or closes */\n onOpenChange?: (open: boolean) => void;\n className?: string;\n style?: React.CSSProperties;\n}\n\nexport const SidebarProvider = React.forwardRef<HTMLDivElement, SidebarProviderProps>(\n ({ children, defaultOpen = true, open: controlledOpen, onOpenChange, className, style }, ref) => {\n const [isMobile, setIsMobile] = React.useState(false);\n const [openMobile, setOpenMobile] = React.useState(false);\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT);\n\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen! : internalOpen;\n\n React.useEffect(() => {\n const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n check();\n window.addEventListener('resize', check);\n return () => window.removeEventListener('resize', check);\n }, []);\n\n const setOpen = React.useCallback(\n (val: boolean) => {\n if (!isControlled) setInternalOpen(val);\n onOpenChange?.(val);\n },\n [isControlled, onOpenChange]\n );\n\n const toggleSidebar = React.useCallback(() => {\n if (isMobile) setOpenMobile((v) => !v);\n else setOpen(!open);\n }, [isMobile, open, setOpen]);\n\n React.useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'b') {\n e.preventDefault();\n toggleSidebar();\n }\n };\n window.addEventListener('keydown', onKey);\n return () => window.removeEventListener('keydown', onKey);\n }, [toggleSidebar]);\n\n const state: SidebarState = open ? 'expanded' : 'collapsed';\n\n return (\n <SidebarContext.Provider\n value={{ state, open, setOpen, toggleSidebar, isMobile, openMobile, setOpenMobile, sidebarWidth, setSidebarWidth }}\n >\n <div\n ref={ref}\n data-sidebar-state={state}\n style={\n {\n '--sidebar-width': `${sidebarWidth}px`,\n '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\n ...style,\n } as React.CSSProperties\n }\n className={cn('group/sidebar-wrapper flex min-h-screen w-full has-data-[variant=inset]:bg-muted/30', className)}\n >\n {children}\n </div>\n </SidebarContext.Provider>\n );\n }\n);\nSidebarProvider.displayName = 'SidebarProvider';\n"
728
728
  },
729
729
  {
730
730
  "path": "src/components/ui/sidebar/SidebarLayout.tsx",
@@ -732,11 +732,11 @@
732
732
  },
733
733
  {
734
734
  "path": "src/components/ui/sidebar/SidebarMenu.tsx",
735
- "content": "import * as React from 'react';\r\nimport { NavLink } from 'react-router-dom';\r\nimport { ChevronRight } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar } from './SidebarContext';\r\n\r\n// ─── Group ────────────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group\"\r\n className={cn('relative flex flex-col w-full min-w-0 px-2 py-1', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroup.displayName = 'SidebarGroup';\r\n\r\nexport const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-label\"\r\n className={cn(\r\n 'flex h-8 shrink-0 items-center rounded-md px-2',\r\n 'text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wider',\r\n 'motion-safe:transition-all motion-safe:duration-200 overflow-hidden whitespace-nowrap select-none',\r\n state === 'collapsed' ? 'opacity-0 h-0 mb-0 hidden' : 'opacity-100',\r\n className\r\n )}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\r\n\r\nexport const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-content\"\r\n className={cn('w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroupContent.displayName = 'SidebarGroupContent';\r\n\r\n// ─── Menu ─────────────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenu = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu\"\r\n className={cn('flex flex-col gap-0.5 list-none m-0 p-0 w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenu.displayName = 'SidebarMenu';\r\n\r\nexport const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n ({ className, ...props }, ref) => (\r\n <li\r\n ref={ref}\r\n data-sidebar=\"menu-item\"\r\n className={cn('group/menu-item relative', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuItem.displayName = 'SidebarMenuItem';\r\n\r\n// ─── SidebarMenuButton ────────────────────────────────────────────────────────\r\n\r\nexport const menuButtonVariants = tv({\r\n base: [\r\n 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md',\r\n 'text-sm font-medium outline-none ring-sidebar-ring motion-safe:transition-all motion-safe:duration-150',\r\n 'hover:bg-accent hover:text-accent-foreground',\r\n 'focus-visible:ring-2 active:bg-accent/80',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'group-has-data-[sidebar=menu-action]/menu-item:pr-8',\r\n // Data state active\r\n 'data-[active=true]:bg-accent data-[active=true]:text-accent-foreground data-[active=true]:font-semibold',\r\n ],\r\n variants: {\r\n size: {\r\n sm: 'h-7 text-xs px-2',\r\n md: 'h-9 px-2',\r\n lg: 'h-11 text-base px-3',\r\n },\r\n collapsed: {\r\n true: 'justify-center px-0',\r\n false: 'justify-start',\r\n },\r\n },\r\n defaultVariants: { size: 'md', collapsed: false },\r\n});\r\n\r\n/** Props for a sidebar menu button */\r\nexport interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n /** Render as child element instead of a button */\r\n asChild?: boolean;\r\n /** Marks the button as the currently active item */\r\n isActive?: boolean;\r\n /** Tooltip text shown when the sidebar is collapsed */\r\n tooltip?: string;\r\n /** Button size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n}\r\n\r\nexport const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(\r\n ({ className, isActive = false, tooltip, size = 'md', children, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const button = (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"menu-button\"\r\n data-active={isActive}\r\n data-size={size}\r\n className={menuButtonVariants({ size, collapsed: isCollapsed, className })}\r\n {...props}\r\n >\r\n {isCollapsed\r\n ? React.Children.toArray(children)[0]\r\n : children}\r\n </button>\r\n );\r\n\r\n if (isCollapsed && tooltip) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={button} />\r\n <TooltipContent side=\"right\">{tooltip}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return button;\r\n }\r\n);\r\nSidebarMenuButton.displayName = 'SidebarMenuButton';\r\n\r\n// ─── SidebarNavLink — wraps React Router NavLink ─────────────────────────────\r\n\r\n/** Props for a sidebar navigation link (wraps React Router NavLink) */\r\nexport interface SidebarNavLinkProps {\r\n /** Route path for the link */\r\n to: string;\r\n /** Icon rendered before the label */\r\n icon?: React.ReactNode;\r\n /** Display text for the link; also used as tooltip when collapsed */\r\n label: string;\r\n /** Match route exactly (React Router `end` prop) */\r\n end?: boolean;\r\n /** Badge element rendered after the label */\r\n badge?: React.ReactNode;\r\n /** Link size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n className?: string;\r\n}\r\n\r\nexport const SidebarNavLink: React.FC<SidebarNavLinkProps> = ({\r\n to,\r\n icon,\r\n label,\r\n end = false,\r\n badge,\r\n size = 'md',\r\n className,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const link = (\r\n <NavLink\r\n to={to}\r\n end={end}\r\n className={({ isActive }) => cn(\r\n menuButtonVariants({ size, collapsed: isCollapsed, className }),\r\n isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold' : 'text-sidebar-foreground/70'\r\n )}\r\n >\r\n {icon && (\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">\r\n {icon}\r\n </span>\r\n )}\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate\">{label}</span>\r\n {badge && <span className=\"ml-auto shrink-0\">{badge}</span>}\r\n </>\r\n )}\r\n </NavLink>\r\n );\r\n\r\n if (isCollapsed && label) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={link} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return link;\r\n};\r\nSidebarNavLink.displayName = 'SidebarNavLink';\r\n\r\n// ─── SidebarMenuCollapsible — nhóm có sub-items ───────────────────────────────\r\n\r\n/** Props for a collapsible sidebar menu group with sub-items */\r\nexport interface SidebarMenuCollapsibleProps {\r\n /** Unique identifier for the group */\r\n id: string;\r\n /** Icon displayed next to the group label */\r\n icon: React.ReactNode;\r\n /** Display text for the collapsible group header */\r\n label: string;\r\n children: React.ReactNode;\r\n /** Whether the group is initially expanded */\r\n defaultOpen?: boolean;\r\n /** When true, the group auto-expands and shows an active indicator */\r\n isChildActive?: boolean;\r\n}\r\n\r\nexport const SidebarMenuCollapsible: React.FC<SidebarMenuCollapsibleProps> = ({\r\n icon,\r\n label,\r\n children,\r\n defaultOpen = false,\r\n isChildActive = false,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const [isOpen, setIsOpen] = React.useState(defaultOpen || isChildActive);\r\n const prevOpenRef = React.useRef(isOpen);\r\n\r\n // Khi sidebar collapse → đóng tất cả sub-menu, ghi nhớ state\r\n // Khi sidebar expand → khôi phục state cũ\r\n React.useEffect(() => {\r\n if (isCollapsed) {\r\n prevOpenRef.current = isOpen;\r\n setIsOpen(false);\r\n } else {\r\n setIsOpen(prevOpenRef.current);\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [isCollapsed]);\r\n\r\n // Khi có child active, mở group\r\n React.useEffect(() => {\r\n if (isChildActive && !isCollapsed) {\r\n setIsOpen(true);\r\n prevOpenRef.current = true;\r\n }\r\n }, [isChildActive, isCollapsed]);\r\n\r\n const trigger = (\r\n <button\r\n type=\"button\"\r\n aria-expanded={isOpen}\r\n data-active={isChildActive && isCollapsed}\r\n onClick={() => {\r\n if (!isCollapsed) {\r\n const next = !isOpen;\r\n setIsOpen(next);\r\n prevOpenRef.current = next;\r\n }\r\n }}\r\n className={menuButtonVariants({\r\n collapsed: isCollapsed,\r\n className:\r\n isChildActive && isCollapsed\r\n ? 'text-sidebar-accent-foreground'\r\n : 'text-sidebar-foreground/70',\r\n })}\r\n >\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">{icon}</span>\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate text-left\">{label}</span>\r\n <ChevronRight\r\n className={cn(\r\n 'ml-auto h-3.5 w-3.5 shrink-0 text-sidebar-foreground/40',\r\n 'motion-safe:transition-transform motion-safe:duration-200',\r\n isOpen && 'rotate-90'\r\n )}\r\n />\r\n </>\r\n )}\r\n </button>\r\n );\r\n\r\n return (\r\n <>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n\r\n {/* Sub-items với animation mượt - Sử dụng SidebarMenuSub (ul) để hợp lệ HTML */}\r\n <SidebarMenuSub\r\n className={cn(\r\n 'overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-in-out',\r\n !isCollapsed && isOpen ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'\r\n )}\r\n >\r\n {children}\r\n </SidebarMenuSub>\r\n </>\r\n );\r\n};\r\nSidebarMenuCollapsible.displayName = 'SidebarMenuCollapsible';\r\n\r\n// ─── SidebarMenuSub ───────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n if (state === 'collapsed') return null;\r\n return (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu-sub\"\r\n className={cn('mx-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 list-none', className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarMenuSub.displayName = 'SidebarMenuSub';\r\n\r\nexport const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n (props, ref) => <li ref={ref} {...props} />\r\n);\r\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\r\n\r\n// ─── Badge & Skeleton ─────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"menu-badge\"\r\n className={cn('ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\r\n\r\nexport const SidebarMenuSkeleton: React.FC<{ showIcon?: boolean }> = ({ showIcon = true }) => (\r\n <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\r\n {showIcon && <div className=\"h-4 w-4 rounded bg-sidebar-accent motion-safe:animate-pulse shrink-0\" />}\r\n <div className=\"h-4 flex-1 rounded bg-sidebar-accent motion-safe:animate-pulse\" />\r\n </div>\r\n);\r\n"
735
+ "content": "import * as React from 'react';\nimport { NavLink } from 'react-router-dom';\nimport { ChevronRight } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\nimport { cn } from '@/lib/utils/cn';\nimport { useSidebar } from './SidebarContext';\n\n// ─── Group ────────────────────────────────────────────────────────────────────\n\nexport const SidebarGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"group\"\n className={cn('relative flex flex-col w-full min-w-0 px-2 py-1', className)}\n {...props}\n />\n )\n);\nSidebarGroup.displayName = 'SidebarGroup';\n\nexport const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const { state } = useSidebar();\n return (\n <div\n ref={ref}\n data-sidebar=\"group-label\"\n className={cn(\n 'flex h-8 shrink-0 items-center rounded-md px-2',\n 'text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wider',\n 'motion-safe:transition-all motion-safe:duration-200 overflow-hidden whitespace-nowrap select-none',\n state === 'collapsed' ? 'opacity-0 h-0 mb-0 hidden' : 'opacity-100',\n className\n )}\n {...props}\n />\n );\n }\n);\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\n\nexport const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"group-content\"\n className={cn('w-full', className)}\n {...props}\n />\n )\n);\nSidebarGroupContent.displayName = 'SidebarGroupContent';\n\n// ─── Menu ─────────────────────────────────────────────────────────────────────\n\nexport const SidebarMenu = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\n ({ className, ...props }, ref) => (\n <ul\n ref={ref}\n data-sidebar=\"menu\"\n className={cn('flex flex-col gap-0.5 list-none m-0 p-0 w-full', className)}\n {...props}\n />\n )\n);\nSidebarMenu.displayName = 'SidebarMenu';\n\nexport const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\n ({ className, ...props }, ref) => (\n <li\n ref={ref}\n data-sidebar=\"menu-item\"\n className={cn('group/menu-item relative', className)}\n {...props}\n />\n )\n);\nSidebarMenuItem.displayName = 'SidebarMenuItem';\n\n// ─── SidebarMenuButton ────────────────────────────────────────────────────────\n\nexport const menuButtonVariants = tv({\n base: [\n 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md',\n 'text-sm font-medium outline-none ring-sidebar-ring motion-safe:transition-all motion-safe:duration-150',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:ring-2 active:bg-accent/80',\n 'disabled:pointer-events-none disabled:opacity-50',\n 'group-has-data-[sidebar=menu-action]/menu-item:pr-8',\n // Data state active\n 'data-[active=true]:bg-accent data-[active=true]:text-accent-foreground data-[active=true]:font-semibold',\n ],\n variants: {\n size: {\n sm: 'h-7 text-xs px-2',\n md: 'h-9 px-2',\n lg: 'h-11 text-base px-3',\n },\n collapsed: {\n true: 'justify-center px-0',\n false: 'justify-start',\n },\n },\n defaultVariants: { size: 'md', collapsed: false },\n});\n\n/** Props for a sidebar menu button */\nexport interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n /** Render as child element instead of a button */\n asChild?: boolean;\n /** Marks the button as the currently active item */\n isActive?: boolean;\n /** Tooltip text shown when the sidebar is collapsed */\n tooltip?: string;\n /** Button size variant */\n size?: 'sm' | 'md' | 'lg';\n}\n\nexport const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(\n ({ className, isActive = false, tooltip, size = 'md', children, ...props }, ref) => {\n const { state } = useSidebar();\n const isCollapsed = state === 'collapsed';\n\n const button = (\n <button\n ref={ref}\n type=\"button\"\n data-sidebar=\"menu-button\"\n data-active={isActive}\n data-size={size}\n className={menuButtonVariants({ size, collapsed: isCollapsed, className })}\n {...props}\n >\n {isCollapsed\n ? React.Children.toArray(children)[0]\n : children}\n </button>\n );\n\n if (isCollapsed && tooltip) {\n return (\n <Tooltip>\n <TooltipTrigger render={button} />\n <TooltipContent side=\"right\">{tooltip}</TooltipContent>\n </Tooltip>\n );\n }\n\n return button;\n }\n);\nSidebarMenuButton.displayName = 'SidebarMenuButton';\n\n// ─── SidebarNavLink — wraps React Router NavLink ─────────────────────────────\n\n/** Props for a sidebar navigation link (wraps React Router NavLink) */\nexport interface SidebarNavLinkProps {\n /** Route path for the link */\n to: string;\n /** Icon rendered before the label */\n icon?: React.ReactNode;\n /** Display text for the link; also used as tooltip when collapsed */\n label: string;\n /** Match route exactly (React Router `end` prop) */\n end?: boolean;\n /** Badge element rendered after the label */\n badge?: React.ReactNode;\n /** Link size variant */\n size?: 'sm' | 'md' | 'lg';\n className?: string;\n}\n\nexport const SidebarNavLink: React.FC<SidebarNavLinkProps> = ({\n to,\n icon,\n label,\n end = false,\n badge,\n size = 'md',\n className,\n}) => {\n const { state } = useSidebar();\n const isCollapsed = state === 'collapsed';\n\n const link = (\n <NavLink\n to={to}\n end={end}\n className={({ isActive }) => cn(\n menuButtonVariants({ size, collapsed: isCollapsed, className }),\n isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold' : 'text-sidebar-foreground/70'\n )}\n >\n {icon && (\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">\n {icon}\n </span>\n )}\n {!isCollapsed && (\n <>\n <span className=\"flex-1 truncate\">{label}</span>\n {badge && <span className=\"ml-auto shrink-0\">{badge}</span>}\n </>\n )}\n </NavLink>\n );\n\n if (isCollapsed && label) {\n return (\n <Tooltip>\n <TooltipTrigger render={link} />\n <TooltipContent side=\"right\">{label}</TooltipContent>\n </Tooltip>\n );\n }\n\n return link;\n};\nSidebarNavLink.displayName = 'SidebarNavLink';\n\n// ─── SidebarMenuCollapsible — nhóm có sub-items ───────────────────────────────\n\n/** Props for a collapsible sidebar menu group with sub-items */\nexport interface SidebarMenuCollapsibleProps {\n /** Unique identifier for the group */\n id: string;\n /** Icon displayed next to the group label */\n icon: React.ReactNode;\n /** Display text for the collapsible group header */\n label: string;\n children: React.ReactNode;\n /** Whether the group is initially expanded */\n defaultOpen?: boolean;\n /** When true, the group auto-expands and shows an active indicator */\n isChildActive?: boolean;\n}\n\nexport const SidebarMenuCollapsible: React.FC<SidebarMenuCollapsibleProps> = ({\n icon,\n label,\n children,\n defaultOpen = false,\n isChildActive = false,\n}) => {\n const { state } = useSidebar();\n const isCollapsed = state === 'collapsed';\n\n const [isOpen, setIsOpen] = React.useState(defaultOpen || isChildActive);\n const prevOpenRef = React.useRef(isOpen);\n\n // Khi sidebar collapse → đóng tất cả sub-menu, ghi nhớ state\n // Khi sidebar expand → khôi phục state cũ\n React.useEffect(() => {\n if (isCollapsed) {\n prevOpenRef.current = isOpen;\n setIsOpen(false);\n } else {\n setIsOpen(prevOpenRef.current);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isCollapsed]);\n\n // Khi có child active, mở group\n React.useEffect(() => {\n if (isChildActive && !isCollapsed) {\n setIsOpen(true);\n prevOpenRef.current = true;\n }\n }, [isChildActive, isCollapsed]);\n\n const trigger = (\n <button\n type=\"button\"\n aria-expanded={isOpen}\n data-active={isChildActive && isCollapsed}\n onClick={() => {\n if (!isCollapsed) {\n const next = !isOpen;\n setIsOpen(next);\n prevOpenRef.current = next;\n }\n }}\n className={menuButtonVariants({\n collapsed: isCollapsed,\n className:\n isChildActive && isCollapsed\n ? 'text-sidebar-accent-foreground'\n : 'text-sidebar-foreground/70',\n })}\n >\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">{icon}</span>\n {!isCollapsed && (\n <>\n <span className=\"flex-1 truncate text-left\">{label}</span>\n <ChevronRight\n className={cn(\n 'ml-auto h-3.5 w-3.5 shrink-0 text-sidebar-foreground/40',\n 'motion-safe:transition-transform motion-safe:duration-200',\n isOpen && 'rotate-90'\n )}\n />\n </>\n )}\n </button>\n );\n\n return (\n <>\n {isCollapsed ? (\n <Tooltip>\n <TooltipTrigger render={trigger} />\n <TooltipContent side=\"right\">{label}</TooltipContent>\n </Tooltip>\n ) : (\n trigger\n )}\n\n {/* Sub-items với animation mượt - Sử dụng SidebarMenuSub (ul) để hợp lệ HTML */}\n <SidebarMenuSub\n className={cn(\n 'overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-in-out',\n !isCollapsed && isOpen ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'\n )}\n >\n {children}\n </SidebarMenuSub>\n </>\n );\n};\nSidebarMenuCollapsible.displayName = 'SidebarMenuCollapsible';\n\n// ─── SidebarMenuSub ───────────────────────────────────────────────────────────\n\nexport const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\n ({ className, ...props }, ref) => {\n const { state } = useSidebar();\n if (state === 'collapsed') return null;\n return (\n <ul\n ref={ref}\n data-sidebar=\"menu-sub\"\n className={cn('mx-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 list-none', className)}\n {...props}\n />\n );\n }\n);\nSidebarMenuSub.displayName = 'SidebarMenuSub';\n\nexport const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\n (props, ref) => <li ref={ref} {...props} />\n);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\n// ─── Badge & Skeleton ─────────────────────────────────────────────────────────\n\nexport const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"menu-badge\"\n className={cn('ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary', className)}\n {...props}\n />\n )\n);\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\n\nexport const SidebarMenuSkeleton: React.FC<{ showIcon?: boolean }> = ({ showIcon = true }) => (\n <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\n {showIcon && <div className=\"h-4 w-4 rounded bg-sidebar-accent motion-safe:animate-pulse shrink-0\" />}\n <div className=\"h-4 flex-1 rounded bg-sidebar-accent motion-safe:animate-pulse\" />\n </div>\n);\n"
736
736
  },
737
737
  {
738
738
  "path": "src/components/ui/sidebar/SidebarUserMenu.tsx",
739
- "content": "import * as React from 'react';\r\nimport { ChevronsUpDown } from 'lucide-react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar } from './SidebarContext';\r\nimport { menuButtonVariants } from './SidebarMenu';\r\n\r\n// ─── User Menu Popover (shadcn style) ─────────────────────────────────────────\r\n\r\ninterface UserMenuPopoverProps {\r\n name: string;\r\n email: string;\r\n avatar?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nexport const UserMenuPopover: React.FC<UserMenuPopoverProps> = ({ name, email, avatar, children }) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n const [open, setOpen] = React.useState(false);\r\n\r\n const trigger = (\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n type=\"button\"\r\n data-active={open}\r\n className={cn(\r\n menuButtonVariants({ size: 'lg', collapsed: isCollapsed }),\r\n 'data-[active=true]:bg-sidebar-accent'\r\n )}\r\n >\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-8 h-8 rounded-lg shrink-0 object-cover border border-sidebar-border\"\r\n />\r\n {!isCollapsed && (\r\n <>\r\n <div className=\"flex-1 text-left overflow-hidden grid\">\r\n <span className=\"text-sm font-semibold truncate leading-tight\">{name}</span>\r\n <span className=\"text-xs text-sidebar-foreground/50 truncate leading-tight\">{email}</span>\r\n </div>\r\n <ChevronsUpDown className=\"ml-auto h-4 w-4 shrink-0 text-sidebar-foreground/40\" />\r\n </>\r\n )}\r\n </button>\r\n }\r\n />\r\n );\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{name}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side=\"right\" align=\"end\" sideOffset={8}>\r\n <BasePopover.Popup\r\n className={cn(\r\n 'z-50 w-64 rounded-xl border border-border bg-popover shadow-xl outline-none p-1',\r\n 'motion-safe:data-open:animate-in motion-safe:data-open:fade-in-0 motion-safe:data-open:zoom-in-95',\r\n 'motion-safe:data-closed:animate-out motion-safe:data-closed:fade-out-0 motion-safe:data-closed:zoom-out-95'\r\n )}\r\n >\r\n {/* User info header */}\r\n <div className=\"flex items-center gap-3 p-3 pb-2 border-b border-border/50\">\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-10 h-10 rounded-lg object-cover border border-border\"\r\n />\r\n <div className=\"flex-1 overflow-hidden\">\r\n <p className=\"text-sm font-semibold truncate\">{name}</p>\r\n <p className=\"text-xs text-muted-foreground truncate\">{email}</p>\r\n </div>\r\n </div>\r\n\r\n {/* Menu items */}\r\n <div className=\"py-1\">{children}</div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n};\r\n\r\n// ─── UserMenuItem (item trong popover) ───────────────────────────────────────\r\n\r\ninterface UserMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n icon?: React.ReactNode;\r\n destructive?: boolean;\r\n}\r\n\r\nexport const UserMenuItem: React.FC<UserMenuItemProps> = ({ icon, children, destructive, className, ...props }) => (\r\n <button\r\n type=\"button\"\r\n className={cn(\r\n 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm motion-safe:transition-colors',\r\n 'hover:bg-muted outline-none focus-visible:bg-muted',\r\n destructive ? 'text-destructive hover:text-destructive' : 'text-foreground',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {icon && <span className=\"shrink-0 h-4 w-4 flex items-center justify-center\">{icon}</span>}\r\n {children}\r\n </button>\r\n);\r\n"
739
+ "content": "import * as React from 'react';\nimport { ChevronsUpDown } from 'lucide-react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\nimport { cn } from '@/lib/utils/cn';\nimport { useSidebar } from './SidebarContext';\nimport { menuButtonVariants } from './SidebarMenu';\n\n// ─── User Menu Popover (shadcn style) ─────────────────────────────────────────\n\ninterface UserMenuPopoverProps {\n name: string;\n email: string;\n avatar?: string;\n children?: React.ReactNode;\n}\n\nexport const UserMenuPopover: React.FC<UserMenuPopoverProps> = ({ name, email, avatar, children }) => {\n const { state } = useSidebar();\n const isCollapsed = state === 'collapsed';\n const [open, setOpen] = React.useState(false);\n\n const trigger = (\n <BasePopover.Trigger\n render={\n <button\n type=\"button\"\n data-active={open}\n className={cn(\n menuButtonVariants({ size: 'lg', collapsed: isCollapsed }),\n 'data-[active=true]:bg-sidebar-accent'\n )}\n >\n <img\n src={avatar || 'https://i.pravatar.cc/100'}\n alt={name}\n className=\"w-8 h-8 rounded-lg shrink-0 object-cover border border-sidebar-border\"\n />\n {!isCollapsed && (\n <>\n <div className=\"flex-1 text-left overflow-hidden grid\">\n <span className=\"text-sm font-semibold truncate leading-tight\">{name}</span>\n <span className=\"text-xs text-sidebar-foreground/50 truncate leading-tight\">{email}</span>\n </div>\n <ChevronsUpDown className=\"ml-auto h-4 w-4 shrink-0 text-sidebar-foreground/40\" />\n </>\n )}\n </button>\n }\n />\n );\n\n return (\n <BasePopover.Root open={open} onOpenChange={setOpen}>\n {isCollapsed ? (\n <Tooltip>\n <TooltipTrigger render={trigger} />\n <TooltipContent side=\"right\">{name}</TooltipContent>\n </Tooltip>\n ) : (\n trigger\n )}\n <BasePopover.Portal>\n <BasePopover.Positioner side=\"right\" align=\"end\" sideOffset={8}>\n <BasePopover.Popup\n className={cn(\n 'z-50 w-64 rounded-xl border border-border bg-popover shadow-xl outline-none p-1',\n 'motion-safe:data-open:animate-in motion-safe:data-open:fade-in-0 motion-safe:data-open:zoom-in-95',\n 'motion-safe:data-closed:animate-out motion-safe:data-closed:fade-out-0 motion-safe:data-closed:zoom-out-95'\n )}\n >\n {/* User info header */}\n <div className=\"flex items-center gap-3 p-3 pb-2 border-b border-border/50\">\n <img\n src={avatar || 'https://i.pravatar.cc/100'}\n alt={name}\n className=\"w-10 h-10 rounded-lg object-cover border border-border\"\n />\n <div className=\"flex-1 overflow-hidden\">\n <p className=\"text-sm font-semibold truncate\">{name}</p>\n <p className=\"text-xs text-muted-foreground truncate\">{email}</p>\n </div>\n </div>\n\n {/* Menu items */}\n <div className=\"py-1\">{children}</div>\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n </BasePopover.Root>\n );\n};\n\n// ─── UserMenuItem (item trong popover) ───────────────────────────────────────\n\ninterface UserMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n icon?: React.ReactNode;\n destructive?: boolean;\n}\n\nexport const UserMenuItem: React.FC<UserMenuItemProps> = ({ icon, children, destructive, className, ...props }) => (\n <button\n type=\"button\"\n className={cn(\n 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm motion-safe:transition-colors',\n 'hover:bg-muted outline-none focus-visible:bg-muted',\n destructive ? 'text-destructive hover:text-destructive' : 'text-foreground',\n className\n )}\n {...props}\n >\n {icon && <span className=\"shrink-0 h-4 w-4 flex items-center justify-center\">{icon}</span>}\n {children}\n </button>\n);\n"
740
740
  }
741
741
  ]
742
742
  },
@@ -763,7 +763,7 @@
763
763
  "files": [
764
764
  {
765
765
  "path": "src/components/ui/slider/Slider.tsx",
766
- "content": "import * as React from 'react';\r\nimport { Slider as BaseSlider } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst sliderVariants = tv({\r\n slots: {\r\n root: 'relative flex w-full touch-none select-none items-center py-4 data-disabled:opacity-50 data-disabled:cursor-not-allowed',\r\n control: 'relative flex w-full items-center',\r\n track: 'relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary data-disabled:bg-muted',\r\n indicator: 'absolute h-full bg-primary data-disabled:bg-muted-foreground/30',\r\n thumb: 'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 data-disabled:border-muted-foreground data-disabled:bg-muted data-disabled:pointer-events-none',\r\n }\r\n});\r\n\r\nconst { root, control, track, indicator, thumb } = sliderVariants();\r\n\r\n/** Props for the Slider component */\r\nexport interface SliderProps extends React.ComponentPropsWithoutRef<typeof BaseSlider.Root> {\r\n className?: string;\r\n /** Hiển thị tooltip số khi kéo / hover thumb */\r\n showTooltip?: boolean;\r\n}\r\n\r\nconst toArray = (v: unknown): number[] => {\r\n if (Array.isArray(v)) return v as number[];\r\n if (typeof v === 'number') return [v];\r\n return [0];\r\n};\r\n\r\nconst Slider = React.forwardRef<React.ElementRef<typeof BaseSlider.Root>, SliderProps>(\r\n ({ className, showTooltip, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {\r\n const isControlled = valueProp !== undefined;\r\n const [internalValues, setInternalValues] = React.useState<number[]>(\r\n () => toArray(isControlled ? valueProp : defaultValue)\r\n );\r\n\r\n // Sync khi prop value thay đổi từ bên ngoài (controlled)\r\n React.useEffect(() => {\r\n if (isControlled) setInternalValues(toArray(valueProp));\r\n }, [isControlled, valueProp]);\r\n\r\n const currentValues = isControlled ? toArray(valueProp) : internalValues;\r\n\r\n const handleValueChange: NonNullable<SliderProps['onValueChange']> = (newValues, eventDetails) => {\r\n if (!isControlled) setInternalValues(toArray(newValues));\r\n onValueChange?.(newValues, eventDetails);\r\n };\r\n\r\n return (\r\n <BaseSlider.Root\r\n ref={ref}\r\n className={root({ className })}\r\n aria-label={props['aria-label'] ?? 'Slider'}\r\n value={valueProp}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange}\r\n {...props}\r\n >\r\n <BaseSlider.Control className={control()}>\r\n <BaseSlider.Track className={track()}>\r\n <BaseSlider.Indicator className={indicator()} />\r\n </BaseSlider.Track>\r\n\r\n {currentValues.map((val, index) => (\r\n <BaseSlider.Thumb\r\n key={index}\r\n className={cn(thumb(), showTooltip && 'relative group')}\r\n >\r\n {showTooltip && (\r\n <span className=\"\r\n absolute -top-9 left-1/2 -translate-x-1/2\r\n min-w-[28px] text-center\r\n bg-primary text-primary-foreground\r\n text-xs font-medium leading-none\r\n px-1.5 py-1 rounded shadow-sm\r\n opacity-0 group-hover:opacity-100 group-data-[dragging]:opacity-100\r\n transition-opacity duration-150\r\n pointer-events-none select-none\r\n \">\r\n {val}\r\n </span>\r\n )}\r\n </BaseSlider.Thumb>\r\n ))}\r\n </BaseSlider.Control>\r\n </BaseSlider.Root>\r\n );\r\n }\r\n);\r\nSlider.displayName = 'Slider';\r\n\r\nexport { Slider };\r\n"
766
+ "content": "import * as React from 'react';\nimport { Slider as BaseSlider } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst sliderVariants = tv({\n slots: {\n root: 'relative flex w-full touch-none select-none items-center py-4 data-disabled:opacity-50 data-disabled:cursor-not-allowed',\n control: 'relative flex w-full items-center',\n track: 'relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary data-disabled:bg-muted',\n indicator: 'absolute h-full bg-primary data-disabled:bg-muted-foreground/30',\n thumb: 'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 data-disabled:border-muted-foreground data-disabled:bg-muted data-disabled:pointer-events-none',\n }\n});\n\nconst { root, control, track, indicator, thumb } = sliderVariants();\n\n/** Props for the Slider component */\nexport interface SliderProps extends React.ComponentPropsWithoutRef<typeof BaseSlider.Root> {\n className?: string;\n /** Hiển thị tooltip số khi kéo / hover thumb */\n showTooltip?: boolean;\n}\n\nconst toArray = (v: unknown): number[] => {\n if (Array.isArray(v)) return v as number[];\n if (typeof v === 'number') return [v];\n return [0];\n};\n\nconst Slider = React.forwardRef<React.ElementRef<typeof BaseSlider.Root>, SliderProps>(\n ({ className, showTooltip, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {\n const isControlled = valueProp !== undefined;\n const [internalValues, setInternalValues] = React.useState<number[]>(\n () => toArray(isControlled ? valueProp : defaultValue)\n );\n\n // Sync khi prop value thay đổi từ bên ngoài (controlled)\n React.useEffect(() => {\n if (isControlled) setInternalValues(toArray(valueProp));\n }, [isControlled, valueProp]);\n\n const currentValues = isControlled ? toArray(valueProp) : internalValues;\n\n const handleValueChange: NonNullable<SliderProps['onValueChange']> = (newValues, eventDetails) => {\n if (!isControlled) setInternalValues(toArray(newValues));\n onValueChange?.(newValues, eventDetails);\n };\n\n return (\n <BaseSlider.Root\n ref={ref}\n className={root({ className })}\n aria-label={props['aria-label'] ?? 'Slider'}\n value={valueProp}\n defaultValue={defaultValue}\n onValueChange={handleValueChange}\n {...props}\n >\n <BaseSlider.Control className={control()}>\n <BaseSlider.Track className={track()}>\n <BaseSlider.Indicator className={indicator()} />\n </BaseSlider.Track>\n\n {currentValues.map((val, index) => (\n <BaseSlider.Thumb\n key={index}\n className={cn(thumb(), showTooltip && 'relative group')}\n >\n {showTooltip && (\n <span className=\"\n absolute -top-9 left-1/2 -translate-x-1/2\n min-w-[28px] text-center\n bg-primary text-primary-foreground\n text-xs font-medium leading-none\n px-1.5 py-1 rounded shadow-sm\n opacity-0 group-hover:opacity-100 group-data-[dragging]:opacity-100\n transition-opacity duration-150\n pointer-events-none select-none\n \">\n {val}\n </span>\n )}\n </BaseSlider.Thumb>\n ))}\n </BaseSlider.Control>\n </BaseSlider.Root>\n );\n }\n);\nSlider.displayName = 'Slider';\n\nexport { Slider };\n"
767
767
  }
768
768
  ]
769
769
  },
@@ -812,15 +812,15 @@
812
812
  },
813
813
  {
814
814
  "path": "src/components/ui/table/TableBody.tsx",
815
- "content": "import React from 'react';\nimport {\n flexRender,\n type Row,\n type ColumnDef,\n type RowData,\n} from '@tanstack/react-table';\nimport { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';\nimport { cn } from '@lib/utils/cn';\n\n// ─── Empty State ────────────────────────────────────────────────────────────\n\ninterface TableEmptyProps {\n colSpan: number;\n text: string;\n}\n\nexport function TableEmpty({ colSpan, text }: TableEmptyProps) {\n return (\n <tr>\n <td colSpan={colSpan} className=\"px-4 py-16 text-center text-muted-foreground\">\n <div className=\"flex flex-col items-center justify-center space-y-2\">\n <span className=\"text-muted-foreground/50\">\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\n </svg>\n </span>\n <span>{text}</span>\n </div>\n </td>\n </tr>\n );\n}\n\n// ─── Row Cell Renderer ──────────────────────────────────────────────────────\n\ninterface TableRowCellsProps<TData extends RowData> {\n row: Row<TData>;\n enableColumnResizing: boolean;\n}\n\nfunction TableRowCells<TData extends RowData>({ row, enableColumnResizing }: TableRowCellsProps<TData>) {\n return (\n <>\n {row.getVisibleCells().map(cell => {\n const meta = cell.column.columnDef.meta;\n const align = meta?.align || 'left';\n return (\n <td\n key={cell.id}\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\n className={cn(\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\n \"py-3 border border-border align-middle\",\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\n )}\n >\n <div className={cn(\n \"flex items-center\",\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\n )}>\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </div>\n </td>\n );\n })}\n </>\n );\n}\n\n// ─── Normal Rows ────────────────────────────────────────────────────────────\n\ninterface TableNormalRowsProps<TData extends RowData> {\n rows: Row<TData>[];\n enableColumnResizing: boolean;\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\n}\n\nexport function TableNormalRows<TData extends RowData>({\n rows,\n enableColumnResizing,\n renderSubComponent,\n}: TableNormalRowsProps<TData>) {\n return (\n <>\n {rows.map(row => (\n <React.Fragment key={row.id}>\n <tr\n className={cn(\n \"hover:bg-muted/50 transition-colors group\",\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\n )}\n >\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\n </tr>\n {row.getIsExpanded() && renderSubComponent && (\n <tr>\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\n {renderSubComponent({ row: row.original })}\n </div>\n </td>\n </tr>\n )}\n </React.Fragment>\n ))}\n </>\n );\n}\n\n// ─── Virtual Rows ───────────────────────────────────────────────────────────\n\ninterface TableVirtualRowsProps<TData extends RowData> {\n allRows: Row<TData>[];\n rowVirtualizer: Virtualizer<HTMLDivElement, Element>;\n enableColumnResizing: boolean;\n colSpan: number;\n}\n\nexport function TableVirtualRows<TData extends RowData>({\n allRows,\n rowVirtualizer,\n enableColumnResizing,\n colSpan,\n}: TableVirtualRowsProps<TData>) {\n const virtualItems = rowVirtualizer.getVirtualItems();\n\n return (\n <>\n {rowVirtualizer.getTotalSize() > 0 && (\n <tr aria-hidden=\"true\">\n <td style={{ height: virtualItems[0]?.start ?? 0, padding: 0, border: 0 }} colSpan={colSpan} />\n </tr>\n )}\n {virtualItems.map(virtualRow => {\n const row = allRows[virtualRow.index];\n if (!row) return null;\n return (\n <tr\n key={row.id}\n data-index={virtualRow.index}\n ref={rowVirtualizer.measureElement}\n className={cn(\n \"hover:bg-muted/50 transition-colors\",\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\"\n )}\n >\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\n </tr>\n );\n })}\n {rowVirtualizer.getTotalSize() > 0 && (\n <tr aria-hidden=\"true\">\n <td\n style={{\n height: rowVirtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0),\n padding: 0,\n border: 0,\n }}\n colSpan={colSpan}\n />\n </tr>\n )}\n </>\n );\n}\n"
815
+ "content": "import React from 'react';\r\nimport {\r\n flexRender,\r\n type Row,\r\n type ColumnDef,\r\n type RowData,\r\n} from '@tanstack/react-table';\r\nimport { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\n// ─── Empty State ────────────────────────────────────────────────────────────\r\n\r\ninterface TableEmptyProps {\r\n colSpan: number;\r\n text: string;\r\n}\r\n\r\nexport function TableEmpty({ colSpan, text }: TableEmptyProps) {\r\n return (\r\n <tr>\r\n <td colSpan={colSpan} className=\"px-4 py-16 text-center text-muted-foreground\">\r\n <div className=\"flex flex-col items-center justify-center space-y-2\">\r\n <span className=\"text-muted-foreground/50\">\r\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\r\n </svg>\r\n </span>\r\n <span>{text}</span>\r\n </div>\r\n </td>\r\n </tr>\r\n );\r\n}\r\n\r\n// ─── Row Cell Renderer ──────────────────────────────────────────────────────\r\n\r\ninterface TableRowCellsProps<TData extends RowData> {\r\n row: Row<TData>;\r\n enableColumnResizing: boolean;\r\n}\r\n\r\nfunction TableRowCells<TData extends RowData>({ row, enableColumnResizing }: TableRowCellsProps<TData>) {\r\n return (\r\n <>\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </>\r\n );\r\n}\r\n\r\n// ─── Normal Rows ────────────────────────────────────────────────────────────\r\n\r\ninterface TableNormalRowsProps<TData extends RowData> {\r\n rows: Row<TData>[];\r\n enableColumnResizing: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n}\r\n\r\nexport function TableNormalRows<TData extends RowData>({\r\n rows,\r\n enableColumnResizing,\r\n renderSubComponent,\r\n}: TableNormalRowsProps<TData>) {\r\n return (\r\n <>\r\n {rows.map(row => (\r\n <React.Fragment key={row.id}>\r\n <tr\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors group\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\r\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\r\n )}\r\n >\r\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\r\n </tr>\r\n {row.getIsExpanded() && renderSubComponent && (\r\n <tr>\r\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\r\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\r\n {renderSubComponent({ row: row.original })}\r\n </div>\r\n </td>\r\n </tr>\r\n )}\r\n </React.Fragment>\r\n ))}\r\n </>\r\n );\r\n}\r\n\r\n// ─── Virtual Rows ───────────────────────────────────────────────────────────\r\n\r\ninterface TableVirtualRowsProps<TData extends RowData> {\r\n allRows: Row<TData>[];\r\n rowVirtualizer: Virtualizer<HTMLDivElement, Element>;\r\n enableColumnResizing: boolean;\r\n colSpan: number;\r\n}\r\n\r\nexport function TableVirtualRows<TData extends RowData>({\r\n allRows,\r\n rowVirtualizer,\r\n enableColumnResizing,\r\n colSpan,\r\n}: TableVirtualRowsProps<TData>) {\r\n const virtualItems = rowVirtualizer.getVirtualItems();\r\n\r\n return (\r\n <>\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td style={{ height: virtualItems[0]?.start ?? 0, padding: 0, border: 0 }} colSpan={colSpan} />\r\n </tr>\r\n )}\r\n {virtualItems.map(virtualRow => {\r\n const row = allRows[virtualRow.index];\r\n if (!row) return null;\r\n return (\r\n <tr\r\n key={row.id}\r\n data-index={virtualRow.index}\r\n ref={rowVirtualizer.measureElement}\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\"\r\n )}\r\n >\r\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\r\n </tr>\r\n );\r\n })}\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td\r\n style={{\r\n height: rowVirtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0),\r\n padding: 0,\r\n border: 0,\r\n }}\r\n colSpan={colSpan}\r\n />\r\n </tr>\r\n )}\r\n </>\r\n );\r\n}\r\n"
816
816
  },
817
817
  {
818
818
  "path": "src/components/ui/table/TableHeader.tsx",
819
- "content": "import React from 'react';\nimport {\n flexRender,\n type HeaderGroup,\n type RowData,\n} from '@tanstack/react-table';\nimport { cn } from '@lib/utils/cn';\nimport { ChevronDown, ChevronUp } from 'lucide-react';\n\nexport interface TableHeaderProps<TData extends RowData> {\n headerGroups: HeaderGroup<TData>[];\n enableSorting: boolean;\n enableColumnResizing: boolean;\n}\n\nexport function TableHeader<TData extends RowData>({\n headerGroups,\n enableSorting,\n enableColumnResizing,\n}: TableHeaderProps<TData>) {\n return (\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\n {headerGroups.map(headerGroup => (\n <tr key={headerGroup.id}>\n {headerGroup.headers.map(header => {\n const meta = header.column.columnDef.meta;\n const align = meta?.align || 'left';\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\n\n return (\n <th\n key={header.id}\n colSpan={header.colSpan}\n style={{\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\n position: 'relative'\n }}\n aria-sort={\n canSort\n ? header.column.getIsSorted() === 'desc'\n ? 'descending'\n : header.column.getIsSorted() === 'asc'\n ? 'ascending'\n : 'none'\n : undefined\n }\n className={cn(\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\n )}\n >\n <div\n className={cn(\"flex flex-col h-full\")}\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\n >\n {header.isPlaceholder ? null : (\n <div className={cn(\n \"flex items-center gap-2\",\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\n )}>\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\n {canSort && (\n <span className=\"shrink-0\">\n {{\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\n }[header.column.getIsSorted() as string] ?? (\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\n <ChevronUp className=\"w-3 h-3\" />\n <ChevronDown className=\"w-3 h-3\" />\n </div>\n )}\n </span>\n )}\n </div>\n )}\n </div>\n\n {/* Resize Handle */}\n {enableColumnResizing && header.column.getCanResize() && (\n <div\n {...{\n onMouseDown: header.getResizeHandler(),\n onTouchStart: header.getResizeHandler(),\n className: cn(\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\n ),\n }}\n />\n )}\n </th>\n );\n })}\n </tr>\n ))}\n </thead>\n );\n}\n"
819
+ "content": "import React from 'react';\r\nimport {\r\n flexRender,\r\n type HeaderGroup,\r\n type RowData,\r\n} from '@tanstack/react-table';\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ChevronDown, ChevronUp } from 'lucide-react';\r\n\r\nexport interface TableHeaderProps<TData extends RowData> {\r\n headerGroups: HeaderGroup<TData>[];\r\n enableSorting: boolean;\r\n enableColumnResizing: boolean;\r\n}\r\n\r\nexport function TableHeader<TData extends RowData>({\r\n headerGroups,\r\n enableSorting,\r\n enableColumnResizing,\r\n}: TableHeaderProps<TData>) {\r\n return (\r\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\r\n {headerGroups.map(headerGroup => (\r\n <tr key={headerGroup.id}>\r\n {headerGroup.headers.map(header => {\r\n const meta = header.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\r\n\r\n return (\r\n <th\r\n key={header.id}\r\n colSpan={header.colSpan}\r\n style={{\r\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\r\n position: 'relative'\r\n }}\r\n aria-sort={\r\n canSort\r\n ? header.column.getIsSorted() === 'desc'\r\n ? 'descending'\r\n : header.column.getIsSorted() === 'asc'\r\n ? 'ascending'\r\n : 'none'\r\n : undefined\r\n }\r\n className={cn(\r\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\r\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div\r\n className={cn(\"flex flex-col h-full\")}\r\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\r\n >\r\n {header.isPlaceholder ? null : (\r\n <div className={cn(\r\n \"flex items-center gap-2\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\r\n )}>\r\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\r\n {canSort && (\r\n <span className=\"shrink-0\">\r\n {{\r\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\r\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\r\n }[header.column.getIsSorted() as string] ?? (\r\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\r\n <ChevronUp className=\"w-3 h-3\" />\r\n <ChevronDown className=\"w-3 h-3\" />\r\n </div>\r\n )}\r\n </span>\r\n )}\r\n </div>\r\n )}\r\n </div>\r\n\r\n {/* Resize Handle */}\r\n {enableColumnResizing && header.column.getCanResize() && (\r\n <div\r\n {...{\r\n onMouseDown: header.getResizeHandler(),\r\n onTouchStart: header.getResizeHandler(),\r\n className: cn(\r\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\r\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\r\n ),\r\n }}\r\n />\r\n )}\r\n </th>\r\n );\r\n })}\r\n </tr>\r\n ))}\r\n </thead>\r\n );\r\n}\r\n"
820
820
  },
821
821
  {
822
822
  "path": "src/components/ui/table/TablePagination.tsx",
823
- "content": "import React, { useState, useEffect } from 'react';\nimport { type Table as TanstackTable, type RowData } from '@tanstack/react-table';\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\nimport type { PaginationConfig, TableLabels } from './Table';\n\ninterface TablePaginationProps<TData extends RowData> {\n table: TanstackTable<TData>;\n totalRows: number;\n totalPageCount: number;\n isServerMode: boolean;\n cfg: PaginationConfig;\n labels?: TableLabels;\n}\n\nexport function TablePagination<TData extends RowData>({\n table,\n totalRows,\n totalPageCount,\n isServerMode,\n cfg,\n labels,\n}: TablePaginationProps<TData>) {\n const currentPageIndex = table.getState().pagination.pageIndex;\n const currentPageSize = table.getState().pagination.pageSize;\n const from = currentPageIndex * currentPageSize + 1;\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\n\n const [inputValue, setInputValue] = useState<string | number>(currentPageIndex + 1);\n\n useEffect(() => {\n setInputValue(currentPageIndex + 1);\n }, [currentPageIndex]);\n\n if (totalPageCount <= 0) return null;\n\n return (\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\n {/* showTotal info */}\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\n {cfg.showTotal\n ? cfg.showTotal(totalRows, [from, to])\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\n }\n </div>\n\n {/* Controls */}\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\n {/* Page size */}\n {(cfg.showSizeChanger !== false) && (\n <select\n value={currentPageSize}\n onChange={e => table.setPageSize(Number(e.target.value))}\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\n >\n {pageSizeOptions.map(s => (\n <option key={s} value={s}>{s}{labels?.perPage ?? ' / page'}</option>\n ))}\n </select>\n )}\n\n {/* Nav buttons */}\n <div className=\"flex items-center gap-1 shrink-0\">\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.setPageIndex(0)}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"First page\"\n >\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\n </button>\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <ChevronLeft className=\"w-3.5 h-3.5\" />\n </button>\n\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\n {currentPageIndex + 1} / {totalPageCount}\n </span>\n\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <ChevronRight className=\"w-3.5 h-3.5\" />\n </button>\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.setPageIndex(totalPageCount - 1)}\n disabled={!table.getCanNextPage()}\n aria-label=\"Last page\"\n >\n <ChevronsRight className=\"w-3.5 h-3.5\" />\n </button>\n </div>\n\n {/* Go to page */}\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\n <span className=\"hidden sm:inline\">{labels?.page ?? 'Page'}</span>\n <input\n type=\"number\"\n min={1}\n max={totalPageCount}\n value={inputValue}\n onChange={e => {\n const val = e.target.value;\n setInputValue(val);\n const p = val ? Number(val) - 1 : 0;\n if (val && p >= 0 && p < totalPageCount) {\n table.setPageIndex(p);\n }\n }}\n onBlur={() => setInputValue(currentPageIndex + 1)}\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\n />\n </div>\n </div>\n </div>\n );\n}\n"
823
+ "content": "import React, { useState, useEffect } from 'react';\r\nimport { type Table as TanstackTable, type RowData } from '@tanstack/react-table';\r\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\r\nimport type { PaginationConfig, TableLabels } from './Table';\r\n\r\ninterface TablePaginationProps<TData extends RowData> {\r\n table: TanstackTable<TData>;\r\n totalRows: number;\r\n totalPageCount: number;\r\n isServerMode: boolean;\r\n cfg: PaginationConfig;\r\n labels?: TableLabels;\r\n}\r\n\r\nexport function TablePagination<TData extends RowData>({\r\n table,\r\n totalRows,\r\n totalPageCount,\r\n isServerMode,\r\n cfg,\r\n labels,\r\n}: TablePaginationProps<TData>) {\r\n const currentPageIndex = table.getState().pagination.pageIndex;\r\n const currentPageSize = table.getState().pagination.pageSize;\r\n const from = currentPageIndex * currentPageSize + 1;\r\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\r\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\r\n\r\n const [inputValue, setInputValue] = useState<string | number>(currentPageIndex + 1);\r\n\r\n useEffect(() => {\r\n setInputValue(currentPageIndex + 1);\r\n }, [currentPageIndex]);\r\n\r\n if (totalPageCount <= 0) return null;\r\n\r\n return (\r\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\r\n {/* showTotal info */}\r\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\r\n {cfg.showTotal\r\n ? cfg.showTotal(totalRows, [from, to])\r\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\r\n }\r\n </div>\r\n\r\n {/* Controls */}\r\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\r\n {/* Page size */}\r\n {(cfg.showSizeChanger !== false) && (\r\n <select\r\n value={currentPageSize}\r\n onChange={e => table.setPageSize(Number(e.target.value))}\r\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\r\n >\r\n {pageSizeOptions.map(s => (\r\n <option key={s} value={s}>{s}{labels?.perPage ?? ' / page'}</option>\r\n ))}\r\n </select>\r\n )}\r\n\r\n {/* Nav buttons */}\r\n <div className=\"flex items-center gap-1 shrink-0\">\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(0)}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"First page\"\r\n >\r\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.previousPage()}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"Previous page\"\r\n >\r\n <ChevronLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n\r\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\r\n {currentPageIndex + 1} / {totalPageCount}\r\n </span>\r\n\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.nextPage()}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Next page\"\r\n >\r\n <ChevronRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(totalPageCount - 1)}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Last page\"\r\n >\r\n <ChevronsRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n\r\n {/* Go to page */}\r\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\r\n <span className=\"hidden sm:inline\">{labels?.page ?? 'Page'}</span>\r\n <input\r\n type=\"number\"\r\n min={1}\r\n max={totalPageCount}\r\n value={inputValue}\r\n onChange={e => {\r\n const val = e.target.value;\r\n setInputValue(val);\r\n const p = val ? Number(val) - 1 : 0;\r\n if (val && p >= 0 && p < totalPageCount) {\r\n table.setPageIndex(p);\r\n }\r\n }}\r\n onBlur={() => setInputValue(currentPageIndex + 1)}\r\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
824
824
  }
825
825
  ]
826
826
  },
@@ -831,7 +831,7 @@
831
831
  "files": [
832
832
  {
833
833
  "path": "src/components/ui/table-contents/TableContents.tsx",
834
- "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\nexport type TocItem = {\n id: string;\n label: string;\n level?: 1 | 2 | 3;\n};\n\nexport interface TableContentsProps {\n items: TocItem[];\n /** Offset (px) từ top khi scroll — dành cho sticky header */\n offset?: number;\n /** Title hiển thị trên danh sách */\n title?: string;\n className?: string;\n}\n\n// ─── Helper: tìm scrollable ancestor gần nhất ────────────────────────────────\nfunction getScrollParent(el: HTMLElement | null): HTMLElement | null {\n let node = el?.parentElement ?? null;\n while (node && node !== document.body) {\n const { overflow, overflowY } = getComputedStyle(node);\n if (/auto|scroll/.test(overflow + overflowY)) return node;\n node = node.parentElement;\n }\n return null;\n}\n\n// ─── Hook: theo dõi section đang active qua IntersectionObserver ──────────────\nfunction useActiveSection(ids: string[], offset: number): string {\n const [activeId, setActiveId] = React.useState('');\n\n React.useEffect(() => {\n if (!ids.length) return;\n\n // Dùng scrollable container làm root để rootMargin hoạt động đúng\n const firstEl = document.getElementById(ids[0]);\n const root = firstEl ? getScrollParent(firstEl) : null;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n setActiveId(entry.target.id);\n break;\n }\n }\n },\n { root, rootMargin: `-${offset + 8}px 0px -70% 0px`, threshold: 0 },\n );\n\n ids.forEach((id) => {\n const el = document.getElementById(id);\n if (el) observer.observe(el);\n });\n\n return () => observer.disconnect();\n }, [ids, offset]);\n\n return activeId;\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\nexport const TableContents: React.FC<TableContentsProps> = ({\n items,\n offset = 80,\n title = 'Trên trang này',\n className,\n}) => {\n const ids = React.useMemo(() => items.map((i) => i.id), [items]);\n const activeId = useActiveSection(ids, offset);\n\n const scrollTo = (id: string) => {\n const el = document.getElementById(id);\n if (!el) return;\n\n const container = getScrollParent(el);\n if (!container) {\n // Fallback: window scroll\n window.scrollTo({\n top: el.getBoundingClientRect().top + window.scrollY - offset,\n behavior: 'smooth',\n });\n return;\n }\n\n const top =\n el.getBoundingClientRect().top -\n container.getBoundingClientRect().top +\n container.scrollTop -\n offset;\n container.scrollTo({ top, behavior: 'smooth' });\n };\n\n return (\n <nav className={cn('select-none', className)} aria-label=\"Table of contents\">\n {title && (\n <p className=\"mb-3 text-xs font-semibold uppercase tracking-widest text-muted-foreground\">\n {title}\n </p>\n )}\n\n <ul className=\"space-y-0.5 border-l border-border\">\n {items.map((item) => {\n const level = item.level ?? 1;\n const isActive = activeId === item.id;\n\n return (\n <li key={item.id}>\n <button\n onClick={() => scrollTo(item.id)}\n className={cn(\n 'block w-full text-left text-sm leading-5 transition-all duration-150',\n 'py-1 pr-2 hover:text-foreground',\n // Indentation theo level\n level === 1 && 'pl-3 font-medium',\n level === 2 && 'pl-5 font-normal',\n level === 3 && 'pl-8 font-normal text-xs',\n // Border left indicator\n isActive\n ? '-ml-px border-l-2 border-primary pl-[calc(theme(spacing.3)-1px)] text-primary'\n : 'text-muted-foreground',\n level === 2 && isActive && 'pl-[calc(theme(spacing.5)-1px)]',\n level === 3 && isActive && 'pl-[calc(theme(spacing.8)-1px)]',\n )}\n aria-current={isActive ? 'location' : undefined}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </nav>\n );\n};\n\nexport default TableContents;\n"
834
+ "content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Types ────────────────────────────────────────────────────────────────────\r\nexport type TocItem = {\r\n id: string;\r\n label: string;\r\n level?: 1 | 2 | 3;\r\n};\r\n\r\nexport interface TableContentsProps {\r\n items: TocItem[];\r\n /** Offset (px) từ top khi scroll — dành cho sticky header */\r\n offset?: number;\r\n /** Title hiển thị trên danh sách */\r\n title?: string;\r\n className?: string;\r\n}\r\n\r\n// ─── Helper: tìm scrollable ancestor gần nhất ────────────────────────────────\r\nfunction getScrollParent(el: HTMLElement | null): HTMLElement | null {\r\n let node = el?.parentElement ?? null;\r\n while (node && node !== document.body) {\r\n const { overflow, overflowY } = getComputedStyle(node);\r\n if (/auto|scroll/.test(overflow + overflowY)) return node;\r\n node = node.parentElement;\r\n }\r\n return null;\r\n}\r\n\r\n// ─── Hook: theo dõi section đang active qua IntersectionObserver ──────────────\r\nfunction useActiveSection(ids: string[], offset: number): string {\r\n const [activeId, setActiveId] = React.useState('');\r\n\r\n React.useEffect(() => {\r\n if (!ids.length) return;\r\n\r\n // Dùng scrollable container làm root để rootMargin hoạt động đúng\r\n const firstEl = document.getElementById(ids[0]);\r\n const root = firstEl ? getScrollParent(firstEl) : null;\r\n\r\n const observer = new IntersectionObserver(\r\n (entries) => {\r\n for (const entry of entries) {\r\n if (entry.isIntersecting) {\r\n setActiveId(entry.target.id);\r\n break;\r\n }\r\n }\r\n },\r\n { root, rootMargin: `-${offset + 8}px 0px -70% 0px`, threshold: 0 },\r\n );\r\n\r\n ids.forEach((id) => {\r\n const el = document.getElementById(id);\r\n if (el) observer.observe(el);\r\n });\r\n\r\n return () => observer.disconnect();\r\n }, [ids, offset]);\r\n\r\n return activeId;\r\n}\r\n\r\n// ─── Component ────────────────────────────────────────────────────────────────\r\nexport const TableContents: React.FC<TableContentsProps> = ({\r\n items,\r\n offset = 80,\r\n title = 'Trên trang này',\r\n className,\r\n}) => {\r\n const ids = React.useMemo(() => items.map((i) => i.id), [items]);\r\n const activeId = useActiveSection(ids, offset);\r\n\r\n const scrollTo = (id: string) => {\r\n const el = document.getElementById(id);\r\n if (!el) return;\r\n\r\n const container = getScrollParent(el);\r\n if (!container) {\r\n // Fallback: window scroll\r\n window.scrollTo({\r\n top: el.getBoundingClientRect().top + window.scrollY - offset,\r\n behavior: 'smooth',\r\n });\r\n return;\r\n }\r\n\r\n const top =\r\n el.getBoundingClientRect().top -\r\n container.getBoundingClientRect().top +\r\n container.scrollTop -\r\n offset;\r\n container.scrollTo({ top, behavior: 'smooth' });\r\n };\r\n\r\n return (\r\n <nav className={cn('select-none', className)} aria-label=\"Table of contents\">\r\n {title && (\r\n <p className=\"mb-3 text-xs font-semibold uppercase tracking-widest text-muted-foreground\">\r\n {title}\r\n </p>\r\n )}\r\n\r\n <ul className=\"space-y-0.5 border-l border-border\">\r\n {items.map((item) => {\r\n const level = item.level ?? 1;\r\n const isActive = activeId === item.id;\r\n\r\n return (\r\n <li key={item.id}>\r\n <button\r\n onClick={() => scrollTo(item.id)}\r\n className={cn(\r\n 'block w-full text-left text-sm leading-5 transition-all duration-150',\r\n 'py-1 pr-2 hover:text-foreground',\r\n // Indentation theo level\r\n level === 1 && 'pl-3 font-medium',\r\n level === 2 && 'pl-5 font-normal',\r\n level === 3 && 'pl-8 font-normal text-xs',\r\n // Border left indicator\r\n isActive\r\n ? '-ml-px border-l-2 border-primary pl-[calc(theme(spacing.3)-1px)] text-primary'\r\n : 'text-muted-foreground',\r\n level === 2 && isActive && 'pl-[calc(theme(spacing.5)-1px)]',\r\n level === 3 && isActive && 'pl-[calc(theme(spacing.8)-1px)]',\r\n )}\r\n aria-current={isActive ? 'location' : undefined}\r\n >\r\n {item.label}\r\n </button>\r\n </li>\r\n );\r\n })}\r\n </ul>\r\n </nav>\r\n );\r\n};\r\n\r\nexport default TableContents;\r\n"
835
835
  }
836
836
  ]
837
837
  },
@@ -872,7 +872,7 @@
872
872
  "files": [
873
873
  {
874
874
  "path": "src/components/ui/timeline/Timeline.tsx",
875
- "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst timelineVariants = tv({\n slots: {\n root: 'relative flex flex-col',\n item: 'relative flex gap-4 pb-8 last:pb-0',\n indicator: [\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full',\n 'border-2 border-background bg-muted ring-2 ring-background',\n ].join(' '),\n connector: 'absolute left-0 top-0 bottom-0 flex justify-center',\n connectorLine: 'w-px bg-border',\n content: 'flex-1 pt-0.5',\n title: 'text-sm font-semibold text-foreground leading-none',\n description: 'mt-1 text-sm text-muted-foreground leading-relaxed',\n time: 'mt-1.5 text-xs text-muted-foreground/70',\n },\n variants: {\n size: {\n sm: {\n indicator: 'h-6 w-6 [&_svg]:h-3 [&_svg]:w-3',\n connector: 'w-6',\n },\n md: {\n indicator: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',\n connector: 'w-8',\n },\n lg: {\n indicator: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5',\n connector: 'w-10',\n },\n },\n variant: {\n default: { indicator: 'bg-muted text-muted-foreground' },\n primary: { indicator: 'bg-primary/15 text-primary border-primary/30' },\n success: { indicator: 'bg-success/15 text-success border-success/30' },\n warning: { indicator: 'bg-warning/15 text-warning border-warning/30' },\n danger: { indicator: 'bg-danger/15 text-danger border-danger/30' },\n },\n },\n defaultVariants: {\n size: 'md',\n variant: 'default',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface TimelineItemData {\n title: string;\n description?: string;\n time?: string;\n icon?: React.ReactNode;\n variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger';\n}\n\nexport interface TimelineProps extends VariantProps<typeof timelineVariants> {\n items: TimelineItemData[];\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(\n ({ items, size = 'md', variant: defaultVariant = 'default', className }, ref) => {\n const styles = timelineVariants({ size });\n\n return (\n <div ref={ref} className={cn(styles.root(), className)} role=\"list\">\n {items.map((item, index) => {\n const itemVariant = item.variant ?? defaultVariant;\n const itemStyles = timelineVariants({ size, variant: itemVariant });\n const isLast = index === items.length - 1;\n\n return (\n <div key={index} className={styles.item()} role=\"listitem\">\n {/* Connector line */}\n {!isLast && (\n <div className={styles.connector()}>\n <div className={cn(styles.connectorLine(), 'mt-8')} />\n </div>\n )}\n\n {/* Indicator dot */}\n <div className={itemStyles.indicator()}>\n {item.icon}\n </div>\n\n {/* Content */}\n <div className={styles.content()}>\n <p className={styles.title()}>{item.title}</p>\n {item.description && (\n <p className={styles.description()}>{item.description}</p>\n )}\n {item.time && (\n <p className={styles.time()}>{item.time}</p>\n )}\n </div>\n </div>\n );\n })}\n </div>\n );\n },\n);\n\nTimeline.displayName = 'Timeline';\n\nexport { Timeline, timelineVariants };\n"
875
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst timelineVariants = tv({\r\n slots: {\r\n root: 'relative flex flex-col',\r\n item: 'relative flex gap-4 pb-8 last:pb-0',\r\n indicator: [\r\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full',\r\n 'border-2 border-background bg-muted ring-2 ring-background',\r\n ].join(' '),\r\n connector: 'absolute left-0 top-0 bottom-0 flex justify-center',\r\n connectorLine: 'w-px bg-border',\r\n content: 'flex-1 pt-0.5',\r\n title: 'text-sm font-semibold text-foreground leading-none',\r\n description: 'mt-1 text-sm text-muted-foreground leading-relaxed',\r\n time: 'mt-1.5 text-xs text-muted-foreground/70',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n indicator: 'h-6 w-6 [&_svg]:h-3 [&_svg]:w-3',\r\n connector: 'w-6',\r\n },\r\n md: {\r\n indicator: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',\r\n connector: 'w-8',\r\n },\r\n lg: {\r\n indicator: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5',\r\n connector: 'w-10',\r\n },\r\n },\r\n variant: {\r\n default: { indicator: 'bg-muted text-muted-foreground' },\r\n primary: { indicator: 'bg-primary/15 text-primary border-primary/30' },\r\n success: { indicator: 'bg-success/15 text-success border-success/30' },\r\n warning: { indicator: 'bg-warning/15 text-warning border-warning/30' },\r\n danger: { indicator: 'bg-danger/15 text-danger border-danger/30' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'default',\r\n },\r\n});\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\nexport interface TimelineItemData {\r\n title: string;\r\n description?: string;\r\n time?: string;\r\n icon?: React.ReactNode;\r\n variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger';\r\n}\r\n\r\nexport interface TimelineProps extends VariantProps<typeof timelineVariants> {\r\n items: TimelineItemData[];\r\n className?: string;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────────────────────\r\n\r\nconst Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(\r\n ({ items, size = 'md', variant: defaultVariant = 'default', className }, ref) => {\r\n const styles = timelineVariants({ size });\r\n\r\n return (\r\n <div ref={ref} className={cn(styles.root(), className)} role=\"list\">\r\n {items.map((item, index) => {\r\n const itemVariant = item.variant ?? defaultVariant;\r\n const itemStyles = timelineVariants({ size, variant: itemVariant });\r\n const isLast = index === items.length - 1;\r\n\r\n return (\r\n <div key={index} className={styles.item()} role=\"listitem\">\r\n {/* Connector line */}\r\n {!isLast && (\r\n <div className={styles.connector()}>\r\n <div className={cn(styles.connectorLine(), 'mt-8')} />\r\n </div>\r\n )}\r\n\r\n {/* Indicator dot */}\r\n <div className={itemStyles.indicator()}>\r\n {item.icon}\r\n </div>\r\n\r\n {/* Content */}\r\n <div className={styles.content()}>\r\n <p className={styles.title()}>{item.title}</p>\r\n {item.description && (\r\n <p className={styles.description()}>{item.description}</p>\r\n )}\r\n {item.time && (\r\n <p className={styles.time()}>{item.time}</p>\r\n )}\r\n </div>\r\n </div>\r\n );\r\n })}\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nTimeline.displayName = 'Timeline';\r\n\r\nexport { Timeline, timelineVariants };\r\n"
876
876
  }
877
877
  ]
878
878
  },
@@ -912,7 +912,7 @@
912
912
  "files": [
913
913
  {
914
914
  "path": "src/components/ui/tooltip/Tooltip.tsx",
915
- "content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { Tooltip as BaseTooltip } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst tooltipVariants = tv({\r\n slots: {\r\n popup: 'z-50 overflow-hidden rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-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',\r\n arrow: 'fill-popover',\r\n },\r\n});\r\n\r\nconst { popup, arrow } = tooltipVariants();\r\n\r\n// ─── Compound Components ─────────────────────────────────────────────────────\r\n\r\n/** Wrap multiple Tooltip instances in a shared provider for better performance */\r\nconst TooltipProvider = BaseTooltip.Provider;\r\n\r\nconst Tooltip = BaseTooltip.Root;\r\n\r\nconst TooltipTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ComponentPropsWithoutRef<typeof BaseTooltip.Trigger>\r\n>(({ children, render, ...props }, ref) => (\r\n <BaseTooltip.Trigger\r\n ref={ref}\r\n render={render ?? (React.isValidElement(children) ? children : undefined)}\r\n {...props}\r\n >\r\n {React.isValidElement(children) ? undefined : children}\r\n </BaseTooltip.Trigger>\r\n));\r\nTooltipTrigger.displayName = 'TooltipTrigger';\r\n\r\nexport interface TooltipContentProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup> {\r\n /** Side offset from the trigger (default: 4) */\r\n sideOffset?: number;\r\n /** Side to display the tooltip (default: 'top') */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger (default: 'center') */\r\n align?: 'start' | 'center' | 'end';\r\n /** Show the arrow indicator */\r\n showArrow?: boolean;\r\n}\r\n\r\nconst TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(\r\n ({ className, sideOffset = 4, side = 'top', align = 'center', showArrow = true, children, ...props }, ref) => (\r\n <BaseTooltip.Portal>\r\n <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseTooltip.Popup ref={ref} className={cn(popup(), className)} role=\"tooltip\" {...props}>\r\n {showArrow && <BaseTooltip.Arrow className={arrow()} />}\r\n {children}\r\n </BaseTooltip.Popup>\r\n </BaseTooltip.Positioner>\r\n </BaseTooltip.Portal>\r\n )\r\n);\r\nTooltipContent.displayName = 'TooltipContent';\r\n\r\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, tooltipVariants };\r\n"
915
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { Tooltip as BaseTooltip } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst tooltipVariants = tv({\n slots: {\n popup: 'z-50 overflow-hidden rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-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',\n arrow: 'fill-popover',\n },\n});\n\nconst { popup, arrow } = tooltipVariants();\n\n// ─── Compound Components ─────────────────────────────────────────────────────\n\n/** Wrap multiple Tooltip instances in a shared provider for better performance */\nconst TooltipProvider = BaseTooltip.Provider;\n\nconst Tooltip = BaseTooltip.Root;\n\nconst TooltipTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BaseTooltip.Trigger>\n>(({ children, render, ...props }, ref) => (\n <BaseTooltip.Trigger\n ref={ref}\n render={render ?? (React.isValidElement(children) ? children : undefined)}\n {...props}\n >\n {React.isValidElement(children) ? undefined : children}\n </BaseTooltip.Trigger>\n));\nTooltipTrigger.displayName = 'TooltipTrigger';\n\nexport interface TooltipContentProps\n extends React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup> {\n /** Side offset from the trigger (default: 4) */\n sideOffset?: number;\n /** Side to display the tooltip (default: 'top') */\n side?: 'top' | 'right' | 'bottom' | 'left';\n /** Alignment relative to the trigger (default: 'center') */\n align?: 'start' | 'center' | 'end';\n /** Show the arrow indicator */\n showArrow?: boolean;\n}\n\nconst TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(\n ({ className, sideOffset = 4, side = 'top', align = 'center', showArrow = true, children, ...props }, ref) => (\n <BaseTooltip.Portal>\n <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseTooltip.Popup ref={ref} className={cn(popup(), className)} role=\"tooltip\" {...props}>\n {showArrow && <BaseTooltip.Arrow className={arrow()} />}\n {children}\n </BaseTooltip.Popup>\n </BaseTooltip.Positioner>\n </BaseTooltip.Portal>\n )\n);\nTooltipContent.displayName = 'TooltipContent';\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, tooltipVariants };\n"
916
916
  }
917
917
  ]
918
918
  },
@@ -927,7 +927,7 @@
927
927
  "files": [
928
928
  {
929
929
  "path": "src/components/ui/tree-view/TreeView.tsx",
930
- "content": "import * as React from 'react';\nimport { Collapsible } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { ChevronRight, FileIcon, FolderIcon, FolderOpen } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst treeViewVariants = tv({\n slots: {\n root: 'flex flex-col text-sm',\n item: [\n 'flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer',\n 'transition-colors hover:bg-muted/50',\n 'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\n ].join(' '),\n itemActive: 'bg-primary/10 text-primary hover:bg-primary/15',\n chevron: 'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',\n chevronOpen: 'rotate-90',\n icon: 'h-4 w-4 shrink-0 text-muted-foreground',\n label: 'truncate select-none',\n children: 'pl-4',\n },\n});\n\nconst styles = treeViewVariants();\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface TreeNode {\n id: string;\n label: string;\n icon?: React.ReactNode;\n children?: TreeNode[];\n disabled?: boolean;\n}\n\nexport interface TreeViewProps {\n data: TreeNode[];\n /** Currently selected node id */\n selectedId?: string;\n /** Called when a node is selected */\n onSelect?: (id: string) => void;\n /** Default expanded node ids */\n defaultExpanded?: string[];\n className?: string;\n}\n\n// ─── TreeItem (recursive) ──────────────────────────────────────���─────────────\n\ninterface TreeItemProps {\n node: TreeNode;\n level: number;\n selectedId?: string;\n onSelect?: (id: string) => void;\n expandedSet: Set<string>;\n toggleExpanded: (id: string) => void;\n}\n\nfunction TreeItem({ node, level, selectedId, onSelect, expandedSet, toggleExpanded }: TreeItemProps) {\n const hasChildren = node.children && node.children.length > 0;\n const isExpanded = expandedSet.has(node.id);\n const isSelected = selectedId === node.id;\n\n const handleClick = () => {\n if (node.disabled) return;\n if (hasChildren) {\n toggleExpanded(node.id);\n }\n onSelect?.(node.id);\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleClick();\n }\n if (e.key === 'ArrowRight' && hasChildren && !isExpanded) {\n e.preventDefault();\n toggleExpanded(node.id);\n }\n if (e.key === 'ArrowLeft' && hasChildren && isExpanded) {\n e.preventDefault();\n toggleExpanded(node.id);\n }\n };\n\n const defaultIcon = hasChildren\n ? (isExpanded ? <FolderOpen className={styles.icon()} /> : <FolderIcon className={styles.icon()} />)\n : <FileIcon className={styles.icon()} />;\n\n return (\n <div role=\"treeitem\" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected}>\n <div\n className={cn(\n styles.item(),\n isSelected && styles.itemActive(),\n node.disabled && 'opacity-50 pointer-events-none',\n )}\n style={{ paddingLeft: `${level * 16 + 8}px` }}\n tabIndex={node.disabled ? undefined : 0}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {hasChildren ? (\n <ChevronRight className={cn(styles.chevron(), isExpanded && styles.chevronOpen())} />\n ) : (\n <span className=\"w-4 shrink-0\" />\n )}\n\n {node.icon ?? defaultIcon}\n <span className={styles.label()}>{node.label}</span>\n </div>\n\n {hasChildren && isExpanded && (\n <div role=\"group\">\n {node.children!.map((child) => (\n <TreeItem\n key={child.id}\n node={child}\n level={level + 1}\n selectedId={selectedId}\n onSelect={onSelect}\n expandedSet={expandedSet}\n toggleExpanded={toggleExpanded}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\n// ─── TreeView ────────────────────────────────────────────────────────────────\n\nconst TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(\n ({ data, selectedId, onSelect, defaultExpanded = [], className }, ref) => {\n const [expandedSet, setExpandedSet] = React.useState<Set<string>>(\n () => new Set(defaultExpanded),\n );\n\n const toggleExpanded = React.useCallback((id: string) => {\n setExpandedSet((prev) => {\n const next = new Set(prev);\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n return next;\n });\n }, []);\n\n return (\n <div\n ref={ref}\n role=\"tree\"\n aria-label=\"Tree view\"\n className={cn(styles.root(), className)}\n >\n {data.map((node) => (\n <TreeItem\n key={node.id}\n node={node}\n level={0}\n selectedId={selectedId}\n onSelect={onSelect}\n expandedSet={expandedSet}\n toggleExpanded={toggleExpanded}\n />\n ))}\n </div>\n );\n },\n);\n\nTreeView.displayName = 'TreeView';\n\nexport { TreeView, treeViewVariants };\n"
930
+ "content": "import * as React from 'react';\r\nimport { Collapsible } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronRight, FileIcon, FolderIcon, FolderOpen } from 'lucide-react';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst treeViewVariants = tv({\r\n slots: {\r\n root: 'flex flex-col text-sm',\r\n item: [\r\n 'flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer',\r\n 'transition-colors hover:bg-muted/50',\r\n 'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\r\n ].join(' '),\r\n itemActive: 'bg-primary/10 text-primary hover:bg-primary/15',\r\n chevron: 'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',\r\n chevronOpen: 'rotate-90',\r\n icon: 'h-4 w-4 shrink-0 text-muted-foreground',\r\n label: 'truncate select-none',\r\n children: 'pl-4',\r\n },\r\n});\r\n\r\nconst styles = treeViewVariants();\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\nexport interface TreeNode {\r\n id: string;\r\n label: string;\r\n icon?: React.ReactNode;\r\n children?: TreeNode[];\r\n disabled?: boolean;\r\n}\r\n\r\nexport interface TreeViewProps {\r\n data: TreeNode[];\r\n /** Currently selected node id */\r\n selectedId?: string;\r\n /** Called when a node is selected */\r\n onSelect?: (id: string) => void;\r\n /** Default expanded node ids */\r\n defaultExpanded?: string[];\r\n className?: string;\r\n}\r\n\r\n// ─── TreeItem (recursive) ──────────────────────────────────────���─────────────\r\n\r\ninterface TreeItemProps {\r\n node: TreeNode;\r\n level: number;\r\n selectedId?: string;\r\n onSelect?: (id: string) => void;\r\n expandedSet: Set<string>;\r\n toggleExpanded: (id: string) => void;\r\n}\r\n\r\nfunction TreeItem({ node, level, selectedId, onSelect, expandedSet, toggleExpanded }: TreeItemProps) {\r\n const hasChildren = node.children && node.children.length > 0;\r\n const isExpanded = expandedSet.has(node.id);\r\n const isSelected = selectedId === node.id;\r\n\r\n const handleClick = () => {\r\n if (node.disabled) return;\r\n if (hasChildren) {\r\n toggleExpanded(node.id);\r\n }\r\n onSelect?.(node.id);\r\n };\r\n\r\n const handleKeyDown = (e: React.KeyboardEvent) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n handleClick();\r\n }\r\n if (e.key === 'ArrowRight' && hasChildren && !isExpanded) {\r\n e.preventDefault();\r\n toggleExpanded(node.id);\r\n }\r\n if (e.key === 'ArrowLeft' && hasChildren && isExpanded) {\r\n e.preventDefault();\r\n toggleExpanded(node.id);\r\n }\r\n };\r\n\r\n const defaultIcon = hasChildren\r\n ? (isExpanded ? <FolderOpen className={styles.icon()} /> : <FolderIcon className={styles.icon()} />)\r\n : <FileIcon className={styles.icon()} />;\r\n\r\n return (\r\n <div role=\"treeitem\" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected}>\r\n <div\r\n className={cn(\r\n styles.item(),\r\n isSelected && styles.itemActive(),\r\n node.disabled && 'opacity-50 pointer-events-none',\r\n )}\r\n style={{ paddingLeft: `${level * 16 + 8}px` }}\r\n tabIndex={node.disabled ? undefined : 0}\r\n onClick={handleClick}\r\n onKeyDown={handleKeyDown}\r\n >\r\n {hasChildren ? (\r\n <ChevronRight className={cn(styles.chevron(), isExpanded && styles.chevronOpen())} />\r\n ) : (\r\n <span className=\"w-4 shrink-0\" />\r\n )}\r\n\r\n {node.icon ?? defaultIcon}\r\n <span className={styles.label()}>{node.label}</span>\r\n </div>\r\n\r\n {hasChildren && isExpanded && (\r\n <div role=\"group\">\r\n {node.children!.map((child) => (\r\n <TreeItem\r\n key={child.id}\r\n node={child}\r\n level={level + 1}\r\n selectedId={selectedId}\r\n onSelect={onSelect}\r\n expandedSet={expandedSet}\r\n toggleExpanded={toggleExpanded}\r\n />\r\n ))}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// ─── TreeView ────────────────────────────────────────────────────────────────\r\n\r\nconst TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(\r\n ({ data, selectedId, onSelect, defaultExpanded = [], className }, ref) => {\r\n const [expandedSet, setExpandedSet] = React.useState<Set<string>>(\r\n () => new Set(defaultExpanded),\r\n );\r\n\r\n const toggleExpanded = React.useCallback((id: string) => {\r\n setExpandedSet((prev) => {\r\n const next = new Set(prev);\r\n if (next.has(id)) {\r\n next.delete(id);\r\n } else {\r\n next.add(id);\r\n }\r\n return next;\r\n });\r\n }, []);\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"tree\"\r\n aria-label=\"Tree view\"\r\n className={cn(styles.root(), className)}\r\n >\r\n {data.map((node) => (\r\n <TreeItem\r\n key={node.id}\r\n node={node}\r\n level={0}\r\n selectedId={selectedId}\r\n onSelect={onSelect}\r\n expandedSet={expandedSet}\r\n toggleExpanded={toggleExpanded}\r\n />\r\n ))}\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nTreeView.displayName = 'TreeView';\r\n\r\nexport { TreeView, treeViewVariants };\r\n"
931
931
  }
932
932
  ]
933
933
  },