basuicn 0.3.12 → 0.3.19

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 (316) hide show
  1. package/dist/assets/abap-CLvhMVsD.js +1 -0
  2. package/dist/assets/actionscript-3--17pq3dv.js +1 -0
  3. package/dist/assets/ada-C5qYipkI.js +1 -0
  4. package/dist/assets/andromeeda-vGVdxbeo.js +1 -0
  5. package/dist/assets/angular-html-C_R4boCs.js +1 -0
  6. package/dist/assets/angular-ts-DDC-7KEU.js +1 -0
  7. package/dist/assets/apache-U0d_L8uA.js +1 -0
  8. package/dist/assets/apex-VAyPSnFM.js +1 -0
  9. package/dist/assets/apl-C6NMFcit.js +1 -0
  10. package/dist/assets/applescript-CCn79oCD.js +1 -0
  11. package/dist/assets/ara-4CJ0cIlV.js +1 -0
  12. package/dist/assets/asciidoc-DE70LPWp.js +1 -0
  13. package/dist/assets/asm-Cmm7eHzH.js +1 -0
  14. package/dist/assets/astro-D6HwFgiT.js +1 -0
  15. package/dist/assets/aurora-x-CDeNXAV0.js +1 -0
  16. package/dist/assets/awk-BWXHIvNe.js +1 -0
  17. package/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
  18. package/dist/assets/ayu-light-C3h-C4tm.js +1 -0
  19. package/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  20. package/dist/assets/ballerina-B7ZEbQpA.js +1 -0
  21. package/dist/assets/bat-Bo4NYOV-.js +1 -0
  22. package/dist/assets/beancount-D-usSTwE.js +1 -0
  23. package/dist/assets/berry-DKpUyyne.js +1 -0
  24. package/dist/assets/bibtex-Ci_nEsc7.js +1 -0
  25. package/dist/assets/bicep-CUHmPFLl.js +1 -0
  26. package/dist/assets/bird2-C6vDhewU.js +1 -0
  27. package/dist/assets/blade-C4l1V02K.js +1 -0
  28. package/dist/assets/bsl-BkkzgIyY.js +1 -0
  29. package/dist/assets/c-KSOZ_xJw.js +1 -0
  30. package/dist/assets/c3-BFHwR3_K.js +1 -0
  31. package/dist/assets/cadence-CQ2zXKGN.js +1 -0
  32. package/dist/assets/cairo-DLTphjLi.js +1 -0
  33. package/dist/assets/catppuccin-frappe-3VR1Za6u.js +1 -0
  34. package/dist/assets/catppuccin-latte-DwIHMF0Q.js +1 -0
  35. package/dist/assets/catppuccin-macchiato-DYnBP6_5.js +1 -0
  36. package/dist/assets/catppuccin-mocha-DYhrFGRu.js +1 -0
  37. package/dist/assets/clarity-SemFz856.js +1 -0
  38. package/dist/assets/clojure-DqKBuwfJ.js +1 -0
  39. package/dist/assets/cmake-Bj61d0ZC.js +1 -0
  40. package/dist/assets/cobol-DV3uoHgO.js +1 -0
  41. package/dist/assets/codeowners-C8r90Shi.js +1 -0
  42. package/dist/assets/codeql-oeQT6MSM.js +1 -0
  43. package/dist/assets/coffee-BI7IzQLX.js +1 -0
  44. package/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
  45. package/dist/assets/coq-BrsZFFmf.js +1 -0
  46. package/dist/assets/cpp-nwtcw6V-.js +1 -0
  47. package/dist/assets/crystal-Cj7dDBIL.js +1 -0
  48. package/dist/assets/csharp-oqKa8noW.js +1 -0
  49. package/dist/assets/css-Bjl9g7PY.js +1 -0
  50. package/dist/assets/csv-Dx-8-gkx.js +1 -0
  51. package/dist/assets/cue-CE9AQfxI.js +1 -0
  52. package/dist/assets/cypher-ClKdZ_lG.js +1 -0
  53. package/dist/assets/d-qD-0Kul2.js +1 -0
  54. package/dist/assets/dark-plus-Cs2F2srj.js +1 -0
  55. package/dist/assets/dart-CnvKMtbv.js +1 -0
  56. package/dist/assets/dax-BkyTk9wS.js +1 -0
  57. package/dist/assets/desktop-Dlh5hvp9.js +1 -0
  58. package/dist/assets/diff-woXpYk--.js +1 -0
  59. package/dist/assets/docker-IyjqRm3v.js +1 -0
  60. package/dist/assets/dotenv-_5a1GRtc.js +1 -0
  61. package/dist/assets/dracula-BHWKrbxM.js +1 -0
  62. package/dist/assets/dracula-soft-5eyTD99u.js +1 -0
  63. package/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
  64. package/dist/assets/edge-CLyulaNo.js +1 -0
  65. package/dist/assets/elixir-D1qe5nvJ.js +1 -0
  66. package/dist/assets/elm-BFVadj03.js +1 -0
  67. package/dist/assets/emacs-lisp-B4R74twV.js +1 -0
  68. package/dist/assets/erb-DLGpISTK.js +1 -0
  69. package/dist/assets/erlang-Cphh6RMH.js +1 -0
  70. package/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
  71. package/dist/assets/everforest-light-Df2xbC6M.js +1 -0
  72. package/dist/assets/fennel-DQxkIbk2.js +1 -0
  73. package/dist/assets/fish-BJitypiv.js +1 -0
  74. package/dist/assets/fluent-C03EYrpw.js +1 -0
  75. package/dist/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
  76. package/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
  77. package/dist/assets/fsharp-D13ZGOAj.js +1 -0
  78. package/dist/assets/gdresource-C0sCabJj.js +1 -0
  79. package/dist/assets/gdscript-Cp2uCuqX.js +1 -0
  80. package/dist/assets/gdshader-CBce3t8t.js +1 -0
  81. package/dist/assets/genie-CV2tkWYe.js +1 -0
  82. package/dist/assets/gherkin-DExj1W_8.js +1 -0
  83. package/dist/assets/git-commit-BSykSTBG.js +1 -0
  84. package/dist/assets/git-rebase-BAH2F1ja.js +1 -0
  85. package/dist/assets/github-dark-C-LZuMrd.js +1 -0
  86. package/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
  87. package/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  88. package/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  89. package/dist/assets/github-light-EUqPIrTm.js +1 -0
  90. package/dist/assets/github-light-default-BXViO-2h.js +1 -0
  91. package/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  92. package/dist/assets/gleam-CSRkHgEL.js +1 -0
  93. package/dist/assets/glimmer-js-Dd1uIqub.js +1 -0
  94. package/dist/assets/glimmer-ts-DjqoMFcz.js +1 -0
  95. package/dist/assets/glsl-D2nmnYHV.js +1 -0
  96. package/dist/assets/gn-ilITqXS6.js +1 -0
  97. package/dist/assets/gnuplot-7GGW24-e.js +1 -0
  98. package/dist/assets/go-rLFTqkRN.js +1 -0
  99. package/dist/assets/graphql-Cw77QnDJ.js +1 -0
  100. package/dist/assets/groovy-CacY0gHj.js +1 -0
  101. package/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  102. package/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  103. package/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  104. package/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  105. package/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  106. package/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  107. package/dist/assets/hack-Bb9mpV0i.js +1 -0
  108. package/dist/assets/haml-CF8RwEu1.js +1 -0
  109. package/dist/assets/handlebars-D2zFsrot.js +1 -0
  110. package/dist/assets/haskell-D8IpX4py.js +1 -0
  111. package/dist/assets/haxe-OTjmBuCE.js +1 -0
  112. package/dist/assets/hcl-Dh228itO.js +1 -0
  113. package/dist/assets/hjson-CxZEssPk.js +1 -0
  114. package/dist/assets/hlsl-Cvrh5tZx.js +1 -0
  115. package/dist/assets/horizon-CE9ld1lL.js +1 -0
  116. package/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
  117. package/dist/assets/houston-CsvMBhTu.js +1 -0
  118. package/dist/assets/html-DPsTyQ4s.js +1 -0
  119. package/dist/assets/html-derivative-uE_H-K2_.js +1 -0
  120. package/dist/assets/http-BLC6NeYh.js +1 -0
  121. package/dist/assets/hurl-DO46mQZ1.js +1 -0
  122. package/dist/assets/hxml-B0Qn7Nwc.js +1 -0
  123. package/dist/assets/hy-CZbG8q4J.js +1 -0
  124. package/dist/assets/imba-DsUTQ-LC.js +1 -0
  125. package/dist/assets/index-Ba666nDd.css +1 -0
  126. package/dist/assets/index-CnNrrRnN.js +897 -0
  127. package/dist/assets/ini-B5eOa1yu.js +1 -0
  128. package/dist/assets/java-kzUtURfb.js +1 -0
  129. package/dist/assets/javascript-hXYTfjcT.js +1 -0
  130. package/dist/assets/jinja-DW3Ipkk9.js +1 -0
  131. package/dist/assets/jison-Dtk5Sh4F.js +1 -0
  132. package/dist/assets/json-yvvy5IcZ.js +1 -0
  133. package/dist/assets/json5-BR5RXkoi.js +1 -0
  134. package/dist/assets/jsonc-CYpm1nAK.js +1 -0
  135. package/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  136. package/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
  137. package/dist/assets/jssm-DXw9l8Rf.js +1 -0
  138. package/dist/assets/jsx-CXY-Xklo.js +1 -0
  139. package/dist/assets/julia-B-djpp87.js +1 -0
  140. package/dist/assets/just-Dy_P1Mi8.js +1 -0
  141. package/dist/assets/kanagawa-dragon-CXtmUGW6.js +1 -0
  142. package/dist/assets/kanagawa-lotus-BN08jTvb.js +1 -0
  143. package/dist/assets/kanagawa-wave-CTweb8Dz.js +1 -0
  144. package/dist/assets/kdl-CsD5j6eV.js +1 -0
  145. package/dist/assets/kotlin-DhhofPvG.js +1 -0
  146. package/dist/assets/kusto-C7mF5XQf.js +1 -0
  147. package/dist/assets/laserwave-C_8bwKvT.js +1 -0
  148. package/dist/assets/latex-CeRzDjwd.js +1 -0
  149. package/dist/assets/lean-CewbzKMR.js +1 -0
  150. package/dist/assets/less-DVTAwKKz.js +1 -0
  151. package/dist/assets/light-plus-DVQuIRkW.js +1 -0
  152. package/dist/assets/liquid-C5oaVhR-.js +1 -0
  153. package/dist/assets/llvm-Cm23YOpf.js +1 -0
  154. package/dist/assets/log-BNLmms1o.js +1 -0
  155. package/dist/assets/logo-Cluzi2Zq.js +1 -0
  156. package/dist/assets/lua-CzZee3zd.js +1 -0
  157. package/dist/assets/luau-FMPmPwt6.js +1 -0
  158. package/dist/assets/make-Dixweg8N.js +1 -0
  159. package/dist/assets/markdown-BYOwaDjH.js +1 -0
  160. package/dist/assets/marko-DwJR36iE.js +1 -0
  161. package/dist/assets/material-theme-Bm3Qr25_.js +1 -0
  162. package/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
  163. package/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
  164. package/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  165. package/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  166. package/dist/assets/matlab-D7qyCx1q.js +1 -0
  167. package/dist/assets/mdc-Cz5KPxip.js +1 -0
  168. package/dist/assets/mdx-DQZ5AkYe.js +1 -0
  169. package/dist/assets/mermaid-Bk4SNUv9.js +1 -0
  170. package/dist/assets/min-dark-BSWPekZh.js +1 -0
  171. package/dist/assets/min-light-DDpmG2fV.js +1 -0
  172. package/dist/assets/mipsasm-BMqwQI7S.js +1 -0
  173. package/dist/assets/mojo-BgCJLMeH.js +1 -0
  174. package/dist/assets/monokai-CdkpiU2Y.js +1 -0
  175. package/dist/assets/moonbit-CaWjb8XO.js +1 -0
  176. package/dist/assets/move-B1IS1UjX.js +1 -0
  177. package/dist/assets/narrat-_X_XdTYD.js +1 -0
  178. package/dist/assets/nextflow-BJtWHP5T.js +1 -0
  179. package/dist/assets/nextflow-groovy-DJMQeKeT.js +1 -0
  180. package/dist/assets/nginx-DP8PKHqg.js +1 -0
  181. package/dist/assets/night-owl-DhmEMT88.js +1 -0
  182. package/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
  183. package/dist/assets/nim-DLj_luda.js +1 -0
  184. package/dist/assets/nix-IvuFDN5E.js +1 -0
  185. package/dist/assets/nord-Cb4Vim4T.js +1 -0
  186. package/dist/assets/nushell-DcLAeLz5.js +1 -0
  187. package/dist/assets/objective-c-D1A_Heim.js +1 -0
  188. package/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
  189. package/dist/assets/ocaml-O90oeIOV.js +1 -0
  190. package/dist/assets/odin-B1RWQWA5.js +1 -0
  191. package/dist/assets/one-dark-pro-CLwyXe_n.js +1 -0
  192. package/dist/assets/one-light-D7Lr4KcI.js +1 -0
  193. package/dist/assets/openscad-BUDT5pXO.js +1 -0
  194. package/dist/assets/pascal-4ZHwLPI5.js +1 -0
  195. package/dist/assets/perl-gt0YvaTf.js +1 -0
  196. package/dist/assets/php-BPUC88QF.js +1 -0
  197. package/dist/assets/pkl-ot-7Btpt.js +1 -0
  198. package/dist/assets/plastic-DQwYfKfQ.js +1 -0
  199. package/dist/assets/plsql-DGHpHOYJ.js +1 -0
  200. package/dist/assets/po-BiJDBrnU.js +1 -0
  201. package/dist/assets/poimandres-DRFjx7u4.js +1 -0
  202. package/dist/assets/polar-C7UOKdEL.js +1 -0
  203. package/dist/assets/postcss-BXeXVLqQ.js +1 -0
  204. package/dist/assets/powerquery-DNMTfnFr.js +1 -0
  205. package/dist/assets/powershell-DshXNtvi.js +1 -0
  206. package/dist/assets/prisma-BsRQq5mF.js +1 -0
  207. package/dist/assets/prolog-iXnhIJG7.js +1 -0
  208. package/dist/assets/proto-DB4EqR-F.js +1 -0
  209. package/dist/assets/pug-DgFsRPbA.js +1 -0
  210. package/dist/assets/puppet-CDv2pdJW.js +1 -0
  211. package/dist/assets/purescript-9MfHhQsQ.js +1 -0
  212. package/dist/assets/python-gzcpVVnB.js +1 -0
  213. package/dist/assets/qml-C_LfdLXm.js +1 -0
  214. package/dist/assets/qmldir-DCQb3MpD.js +1 -0
  215. package/dist/assets/qss-Fe1Jh2GI.js +1 -0
  216. package/dist/assets/r-Pj2SwcAG.js +1 -0
  217. package/dist/assets/racket-DcIDlBhZ.js +1 -0
  218. package/dist/assets/raku-B3gFvitq.js +1 -0
  219. package/dist/assets/razor-BKc5ThWc.js +1 -0
  220. package/dist/assets/red-CJ3rzSJv.js +1 -0
  221. package/dist/assets/reg-CRGYupPL.js +1 -0
  222. package/dist/assets/regexp-BHCMzRa4.js +1 -0
  223. package/dist/assets/rel-BtDbiS_P.js +1 -0
  224. package/dist/assets/riscv-Ckw8ddFX.js +1 -0
  225. package/dist/assets/ron-VUp2lXgN.js +1 -0
  226. package/dist/assets/rose-pine-BthvhNj6.js +1 -0
  227. package/dist/assets/rose-pine-dawn-Dg85fqjY.js +1 -0
  228. package/dist/assets/rose-pine-moon-hon4tzzS.js +1 -0
  229. package/dist/assets/rosmsg-CAekHB0j.js +1 -0
  230. package/dist/assets/rst-D1Q1H8XX.js +1 -0
  231. package/dist/assets/ruby-aFDqyHwf.js +1 -0
  232. package/dist/assets/rust-Cfkwpbl8.js +1 -0
  233. package/dist/assets/sas-DUQasUvv.js +1 -0
  234. package/dist/assets/sass-DXrisJhu.js +1 -0
  235. package/dist/assets/scala-DKOlJaKm.js +1 -0
  236. package/dist/assets/scheme-DQCgrYNe.js +1 -0
  237. package/dist/assets/scss-DRMVx3p6.js +1 -0
  238. package/dist/assets/sdbl-bTVj8UrX.js +1 -0
  239. package/dist/assets/shaderlab-TOUzSsQk.js +1 -0
  240. package/dist/assets/shellscript-9nE2ns3B.js +1 -0
  241. package/dist/assets/shellsession-Ym61uyCB.js +1 -0
  242. package/dist/assets/slack-dark-DnToyrRv.js +1 -0
  243. package/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
  244. package/dist/assets/smalltalk-B16xEiuN.js +1 -0
  245. package/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
  246. package/dist/assets/solarized-dark-DV17i1UV.js +1 -0
  247. package/dist/assets/solarized-light-DSh2HLQt.js +1 -0
  248. package/dist/assets/solidity-CKzVLygQ.js +1 -0
  249. package/dist/assets/soy-C9F5-hVX.js +1 -0
  250. package/dist/assets/sparql-D_iOobhT.js +1 -0
  251. package/dist/assets/splunk-BC2Px7Mm.js +1 -0
  252. package/dist/assets/sql-DNRGB4_D.js +1 -0
  253. package/dist/assets/ssh-config-BgfXC-Er.js +1 -0
  254. package/dist/assets/stata-9CUIUM5m.js +1 -0
  255. package/dist/assets/stylus-B6D30XZt.js +1 -0
  256. package/dist/assets/surrealql-BKa1jBv8.js +1 -0
  257. package/dist/assets/svelte-l-AzXjIs.js +1 -0
  258. package/dist/assets/swift-DonLKvLd.js +1 -0
  259. package/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
  260. package/dist/assets/system-verilog-DJ5XKQeo.js +1 -0
  261. package/dist/assets/systemd-BxMlprV5.js +1 -0
  262. package/dist/assets/talonscript-CohzipZa.js +1 -0
  263. package/dist/assets/tasl-DMoTqEGO.js +1 -0
  264. package/dist/assets/tcl-CZd0xW_V.js +1 -0
  265. package/dist/assets/templ-CcqG4x7p.js +1 -0
  266. package/dist/assets/terraform-DswuEJGm.js +1 -0
  267. package/dist/assets/tex-DqjZxgcw.js +1 -0
  268. package/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
  269. package/dist/assets/toml-CcmNWLt0.js +1 -0
  270. package/dist/assets/ts-tags-7N5waLHg.js +1 -0
  271. package/dist/assets/tsv-sltzmVWM.js +1 -0
  272. package/dist/assets/tsx-CZDiJHa-.js +1 -0
  273. package/dist/assets/turtle-ByJddavk.js +1 -0
  274. package/dist/assets/twig-DnrUvKgH.js +1 -0
  275. package/dist/assets/typescript-CNLx8Xjf.js +1 -0
  276. package/dist/assets/typespec-BRdr0IET.js +1 -0
  277. package/dist/assets/typst-DI99ib-x.js +1 -0
  278. package/dist/assets/v-DETTlOr0.js +1 -0
  279. package/dist/assets/vala-zf12oZj6.js +1 -0
  280. package/dist/assets/vb-Djn5o6TS.js +1 -0
  281. package/dist/assets/verilog-CiiDBU1e.js +1 -0
  282. package/dist/assets/vesper-DdrHHSXu.js +1 -0
  283. package/dist/assets/vhdl-BroJfC0k.js +1 -0
  284. package/dist/assets/viml-DvXPmvsu.js +1 -0
  285. package/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
  286. package/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
  287. package/dist/assets/vitesse-light-VbXTXTou.js +1 -0
  288. package/dist/assets/vue-St5TuCBW.js +1 -0
  289. package/dist/assets/vue-html-BQ_bILAj.js +1 -0
  290. package/dist/assets/vue-vine-BbKxMI1X.js +1 -0
  291. package/dist/assets/vyper-CgoNMtux.js +1 -0
  292. package/dist/assets/wasm-BnjxR4X6.js +1 -0
  293. package/dist/assets/wasm-ByWQv1Qj.js +1 -0
  294. package/dist/assets/wenyan-C8pVoKbM.js +1 -0
  295. package/dist/assets/wgsl-BsKzXJz4.js +1 -0
  296. package/dist/assets/wikitext-ClFFjSW2.js +1 -0
  297. package/dist/assets/wit-DdvCle-K.js +1 -0
  298. package/dist/assets/wolfram-DLL8P-h_.js +1 -0
  299. package/dist/assets/xml-Bz0xg06z.js +1 -0
  300. package/dist/assets/xsl-DC-2vw27.js +1 -0
  301. package/dist/assets/yaml-m0kezuu_.js +1 -0
  302. package/dist/assets/zenscript-BnlCZFoB.js +1 -0
  303. package/dist/assets/zig-CMLA9XwU.js +1 -0
  304. package/dist/icons.svg +24 -24
  305. package/dist/index.html +29 -13
  306. package/dist/llm-full.txt +298 -0
  307. package/dist/llm.txt +110 -0
  308. package/dist/ui-cli.cjs +1 -1
  309. package/package.json +10 -5
  310. package/registry.json +179 -121
  311. package/scripts/build-cli.mjs +12 -12
  312. package/scripts/build-registry.ts +261 -261
  313. package/scripts/ui-cli.ts +1333 -1333
  314. package/dist/assets/index-1YAQdTE0.css +0 -2
  315. package/dist/assets/index-BsQ6nn74.js +0 -237
  316. package/dist/ui-cli.js +0 -124
package/registry.json CHANGED
@@ -14,19 +14,19 @@
14
14
  "files": [
15
15
  {
16
16
  "path": "src/lib/utils/cn.ts",
17
- "content": "import { clsx, type ClassValue } from \"clsx\";\r\nimport { twMerge } from \"tailwind-merge\";\r\n\r\n/**\r\n * Hàm cn kết hợp giữa:\r\n * 1. clsx: Cho phép truyền class theo kiểu object, array, conditional (true/false)\r\n * 2. tailwind-merge: Đảm bảo các class Tailwind sau cùng sẽ ghi đè các class trước đó một cách chính xác\r\n */\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs));\r\n}"
17
+ "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Hàm cn kết hợp giữa:\n * 1. clsx: Cho phép truyền class theo kiểu object, array, conditional (true/false)\n * 2. tailwind-merge: Đảm bảo các class Tailwind sau cùng sẽ ghi đè các class trước đó một cách chính xác\n */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}"
18
18
  },
19
19
  {
20
20
  "path": "src/styles/index.css",
21
- "content": "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');\r\n@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n/* Thin scrollbar — auto-hide */\r\n* {\r\n scrollbar-width: thin;\r\n scrollbar-color: transparent transparent;\r\n}\r\n\r\n*:hover {\r\n scrollbar-color: var(--border) transparent;\r\n}\r\n\r\n*::-webkit-scrollbar {\r\n width: 4px;\r\n height: 4px;\r\n}\r\n\r\n*::-webkit-scrollbar-track {\r\n background: transparent;\r\n}\r\n\r\n*::-webkit-scrollbar-thumb {\r\n background: transparent;\r\n border-radius: 9999px;\r\n}\r\n\r\n*:hover::-webkit-scrollbar-thumb {\r\n background: var(--border);\r\n}\r\n\r\n*::-webkit-scrollbar-thumb:hover {\r\n background: var(--muted-foreground);\r\n}\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n /* ─── Kraken-inspired radius scale ───────────────────────────────────────── */\r\n --radius-sm: 0.375rem; /* 6px — badges, tags */\r\n --radius-md: 0.5rem; /* 8px — tooltips, popovers */\r\n --radius-lg: 0.75rem; /* 12px — buttons, inputs, cards */\r\n --radius-xl: 1rem; /* 16px — modals, large containers */\r\n\r\n /* ─── Shadow tokens ───────────────────────────────────────────────────────── */\r\n --shadow-subtle: rgba(0,0,0,0.03) 0px 4px 24px;\r\n --shadow-micro: rgba(16,24,40,0.04) 0px 1px 4px;\r\n\r\n /* ─── Font families ───────────────────────────────────────────────────────── */\r\n --font-display: 'IBM Plex Sans', Helvetica, Arial, sans-serif;\r\n --font-ui: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n\r\n --animate-blink: blink 1s step-end infinite;\r\n @keyframes blink {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #101114; /* Kraken Near Black */\r\n --primary: #7132f5; /* Kraken Purple */\r\n --primary-foreground: #ffffff;\r\n --secondary: #ebe5fe; /* Purple Subtle — rgba(133,91,251,0.16) on white */\r\n --secondary-foreground: #5741d8; /* Purple Dark */\r\n --muted: #f8f8fb; /* Cool off-white */\r\n --muted-foreground: #9497a9; /* Kraken Silver Blue */\r\n --accent: #f5f4ff; /* Light purple tint */\r\n --accent-foreground: #101114;\r\n --success: #149e61; /* Kraken Green */\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #dedee5; /* Kraken Border Gray */\r\n --input: #dedee5;\r\n --ring: #7132f5;\r\n --popover: #ffffff;\r\n --popover-foreground: #101114;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #c0c2d1; /* Cool gray toggle */\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #7132f5; /* Kraken Purple */\r\n --chart-3: #149e61; /* Kraken Green */\r\n --chart-4: #f59e0b;\r\n --chart-5: #5741d8; /* Purple Dark */\r\n\r\n --sidebar: #f8f8fb;\r\n --sidebar-foreground: #101114;\r\n --sidebar-border: #dedee5;\r\n --sidebar-accent: #f5f4ff;\r\n --sidebar-accent-foreground: #7132f5;\r\n --sidebar-ring: #7132f5;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme — Kraken-inspired Deep Purple Night */\r\n --background: #0e0c14; /* Near-black with purple tint */\r\n --foreground: #ededf0; /* Off-white */\r\n\r\n --primary: #9b72ff; /* Lighter Kraken Purple — readable on dark */\r\n --primary-foreground: #ffffff;\r\n\r\n --secondary: #1c1929; /* Dark purple-tinted surface */\r\n --secondary-foreground: #c4b0ff; /* Soft lavender */\r\n\r\n --muted: #17151f; /* Deeper surface */\r\n --muted-foreground: #7b7d99; /* Muted silver-blue */\r\n\r\n --accent: #1c1929;\r\n --accent-foreground: #ededf0;\r\n\r\n --switch-background: #3d3860;\r\n\r\n --border: #2a2740; /* Dark purple-tinted border */\r\n --input: #2a2740;\r\n\r\n --success: #1acc72; /* Brighter Kraken green for dark bg */\r\n --success-foreground: #ffffff;\r\n\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n\r\n --danger: #f05555;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #9b72ff;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #9b72ff;\r\n --chart-3: #1acc72;\r\n --chart-4: #fbbf24;\r\n --chart-5: #c4b0ff;\r\n\r\n --popover: #17151f;\r\n --popover-foreground: #ededf0;\r\n\r\n --sidebar: #0b0a11;\r\n --sidebar-foreground: #ededf0;\r\n --sidebar-border: #2a2740;\r\n --sidebar-accent: #1c1929;\r\n --sidebar-accent-foreground: #c4b0ff;\r\n --sidebar-ring: #9b72ff;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: var(--font-ui);\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #c0c2d1; /* Kraken cool gray */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #2a2740;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #9497a9; /* Kraken Silver Blue */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #3d3860;\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #c0c2d1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #2a2740 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
21
+ "content": "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');\n@import \"tailwindcss\";\n@plugin \"tailwindcss-animate\";\n@custom-variant dark (&:where(.dark, .dark *));\n/* Thin scrollbar — auto-hide */\n* {\n scrollbar-width: thin;\n scrollbar-color: transparent transparent;\n}\n\n*:hover {\n scrollbar-color: var(--border) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 4px;\n height: 4px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n background: transparent;\n border-radius: 9999px;\n}\n\n*:hover::-webkit-scrollbar-thumb {\n background: var(--border);\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--muted-foreground);\n}\n\n/*\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\n*/\n::view-transition-old(root),\n::view-transition-new(root) {\n animation: none;\n mix-blend-mode: normal;\n}\n\n::view-transition-old(root) {\n z-index: 1;\n}\n\n::view-transition-new(root) {\n z-index: 9999;\n}\n\n\n@theme {\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\n\n\n\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n\n --color-switch-background: var(--switch-background);\n\n --color-border: var(--border);\n\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n\n --color-destructive: var(--danger);\n --color-destructive-foreground: var(--danger-foreground);\n\n --color-danger: var(--danger);\n --color-danger-foreground: var(--danger-foreground);\n\n --color-ring: var(--ring);\n --color-input: var(--input);\n\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-ring: var(--sidebar-ring);\n\n /* ─── Kraken-inspired radius scale ───────────────────────────────────────── */\n --radius-sm: 0.375rem; /* 6px — badges, tags */\n --radius-md: 0.5rem; /* 8px — tooltips, popovers */\n --radius-lg: 0.75rem; /* 12px — buttons, inputs, cards */\n --radius-xl: 1rem; /* 16px — modals, large containers */\n\n /* ─── Shadow tokens ───────────────────────────────────────────────────────── */\n --shadow-subtle: rgba(0,0,0,0.03) 0px 4px 24px;\n --shadow-micro: rgba(16,24,40,0.04) 0px 1px 4px;\n\n /* ─── Font families ───────────────────────────────────────────────────────── */\n --font-display: 'IBM Plex Sans', Helvetica, Arial, sans-serif;\n --font-ui: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;\n\n /* Z-index scale */\n --z-dropdown: 50;\n --z-sticky: 100;\n --z-overlay: 200;\n --z-modal: 300;\n --z-popover: 400;\n --z-toast: 500;\n\n --animate-spin-slow: spin 3s linear infinite;\n --animate-progress-stripes: progress-stripes 1s linear infinite;\n\n @keyframes progress-stripes {\n from { background-position: 1rem 0; }\n to { background-position: 0 0; }\n }\n\n --animate-blink: blink 1s step-end infinite;\n @keyframes blink {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0; }\n }\n}\n\n@layer base {\n :root {\n /* GENERATED:theme-start */\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\n --background: #ffffff;\n --foreground: #101114; /* Kraken Near Black */\n --primary: #7132f5; /* Kraken Purple */\n --primary-foreground: #ffffff;\n --secondary: #ebe5fe; /* Purple Subtle — rgba(133,91,251,0.16) on white */\n --secondary-foreground: #5741d8; /* Purple Dark */\n --muted: #f8f8fb; /* Cool off-white */\n --muted-foreground: #9497a9; /* Kraken Silver Blue */\n --accent: #f5f4ff; /* Light purple tint */\n --accent-foreground: #101114;\n --success: #149e61; /* Kraken Green */\n --success-foreground: #ffffff;\n --warning: #f59e0b;\n --warning-foreground: #ffffff;\n --danger: #ef4444;\n --danger-foreground: #ffffff;\n --destructive: #ef4444;\n --destructive-foreground: #ffffff;\n --border: #dedee5; /* Kraken Border Gray */\n --input: #dedee5;\n --ring: #7132f5;\n --popover: #ffffff;\n --popover-foreground: #101114;\n /* GENERATED:theme-end */\n\n /* Non-theme tokens (not managed by applyTheme) */\n --switch-background: #c0c2d1; /* Cool gray toggle */\n\n --chart-1: #e11d48;\n --chart-2: #7132f5; /* Kraken Purple */\n --chart-3: #149e61; /* Kraken Green */\n --chart-4: #f59e0b;\n --chart-5: #5741d8; /* Purple Dark */\n\n --sidebar: #f8f8fb;\n --sidebar-foreground: #101114;\n --sidebar-border: #dedee5;\n --sidebar-accent: #f5f4ff;\n --sidebar-accent-foreground: #7132f5;\n --sidebar-ring: #7132f5;\n }\n\n .dark {\n /* Dark Theme — Kraken-inspired Deep Purple Night */\n --background: #0e0c14; /* Near-black with purple tint */\n --foreground: #ededf0; /* Off-white */\n\n --primary: #9b72ff; /* Lighter Kraken Purple — readable on dark */\n --primary-foreground: #ffffff;\n\n --secondary: #1c1929; /* Dark purple-tinted surface */\n --secondary-foreground: #c4b0ff; /* Soft lavender */\n\n --muted: #17151f; /* Deeper surface */\n --muted-foreground: #7b7d99; /* Muted silver-blue */\n\n --accent: #1c1929;\n --accent-foreground: #ededf0;\n\n --switch-background: #3d3860;\n\n --border: #2a2740; /* Dark purple-tinted border */\n --input: #2a2740;\n\n --success: #1acc72; /* Brighter Kraken green for dark bg */\n --success-foreground: #ffffff;\n\n --warning: #f59e0b;\n --warning-foreground: #ffffff;\n\n --danger: #f05555;\n --danger-foreground: #ffffff;\n\n --ring: #9b72ff;\n\n --chart-1: #fb7185;\n --chart-2: #9b72ff;\n --chart-3: #1acc72;\n --chart-4: #fbbf24;\n --chart-5: #c4b0ff;\n\n --popover: #17151f;\n --popover-foreground: #ededf0;\n\n --sidebar: #0b0a11;\n --sidebar-foreground: #ededf0;\n --sidebar-border: #2a2740;\n --sidebar-accent: #1c1929;\n --sidebar-accent-foreground: #c4b0ff;\n --sidebar-ring: #9b72ff;\n }\n}\n\n\n@layer base {\n * {\n border-color: var(--border);\n }\n\n html, body {\n margin: 0;\n padding: 0;\n background-color: var(--background);\n color: var(--foreground);\n font-family: var(--font-ui);\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\n height: 100%;\n color-scheme: light;\n transition: background-color 0.3s ease, color 0.3s ease;\n }\n\n html.dark {\n color-scheme: dark;\n }\n\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\n body[style*=\"overflow: hidden\"],\n body[data-scroll-locked] {\n padding-right: 0 !important;\n margin-right: 0 !important;\n }\n\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\n [data-base-ui-dialog-backdrop],\n .base-ui-backdrop,\n [role=\"presentation\"] > div[style*=\"fixed\"] {\n width: 100vw !important;\n height: 100vh !important;\n left: 0 !important;\n top: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n }\n\n /* Custom Scrollbar - Sleek & Modern */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n\n ::-webkit-scrollbar-thumb {\n background: #c0c2d1; /* Kraken cool gray */\n border-radius: 10px;\n border: 2px solid transparent;\n background-clip: content-box;\n }\n\n .dark ::-webkit-scrollbar-thumb {\n background: #2a2740;\n }\n\n ::-webkit-scrollbar-thumb:hover {\n background: #9497a9; /* Kraken Silver Blue */\n background-clip: content-box;\n }\n\n .dark ::-webkit-scrollbar-thumb:hover {\n background: #3d3860;\n }\n\n /* Firefox */\n * {\n scrollbar-width: thin;\n scrollbar-color: #c0c2d1 transparent;\n }\n\n .dark * {\n scrollbar-color: #2a2740 transparent;\n }\n}\n\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\n Respect prefers-reduced-motion for a11y.\n Disables all animations and transitions globally when the user's OS\n requests reduced motion. Individual components can opt-out via\n motion-reduce:* utilities if an animation is essential.\n*/\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n scroll-behavior: auto !important;\n }\n}\n\n/* Ensure data-state=\"checked\" always works for background */\n[data-state=\"checked\"] {\n &.bg-primary {\n background-color: var(--primary);\n }\n}\n\n[data-state=\"indeterminate\"] {\n &.bg-primary {\n background-color: var(--primary);\n }\n}"
22
22
  },
23
23
  {
24
24
  "path": "src/lib/theme/themes.ts",
25
- "content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\r\n\r\nexport interface ThemeColors {\r\n // Surface\r\n background: string;\r\n foreground: string;\r\n // Brand\r\n primary: string;\r\n primaryForeground: string;\r\n // Secondary surface\r\n secondary: string;\r\n secondaryForeground: string;\r\n // Muted surface\r\n muted: string;\r\n mutedForeground: string;\r\n // Accent / hover surface\r\n accent: string;\r\n accentForeground: string;\r\n // Semantic states\r\n success: string;\r\n successForeground: string;\r\n warning: string;\r\n warningForeground: string;\r\n danger: string;\r\n dangerForeground: string;\r\n // Form / input\r\n border: string;\r\n input: string;\r\n ring: string;\r\n // Popover / overlay\r\n popover: string;\r\n popoverForeground: string;\r\n}\r\n\r\nexport interface Theme {\r\n name: string;\r\n label: string;\r\n colors: ThemeColors;\r\n}\r\n\r\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\r\n\r\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\r\n\r\nexport const themes: Theme[] = [\r\n // ─── Default: Kraken Design System ────────────────────────────────────────────\r\n {\r\n name: 'indigo',\r\n label: 'Indigo (Default)',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114', // Kraken Near Black\r\n primary: '#7132f5', // Kraken Purple\r\n primaryForeground: '#ffffff',\r\n secondary: '#ebe5fe', // Purple Subtle (~rgba(133,91,251,0.16) on white)\r\n secondaryForeground: '#5741d8', // Purple Dark\r\n muted: '#f8f8fb', // Cool off-white\r\n mutedForeground: '#9497a9', // Kraken Silver Blue\r\n accent: '#f5f4ff', // Light purple tint\r\n accentForeground: '#101114',\r\n success: '#149e61', // Kraken Green\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dedee5', // Kraken Border Gray\r\n input: '#dedee5',\r\n ring: '#7132f5',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Blue — same contrast standard as Kraken ──────────────────────────────────\r\n {\r\n name: 'blue',\r\n label: 'Blue',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#2563eb',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dbeafe',\r\n secondaryForeground: '#1d4ed8',\r\n muted: '#f8f9fc',\r\n mutedForeground: '#9497a9', // same contrast standard\r\n accent: '#eff6ff',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dde5f0',\r\n input: '#dde5f0',\r\n ring: '#2563eb',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Violet ────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'violet',\r\n label: 'Violet',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#7c3aed',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ede9fe',\r\n secondaryForeground: '#5b21b6',\r\n muted: '#f5f3ff',\r\n mutedForeground: '#9491c4', // violet-tinted, same relative lightness\r\n accent: '#f5f3ff',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#ddd6fe',\r\n input: '#ddd6fe',\r\n ring: '#7c3aed',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Rose ──────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'rose',\r\n label: 'Rose',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#e11d48',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffe4e6',\r\n secondaryForeground: '#9f1239',\r\n muted: '#fff1f2',\r\n mutedForeground: '#9e9099', // rose-tinted silver\r\n accent: '#fff1f2',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#f0d5d9',\r\n input: '#f0d5d9',\r\n ring: '#e11d48',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Emerald ───────────────────────────────────────────────────────────────────\r\n {\r\n name: 'emerald',\r\n label: 'Emerald',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#059669',\r\n primaryForeground: '#ffffff',\r\n secondary: '#d1fae5',\r\n secondaryForeground: '#065f46',\r\n muted: '#ecfdf5',\r\n mutedForeground: '#7a9e8d', // emerald-tinted silver\r\n accent: '#ecfdf5',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#c5e8d9',\r\n input: '#c5e8d9',\r\n ring: '#059669',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Orange ────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'orange',\r\n label: 'Orange',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#ea580c',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffedd5',\r\n secondaryForeground: '#9a3412',\r\n muted: '#fff7ed',\r\n mutedForeground: '#9e9087', // warm-tinted silver\r\n accent: '#fff7ed',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#f5d9b8',\r\n input: '#f5d9b8',\r\n ring: '#ea580c',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Slate ─────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'slate',\r\n label: 'Slate',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#475569',\r\n primaryForeground: '#ffffff',\r\n secondary: '#f1f5f9',\r\n secondaryForeground: '#334155',\r\n muted: '#f8f9fc',\r\n mutedForeground: '#9497a9',\r\n accent: '#f1f5f9',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dedee5',\r\n input: '#dedee5',\r\n ring: '#475569',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n];\r\n\r\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\r\n\r\nconst THEME_STYLE_ID = 'basuicn-theme';\r\n\r\n/**\r\n * Applies a theme by injecting a <style> tag at the START of <head>.\r\n *\r\n * Why <style> tag instead of element.style.setProperty():\r\n * Inline styles have the highest CSS specificity and would override\r\n * .dark { } class rules, breaking dark mode. A <style> tag injected\r\n * before the app's CSS bundle has lower specificity than .dark { }\r\n * rules defined later in the bundle — so dark mode always wins.\r\n */\r\nexport function applyTheme(theme: Theme) {\r\n if (typeof window === 'undefined') return;\r\n if (!theme?.colors) return;\r\n const { colors: c } = theme;\r\n\r\n const css = `\r\n:root:not(.dark) {\r\n --background: ${c.background};\r\n --foreground: ${c.foreground};\r\n --primary: ${c.primary};\r\n --primary-foreground: ${c.primaryForeground};\r\n --secondary: ${c.secondary};\r\n --secondary-foreground: ${c.secondaryForeground};\r\n --muted: ${c.muted};\r\n --muted-foreground: ${c.mutedForeground};\r\n --accent: ${c.accent};\r\n --accent-foreground: ${c.accentForeground};\r\n --success: ${c.success};\r\n --success-foreground: ${c.successForeground};\r\n --warning: ${c.warning};\r\n --warning-foreground: ${c.warningForeground};\r\n --danger: ${c.danger};\r\n --danger-foreground: ${c.dangerForeground};\r\n --destructive: ${c.danger};\r\n --destructive-foreground: ${c.dangerForeground};\r\n --border: ${c.border};\r\n --input: ${c.input};\r\n --ring: ${c.ring};\r\n --popover: ${c.popover};\r\n --popover-foreground: ${c.popoverForeground};\r\n}`.trim();\r\n\r\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\r\n if (!styleEl) {\r\n styleEl = document.createElement('style');\r\n styleEl.id = THEME_STYLE_ID;\r\n document.head.appendChild(styleEl);\r\n }\r\n styleEl.textContent = css;\r\n}\r\n\r\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\r\n\r\n/**\r\n * Converts a Theme's colors into a CSS `:root { }` block string.\r\n * Used by scripts to generate or sync CSS.\r\n */\r\nexport function toCssVars(theme: Theme): string {\r\n const { colors: c } = theme;\r\n const vars: [string, string][] = [\r\n ['--background', c.background],\r\n ['--foreground', c.foreground],\r\n ['--primary', c.primary],\r\n ['--primary-foreground', c.primaryForeground],\r\n ['--secondary', c.secondary],\r\n ['--secondary-foreground', c.secondaryForeground],\r\n ['--muted', c.muted],\r\n ['--muted-foreground', c.mutedForeground],\r\n ['--accent', c.accent],\r\n ['--accent-foreground', c.accentForeground],\r\n ['--success', c.success],\r\n ['--success-foreground', c.successForeground],\r\n ['--warning', c.warning],\r\n ['--warning-foreground', c.warningForeground],\r\n ['--danger', c.danger],\r\n ['--danger-foreground', c.dangerForeground],\r\n ['--destructive', c.danger],\r\n ['--destructive-foreground', c.dangerForeground],\r\n ['--border', c.border],\r\n ['--input', c.input],\r\n ['--ring', c.ring],\r\n ['--popover', c.popover],\r\n ['--popover-foreground', c.popoverForeground],\r\n ];\r\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\r\n return `:root {\\n${body}\\n }`;\r\n}\r\n\r\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\r\n\r\n/**\r\n * Creates a custom theme by merging overrides with the default (indigo) theme.\r\n *\r\n * @example\r\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\r\n */\r\nexport function createTheme(\r\n name: string,\r\n label: string,\r\n colors: Partial<ThemeColors>\r\n): Theme {\r\n const base = themes[0]; // indigo as default base\r\n return {\r\n name,\r\n label,\r\n colors: { ...base.colors, ...colors },\r\n };\r\n}\r\n"
25
+ "content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\n\nexport interface ThemeColors {\n // Surface\n background: string;\n foreground: string;\n // Brand\n primary: string;\n primaryForeground: string;\n // Secondary surface\n secondary: string;\n secondaryForeground: string;\n // Muted surface\n muted: string;\n mutedForeground: string;\n // Accent / hover surface\n accent: string;\n accentForeground: string;\n // Semantic states\n success: string;\n successForeground: string;\n warning: string;\n warningForeground: string;\n danger: string;\n dangerForeground: string;\n // Form / input\n border: string;\n input: string;\n ring: string;\n // Popover / overlay\n popover: string;\n popoverForeground: string;\n}\n\nexport interface Theme {\n name: string;\n label: string;\n colors: ThemeColors;\n}\n\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\n\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\n\nexport const themes: Theme[] = [\n // ─── Default: Kraken Design System ────────────────────────────────────────────\n {\n name: 'indigo',\n label: 'Indigo (Default)',\n colors: {\n background: '#ffffff',\n foreground: '#101114', // Kraken Near Black\n primary: '#7132f5', // Kraken Purple\n primaryForeground: '#ffffff',\n secondary: '#ebe5fe', // Purple Subtle (~rgba(133,91,251,0.16) on white)\n secondaryForeground: '#5741d8', // Purple Dark\n muted: '#f8f8fb', // Cool off-white\n mutedForeground: '#9497a9', // Kraken Silver Blue\n accent: '#f5f4ff', // Light purple tint\n accentForeground: '#101114',\n success: '#149e61', // Kraken Green\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#dedee5', // Kraken Border Gray\n input: '#dedee5',\n ring: '#7132f5',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Blue — same contrast standard as Kraken ──────────────────────────────────\n {\n name: 'blue',\n label: 'Blue',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#2563eb',\n primaryForeground: '#ffffff',\n secondary: '#dbeafe',\n secondaryForeground: '#1d4ed8',\n muted: '#f8f9fc',\n mutedForeground: '#9497a9', // same contrast standard\n accent: '#eff6ff',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#dde5f0',\n input: '#dde5f0',\n ring: '#2563eb',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Violet ────────────────────────────────────────────────────────────────────\n {\n name: 'violet',\n label: 'Violet',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#7c3aed',\n primaryForeground: '#ffffff',\n secondary: '#ede9fe',\n secondaryForeground: '#5b21b6',\n muted: '#f5f3ff',\n mutedForeground: '#9491c4', // violet-tinted, same relative lightness\n accent: '#f5f3ff',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#ddd6fe',\n input: '#ddd6fe',\n ring: '#7c3aed',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Rose ──────────────────────────────────────────────────────────────────────\n {\n name: 'rose',\n label: 'Rose',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#e11d48',\n primaryForeground: '#ffffff',\n secondary: '#ffe4e6',\n secondaryForeground: '#9f1239',\n muted: '#fff1f2',\n mutedForeground: '#9e9099', // rose-tinted silver\n accent: '#fff1f2',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#f0d5d9',\n input: '#f0d5d9',\n ring: '#e11d48',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Emerald ───────────────────────────────────────────────────────────────────\n {\n name: 'emerald',\n label: 'Emerald',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#059669',\n primaryForeground: '#ffffff',\n secondary: '#d1fae5',\n secondaryForeground: '#065f46',\n muted: '#ecfdf5',\n mutedForeground: '#7a9e8d', // emerald-tinted silver\n accent: '#ecfdf5',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#c5e8d9',\n input: '#c5e8d9',\n ring: '#059669',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Orange ────────────────────────────────────────────────────────────────────\n {\n name: 'orange',\n label: 'Orange',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#ea580c',\n primaryForeground: '#ffffff',\n secondary: '#ffedd5',\n secondaryForeground: '#9a3412',\n muted: '#fff7ed',\n mutedForeground: '#9e9087', // warm-tinted silver\n accent: '#fff7ed',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#f5d9b8',\n input: '#f5d9b8',\n ring: '#ea580c',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n\n // ─── Slate ─────────────────────────────────────────────────────────────────────\n {\n name: 'slate',\n label: 'Slate',\n colors: {\n background: '#ffffff',\n foreground: '#101114',\n primary: '#475569',\n primaryForeground: '#ffffff',\n secondary: '#f1f5f9',\n secondaryForeground: '#334155',\n muted: '#f8f9fc',\n mutedForeground: '#9497a9',\n accent: '#f1f5f9',\n accentForeground: '#101114',\n success: '#149e61',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#dedee5',\n input: '#dedee5',\n ring: '#475569',\n popover: '#ffffff',\n popoverForeground: '#101114',\n },\n },\n];\n\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\n\nconst THEME_STYLE_ID = 'basuicn-theme';\n\n/**\n * Applies a theme by injecting a <style> tag at the START of <head>.\n *\n * Why <style> tag instead of element.style.setProperty():\n * Inline styles have the highest CSS specificity and would override\n * .dark { } class rules, breaking dark mode. A <style> tag injected\n * before the app's CSS bundle has lower specificity than .dark { }\n * rules defined later in the bundle — so dark mode always wins.\n */\nexport function applyTheme(theme: Theme) {\n if (typeof window === 'undefined') return;\n if (!theme?.colors) return;\n const { colors: c } = theme;\n\n const css = `\n:root:not(.dark) {\n --background: ${c.background};\n --foreground: ${c.foreground};\n --primary: ${c.primary};\n --primary-foreground: ${c.primaryForeground};\n --secondary: ${c.secondary};\n --secondary-foreground: ${c.secondaryForeground};\n --muted: ${c.muted};\n --muted-foreground: ${c.mutedForeground};\n --accent: ${c.accent};\n --accent-foreground: ${c.accentForeground};\n --success: ${c.success};\n --success-foreground: ${c.successForeground};\n --warning: ${c.warning};\n --warning-foreground: ${c.warningForeground};\n --danger: ${c.danger};\n --danger-foreground: ${c.dangerForeground};\n --destructive: ${c.danger};\n --destructive-foreground: ${c.dangerForeground};\n --border: ${c.border};\n --input: ${c.input};\n --ring: ${c.ring};\n --popover: ${c.popover};\n --popover-foreground: ${c.popoverForeground};\n}`.trim();\n\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\n if (!styleEl) {\n styleEl = document.createElement('style');\n styleEl.id = THEME_STYLE_ID;\n document.head.appendChild(styleEl);\n }\n styleEl.textContent = css;\n}\n\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\n\n/**\n * Converts a Theme's colors into a CSS `:root { }` block string.\n * Used by scripts to generate or sync CSS.\n */\nexport function toCssVars(theme: Theme): string {\n const { colors: c } = theme;\n const vars: [string, string][] = [\n ['--background', c.background],\n ['--foreground', c.foreground],\n ['--primary', c.primary],\n ['--primary-foreground', c.primaryForeground],\n ['--secondary', c.secondary],\n ['--secondary-foreground', c.secondaryForeground],\n ['--muted', c.muted],\n ['--muted-foreground', c.mutedForeground],\n ['--accent', c.accent],\n ['--accent-foreground', c.accentForeground],\n ['--success', c.success],\n ['--success-foreground', c.successForeground],\n ['--warning', c.warning],\n ['--warning-foreground', c.warningForeground],\n ['--danger', c.danger],\n ['--danger-foreground', c.dangerForeground],\n ['--destructive', c.danger],\n ['--destructive-foreground', c.dangerForeground],\n ['--border', c.border],\n ['--input', c.input],\n ['--ring', c.ring],\n ['--popover', c.popover],\n ['--popover-foreground', c.popoverForeground],\n ];\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\n return `:root {\\n${body}\\n }`;\n}\n\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\n\n/**\n * Creates a custom theme by merging overrides with the default (indigo) theme.\n *\n * @example\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\n */\nexport function createTheme(\n name: string,\n label: string,\n colors: Partial<ThemeColors>\n): Theme {\n const base = themes[0]; // indigo as default base\n return {\n name,\n label,\n colors: { ...base.colors, ...colors },\n };\n}\n"
26
26
  },
27
27
  {
28
28
  "path": "src/lib/theme/ThemeProvider.tsx",
29
- "content": "'use client';\r\n\r\nimport { createContext, useContext, useState, useEffect, type ReactNode } from 'react';\r\nimport { themes, applyTheme, createTheme, type Theme, type ThemeColors, type BuiltInThemeName } from './themes';\r\n\r\nconst STORAGE_KEY = 'ui-theme';\r\n\r\n/**\r\n * Union type for autocomplete suggestions while allowing any string.\r\n */\r\nexport type ThemeName = BuiltInThemeName | (string & {});\r\n\r\ninterface ThemeContextValue {\r\n currentTheme: Theme;\r\n setTheme: (name: ThemeName) => void;\r\n /** Register and switch to a custom theme */\r\n setCustomTheme: (name: string, label: string, colors: Partial<ThemeColors>) => void;\r\n themes: Theme[];\r\n}\r\n\r\nconst ThemeContext = createContext<ThemeContextValue>({\r\n currentTheme: themes[0],\r\n setTheme: () => {},\r\n setCustomTheme: () => {},\r\n themes,\r\n});\r\n\r\ninterface ThemeProviderProps {\r\n children: ReactNode;\r\n defaultTheme?: ThemeName;\r\n}\r\n\r\nconst getStoredTheme = (allThemes: Theme[], defaultThemeName?: ThemeName): Theme => {\r\n if (typeof window === 'undefined') return allThemes[0];\r\n // defaultTheme prop in code takes priority over localStorage\r\n if (defaultThemeName) {\r\n return allThemes.find(t => t.name === defaultThemeName) ?? allThemes[0];\r\n }\r\n try {\r\n const saved = localStorage.getItem(STORAGE_KEY);\r\n if (saved) {\r\n return allThemes.find(t => t.name === saved) ?? allThemes[0];\r\n }\r\n return allThemes[0];\r\n } catch {\r\n return allThemes[0];\r\n }\r\n};\r\n\r\nexport function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) {\r\n const [allThemes, setAllThemes] = useState<Theme[]>(themes);\r\n const [currentTheme, setCurrentTheme] = useState<Theme>(() => getStoredTheme(themes, defaultTheme));\r\n\r\n useEffect(() => {\r\n applyTheme(currentTheme);\r\n }, [currentTheme]);\r\n\r\n const setTheme = (name: ThemeName) => {\r\n const found = allThemes.find(t => t.name === name);\r\n if (!found) return;\r\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\r\n setCurrentTheme(found);\r\n };\r\n\r\n const setCustomTheme = (name: string, label: string, colors: Partial<ThemeColors>) => {\r\n const custom = createTheme(name, label, colors);\r\n setAllThemes(prev => {\r\n const exists = prev.findIndex(t => t.name === name);\r\n return exists >= 0\r\n ? prev.map(t => t.name === name ? custom : t)\r\n : [...prev, custom];\r\n });\r\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\r\n setCurrentTheme(custom);\r\n };\r\n\r\n return (\r\n <ThemeContext.Provider value={{ currentTheme, setTheme, setCustomTheme, themes: allThemes }}>\r\n {children}\r\n </ThemeContext.Provider>\r\n );\r\n}\r\n\r\nexport const useTheme = () => useContext(ThemeContext);\r\n"
29
+ "content": "'use client';\n\nimport { createContext, useContext, useState, useEffect, type ReactNode } from 'react';\nimport { themes, applyTheme, createTheme, type Theme, type ThemeColors, type BuiltInThemeName } from './themes';\n\nconst STORAGE_KEY = 'ui-theme';\n\n/**\n * Union type for autocomplete suggestions while allowing any string.\n */\nexport type ThemeName = BuiltInThemeName | (string & {});\n\ninterface ThemeContextValue {\n currentTheme: Theme;\n setTheme: (name: ThemeName) => void;\n /** Register and switch to a custom theme */\n setCustomTheme: (name: string, label: string, colors: Partial<ThemeColors>) => void;\n themes: Theme[];\n}\n\nconst ThemeContext = createContext<ThemeContextValue>({\n currentTheme: themes[0],\n setTheme: () => {},\n setCustomTheme: () => {},\n themes,\n});\n\ninterface ThemeProviderProps {\n children: ReactNode;\n defaultTheme?: ThemeName;\n}\n\nconst getStoredTheme = (allThemes: Theme[], defaultThemeName?: ThemeName): Theme => {\n if (typeof window === 'undefined') return allThemes[0];\n // defaultTheme prop in code takes priority over localStorage\n if (defaultThemeName) {\n return allThemes.find(t => t.name === defaultThemeName) ?? allThemes[0];\n }\n try {\n const saved = localStorage.getItem(STORAGE_KEY);\n if (saved) {\n return allThemes.find(t => t.name === saved) ?? allThemes[0];\n }\n return allThemes[0];\n } catch {\n return allThemes[0];\n }\n};\n\nexport function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) {\n const [allThemes, setAllThemes] = useState<Theme[]>(themes);\n const [currentTheme, setCurrentTheme] = useState<Theme>(() => getStoredTheme(themes, defaultTheme));\n\n useEffect(() => {\n applyTheme(currentTheme);\n }, [currentTheme]);\n\n const setTheme = (name: ThemeName) => {\n const found = allThemes.find(t => t.name === name);\n if (!found) return;\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\n setCurrentTheme(found);\n };\n\n const setCustomTheme = (name: string, label: string, colors: Partial<ThemeColors>) => {\n const custom = createTheme(name, label, colors);\n setAllThemes(prev => {\n const exists = prev.findIndex(t => t.name === name);\n return exists >= 0\n ? prev.map(t => t.name === name ? custom : t)\n : [...prev, custom];\n });\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\n setCurrentTheme(custom);\n };\n\n return (\n <ThemeContext.Provider value={{ currentTheme, setTheme, setCustomTheme, themes: allThemes }}>\n {children}\n </ThemeContext.Provider>\n );\n}\n\nexport const useTheme = () => useContext(ThemeContext);\n"
30
30
  }
31
31
  ]
32
32
  },
@@ -42,7 +42,7 @@
42
42
  "files": [
43
43
  {
44
44
  "path": "src/components/ui/accordion/Accordion.tsx",
45
- "content": "import * as React from 'react';\r\nimport { Accordion as BaseAccordion } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst accordionVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n item: 'border-b border-border/50 last:border-0',\r\n header: 'flex',\r\n trigger: 'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:text-primary hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n }\r\n});\r\n\r\nconst { root, item, header, trigger, panel } = accordionVariants();\r\n\r\nexport const Accordion = React.forwardRef<React.ElementRef<typeof BaseAccordion.Root>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Root>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Root ref={ref} className={root({ className })} {...props} />\r\n )\r\n)\r\nAccordion.displayName = 'Accordion';\r\n\r\nexport const AccordionItem = React.forwardRef<React.ElementRef<typeof BaseAccordion.Item>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Item>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Item ref={ref} className={item({ className })} {...props} />\r\n )\r\n)\r\nAccordionItem.displayName = 'AccordionItem';\r\n\r\nexport const AccordionTrigger = React.forwardRef<React.ElementRef<typeof BaseAccordion.Trigger>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Trigger>, 'className'> & { className?: string; /** Hide the default chevron icon */hideChevron?: boolean }>(\r\n ({ className, children, hideChevron, ...props }, ref) => (\r\n <BaseAccordion.Header className={header()}>\r\n <BaseAccordion.Trigger ref={ref} className={trigger({ className })} {...props}>\r\n {children}\r\n {!hideChevron && <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />}\r\n </BaseAccordion.Trigger>\r\n </BaseAccordion.Header>\r\n )\r\n)\r\nAccordionTrigger.displayName = 'AccordionTrigger';\r\n\r\nexport const AccordionContent = React.forwardRef<React.ElementRef<typeof BaseAccordion.Panel>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Panel>, 'className'> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <BaseAccordion.Panel ref={ref} className={panel({ className })} {...props}>\r\n <div className=\"pb-4 pt-0\">{children}</div>\r\n </BaseAccordion.Panel>\r\n )\r\n)\r\nAccordionContent.displayName = 'AccordionContent';\r\n"
45
+ "content": "import * as React from 'react';\nimport { Accordion as BaseAccordion } from '@base-ui/react';\nimport { ChevronDown } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\n\nconst accordionVariants = tv({\n slots: {\n root: 'w-full',\n item: 'border-b border-border/50 last:border-0',\n header: 'flex',\n trigger: 'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:text-primary hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 [&[data-panel-open]>svg]:rotate-180',\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\n }\n});\n\nconst { root, item, header, trigger, panel } = accordionVariants();\n\nexport const Accordion = React.forwardRef<React.ElementRef<typeof BaseAccordion.Root>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Root>, 'className'> & { className?: string }>(\n ({ className, ...props }, ref) => (\n <BaseAccordion.Root ref={ref} className={root({ className })} {...props} />\n )\n)\nAccordion.displayName = 'Accordion';\n\nexport const AccordionItem = React.forwardRef<React.ElementRef<typeof BaseAccordion.Item>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Item>, 'className'> & { className?: string }>(\n ({ className, ...props }, ref) => (\n <BaseAccordion.Item ref={ref} className={item({ className })} {...props} />\n )\n)\nAccordionItem.displayName = 'AccordionItem';\n\nexport const AccordionTrigger = React.forwardRef<React.ElementRef<typeof BaseAccordion.Trigger>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Trigger>, 'className'> & { className?: string; /** Hide the default chevron icon */hideChevron?: boolean }>(\n ({ className, children, hideChevron, ...props }, ref) => (\n <BaseAccordion.Header className={header()}>\n <BaseAccordion.Trigger ref={ref} className={trigger({ className })} {...props}>\n {children}\n {!hideChevron && <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />}\n </BaseAccordion.Trigger>\n </BaseAccordion.Header>\n )\n)\nAccordionTrigger.displayName = 'AccordionTrigger';\n\nexport const AccordionContent = React.forwardRef<React.ElementRef<typeof BaseAccordion.Panel>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Panel>, 'className'> & { className?: string }>(\n ({ className, children, ...props }, ref) => (\n <BaseAccordion.Panel ref={ref} className={panel({ className })} {...props}>\n <div className=\"pb-4 pt-0\">{children}</div>\n </BaseAccordion.Panel>\n )\n)\nAccordionContent.displayName = 'AccordionContent';\n"
46
46
  }
47
47
  ]
48
48
  },
@@ -55,7 +55,7 @@
55
55
  "files": [
56
56
  {
57
57
  "path": "src/components/ui/alert/Alert.tsx",
58
- "content": "import * as React from 'react';\r\n// Icons can be passed as children by the consumer\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst alertVariants = tv({\r\n // Kraken: rounded-lg (12px), subtle soft backgrounds\r\n base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',\r\n variants: {\r\n variant: {\r\n default: 'bg-background text-foreground border-border',\r\n destructive: 'border-danger/30 bg-danger/[0.06] text-danger [&>svg]:text-danger',\r\n success: 'border-success/30 bg-success/[0.06] text-success [&>svg]:text-success',\r\n warning: 'border-warning/30 bg-warning/[0.06] text-warning [&>svg]:text-warning',\r\n info: 'border-primary/30 bg-primary/[0.06] text-primary [&>svg]:text-primary',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Alert component */\r\ntype AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>;\r\n\r\nconst Alert = React.forwardRef<HTMLDivElement, AlertProps>(\r\n ({ className, variant, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role=\"alert\"\r\n className={alertVariants({ variant, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlert.displayName = 'Alert';\r\n\r\nconst AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h5\r\n ref={ref}\r\n className={cn('mb-1 font-medium leading-none tracking-tight', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertTitle.displayName = 'AlertTitle';\r\n\r\nconst AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('text-sm [&_p]:leading-relaxed', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertDescription.displayName = 'AlertDescription';\r\n\r\nexport { Alert, AlertTitle, AlertDescription };\r\n"
58
+ "content": "import * as React from 'react';\n// Icons can be passed as children by the consumer\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst alertVariants = tv({\n // Kraken: rounded-lg (12px), subtle soft backgrounds\n base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',\n variants: {\n variant: {\n default: 'bg-background text-foreground border-border',\n destructive: 'border-danger/30 bg-danger/[0.06] text-danger [&>svg]:text-danger',\n success: 'border-success/30 bg-success/[0.06] text-success [&>svg]:text-success',\n warning: 'border-warning/30 bg-warning/[0.06] text-warning [&>svg]:text-warning',\n info: 'border-primary/30 bg-primary/[0.06] text-primary [&>svg]:text-primary',\n }\n },\n defaultVariants: {\n variant: 'default'\n }\n});\n\n/** Props for the Alert component */\ntype AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>;\n\nconst Alert = React.forwardRef<HTMLDivElement, AlertProps>(\n ({ className, variant, ...props }, ref) => (\n <div\n ref={ref}\n role=\"alert\"\n className={alertVariants({ variant, className })}\n {...props}\n />\n )\n);\nAlert.displayName = 'Alert';\n\nconst AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\n ({ className, ...props }, ref) => (\n <h5\n ref={ref}\n className={cn('mb-1 font-medium leading-none tracking-tight', className)}\n {...props}\n />\n )\n);\nAlertTitle.displayName = 'AlertTitle';\n\nconst AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('text-sm [&_p]:leading-relaxed', className)}\n {...props}\n />\n )\n);\nAlertDescription.displayName = 'AlertDescription';\n\nexport { Alert, AlertTitle, AlertDescription };\n"
59
59
  }
60
60
  ]
61
61
  },
@@ -69,7 +69,24 @@
69
69
  "files": [
70
70
  {
71
71
  "path": "src/components/ui/alert-dialog/AlertDialog.tsx",
72
- "content": "import * as React from 'react';\r\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst alertDialogVariants = 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-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 max-w-lg 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 sm:rounded-lg',\r\n header: 'flex flex-col space-y-2 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst AlertDialog = BaseAlertDialog.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 BaseAlertDialog.Trigger>;\r\n\r\ninterface AlertDialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\r\n render?: BaseTriggerProps['render'];\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst AlertDialogTrigger = React.forwardRef<HTMLElement, AlertDialogTriggerProps>(\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 <BaseAlertDialog.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 </BaseAlertDialog.Trigger>\r\n );\r\n },\r\n);\r\nAlertDialogTrigger.displayName = 'AlertDialogTrigger';\r\n\r\n/* ─── Close (wraps BaseAlertDialog.Close for cancel buttons) ─── */\r\nconst AlertDialogClose = BaseAlertDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\nconst AlertDialogContent = React.forwardRef<\r\n HTMLDivElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, children, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Portal>\r\n <BaseAlertDialog.Backdrop className={slots.overlay()} />\r\n <BaseAlertDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n </BaseAlertDialog.Popup>\r\n </BaseAlertDialog.Portal>\r\n );\r\n});\r\nAlertDialogContent.displayName = 'AlertDialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst AlertDialogHeader = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n});\r\nAlertDialogHeader.displayName = 'AlertDialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst AlertDialogFooter = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n});\r\nAlertDialogFooter.displayName = 'AlertDialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst AlertDialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <BaseAlertDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nAlertDialogTitle.displayName = 'AlertDialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst AlertDialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Description\r\n ref={ref}\r\n className={slots.description({ className })}\r\n {...props}\r\n />\r\n );\r\n});\r\nAlertDialogDescription.displayName = 'AlertDialogDescription';\r\n\r\nexport {\r\n AlertDialog,\r\n AlertDialogTrigger,\r\n AlertDialogContent,\r\n AlertDialogHeader,\r\n AlertDialogFooter,\r\n AlertDialogTitle,\r\n AlertDialogDescription,\r\n AlertDialogClose,\r\n alertDialogVariants,\r\n};\r\n"
72
+ "content": "import * as React from 'react';\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\n\nconst alertDialogVariants = tv({\n slots: {\n overlay:\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\n content:\n 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg 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 sm:rounded-lg',\n header: 'flex flex-col space-y-2 text-center sm:text-left',\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2',\n title: 'text-lg font-semibold leading-none tracking-tight',\n description: 'text-sm text-muted-foreground',\n },\n});\n\n/* ─── Root ─── */\nconst AlertDialog = BaseAlertDialog.Root;\n\n/* ─── Trigger ─── */\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\n// để tránh nested button (<button><button>…</button></button>).\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Trigger>;\n\ninterface AlertDialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\n render?: BaseTriggerProps['render'];\n children?: React.ReactNode;\n}\n\nconst AlertDialogTrigger = React.forwardRef<HTMLElement, AlertDialogTriggerProps>(\n ({ render: renderProp, children, ...props }, ref) => {\n const resolvedRender =\n renderProp ?? (React.isValidElement(children) ? children : undefined);\n\n return (\n <BaseAlertDialog.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n render={resolvedRender}\n {...props}\n >\n {resolvedRender ? undefined : children}\n </BaseAlertDialog.Trigger>\n );\n },\n);\nAlertDialogTrigger.displayName = 'AlertDialogTrigger';\n\n/* ─── Close (wraps BaseAlertDialog.Close for cancel buttons) ─── */\nconst AlertDialogClose = BaseAlertDialog.Close;\n\n/* ─── Content (Portal + Backdrop + Popup) ─── */\nconst AlertDialogContent = React.forwardRef<\n HTMLDivElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'className'> & {\n className?: string;\n }\n>(({ className, children, ...props }, ref) => {\n const slots = alertDialogVariants();\n return (\n <BaseAlertDialog.Portal>\n <BaseAlertDialog.Backdrop className={slots.overlay()} />\n <BaseAlertDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\n {children}\n </BaseAlertDialog.Popup>\n </BaseAlertDialog.Portal>\n );\n});\nAlertDialogContent.displayName = 'AlertDialogContent';\n\n/* ─── Header ─── */\nconst AlertDialogHeader = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const slots = alertDialogVariants();\n return <div ref={ref} className={slots.header({ className })} {...props} />;\n});\nAlertDialogHeader.displayName = 'AlertDialogHeader';\n\n/* ─── Footer ─── */\nconst AlertDialogFooter = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const slots = alertDialogVariants();\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\n});\nAlertDialogFooter.displayName = 'AlertDialogFooter';\n\n/* ─── Title ─── */\nconst AlertDialogTitle = React.forwardRef<\n HTMLHeadingElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Title>, 'className'> & {\n className?: string;\n }\n>(({ className, ...props }, ref) => {\n const slots = alertDialogVariants();\n return <BaseAlertDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\n});\nAlertDialogTitle.displayName = 'AlertDialogTitle';\n\n/* ─── Description ─── */\nconst AlertDialogDescription = React.forwardRef<\n HTMLParagraphElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Description>, 'className'> & {\n className?: string;\n }\n>(({ className, ...props }, ref) => {\n const slots = alertDialogVariants();\n return (\n <BaseAlertDialog.Description\n ref={ref}\n className={slots.description({ className })}\n {...props}\n />\n );\n});\nAlertDialogDescription.displayName = 'AlertDialogDescription';\n\nexport {\n AlertDialog,\n AlertDialogTrigger,\n AlertDialogContent,\n AlertDialogHeader,\n AlertDialogFooter,\n AlertDialogTitle,\n AlertDialogDescription,\n AlertDialogClose,\n alertDialogVariants,\n};\n"
73
+ }
74
+ ]
75
+ },
76
+ "animated-beam": {
77
+ "name": "animated-beam",
78
+ "dependencies": [
79
+ "motion"
80
+ ],
81
+ "internalDependencies": [],
82
+ "files": [
83
+ {
84
+ "path": "src/components/ui/animated-beam/AnimatedBeam.tsx",
85
+ "content": "'use client';\n\nimport * as React from 'react';\nimport { motion } from 'motion/react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Defaults ────────────────────────────────────────────────────────────────\n\nconst DEFAULT_DURATION = 5;\nconst DEFAULT_PATH_COLOR = 'gray';\nconst DEFAULT_PATH_WIDTH = 2;\nconst DEFAULT_PATH_OPACITY = 0.2;\n// Kraken purple theme gradient\nconst DEFAULT_GRADIENT_START = '#7132f5';\nconst DEFAULT_GRADIENT_STOP = '#a78bfa';\n\nexport interface AnimatedBeamProps {\n className?: string;\n /** The relatively-positioned container both nodes live in. */\n containerRef: React.RefObject<HTMLElement | null>;\n /** Source node. */\n fromRef: React.RefObject<HTMLElement | null>;\n /** Target node. */\n toRef: React.RefObject<HTMLElement | null>;\n /** Vertical bow of the curve in px (default: 0 = straight). */\n curvature?: number;\n /** Reverse the beam's travel direction (default: false). */\n reverse?: boolean;\n /** Base (static) path color (default: 'gray'). */\n pathColor?: string;\n /** Stroke width in px (default: 2). */\n pathWidth?: number;\n /** Base path opacity (default: 0.2). */\n pathOpacity?: number;\n /** Gradient start color (default: Kraken purple #7132f5). */\n gradientStartColor?: string;\n /** Gradient stop color (default: #a78bfa). */\n gradientStopColor?: string;\n /** Animation delay in seconds (default: 0). */\n delay?: number;\n /** Loop duration in seconds (default: 5). */\n duration?: number;\n startXOffset?: number;\n startYOffset?: number;\n endXOffset?: number;\n endYOffset?: number;\n}\n\nconst AnimatedBeam = React.forwardRef<SVGSVGElement, AnimatedBeamProps>(\n (\n {\n className,\n containerRef,\n fromRef,\n toRef,\n curvature = 0,\n reverse = false,\n duration = DEFAULT_DURATION,\n delay = 0,\n pathColor = DEFAULT_PATH_COLOR,\n pathWidth = DEFAULT_PATH_WIDTH,\n pathOpacity = DEFAULT_PATH_OPACITY,\n gradientStartColor = DEFAULT_GRADIENT_START,\n gradientStopColor = DEFAULT_GRADIENT_STOP,\n startXOffset = 0,\n startYOffset = 0,\n endXOffset = 0,\n endYOffset = 0,\n },\n ref\n ) => {\n const id = React.useId();\n const [pathD, setPathD] = React.useState('');\n const [svgDimensions, setSvgDimensions] = React.useState({ width: 0, height: 0 });\n\n // Animate the gradient stops across the path; reverse flips the direction.\n const gradientCoordinates = reverse\n ? { x1: ['90%', '-10%'], x2: ['100%', '0%'], y1: ['0%', '0%'], y2: ['0%', '0%'] }\n : { x1: ['10%', '110%'], x2: ['0%', '100%'], y1: ['0%', '0%'], y2: ['0%', '0%'] };\n\n React.useEffect(() => {\n const updatePath = () => {\n if (!containerRef.current || !fromRef.current || !toRef.current) return;\n\n const containerRect = containerRef.current.getBoundingClientRect();\n const rectA = fromRef.current.getBoundingClientRect();\n const rectB = toRef.current.getBoundingClientRect();\n\n setSvgDimensions({ width: containerRect.width, height: containerRect.height });\n\n const startX = rectA.left - containerRect.left + rectA.width / 2 + startXOffset;\n const startY = rectA.top - containerRect.top + rectA.height / 2 + startYOffset;\n const endX = rectB.left - containerRect.left + rectB.width / 2 + endXOffset;\n const endY = rectB.top - containerRect.top + rectB.height / 2 + endYOffset;\n\n const controlX = (startX + endX) / 2;\n const controlY = startY - curvature;\n setPathD(`M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`);\n };\n\n const resizeObserver = new ResizeObserver(() => updatePath());\n const container = containerRef.current;\n if (container) resizeObserver.observe(container);\n window.addEventListener('resize', updatePath);\n updatePath();\n\n return () => {\n resizeObserver.disconnect();\n window.removeEventListener('resize', updatePath);\n };\n }, [\n containerRef,\n fromRef,\n toRef,\n curvature,\n startXOffset,\n startYOffset,\n endXOffset,\n endYOffset,\n ]);\n\n return (\n <svg\n ref={ref}\n fill=\"none\"\n width={svgDimensions.width}\n height={svgDimensions.height}\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}\n className={cn(\n 'pointer-events-none absolute left-0 top-0 transform-gpu',\n className\n )}\n aria-hidden=\"true\"\n >\n <path\n d={pathD}\n stroke={pathColor}\n strokeWidth={pathWidth}\n strokeOpacity={pathOpacity}\n strokeLinecap=\"round\"\n />\n <path\n d={pathD}\n stroke={`url(#${id})`}\n strokeWidth={pathWidth}\n strokeOpacity=\"1\"\n strokeLinecap=\"round\"\n />\n <defs>\n <motion.linearGradient\n className=\"transform-gpu\"\n id={id}\n gradientUnits=\"userSpaceOnUse\"\n initial={{ x1: '0%', x2: '0%', y1: '0%', y2: '0%' }}\n animate={{\n x1: gradientCoordinates.x1,\n x2: gradientCoordinates.x2,\n y1: gradientCoordinates.y1,\n y2: gradientCoordinates.y2,\n }}\n transition={{\n delay,\n duration,\n ease: [0.16, 1, 0.3, 1],\n repeat: Infinity,\n repeatDelay: 0,\n }}\n >\n <stop stopColor={gradientStartColor} stopOpacity=\"0\" />\n <stop stopColor={gradientStartColor} />\n <stop offset=\"32.5%\" stopColor={gradientStopColor} />\n <stop offset=\"100%\" stopColor={gradientStopColor} stopOpacity=\"0\" />\n </motion.linearGradient>\n </defs>\n </svg>\n );\n }\n);\nAnimatedBeam.displayName = 'AnimatedBeam';\n\nexport { AnimatedBeam };\n"
86
+ },
87
+ {
88
+ "path": "src/components/ui/animated-beam/index.ts",
89
+ "content": "export * from './AnimatedBeam';\n"
73
90
  }
74
91
  ]
75
92
  },
@@ -82,7 +99,7 @@
82
99
  "files": [
83
100
  {
84
101
  "path": "src/components/ui/aspect-ratio/AspectRatio.tsx",
85
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst aspectRatioVariants = tv({\r\n base: 'relative w-full overflow-hidden',\r\n});\r\n\r\n/** Props for the AspectRatio component */\r\nexport interface AspectRatioProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof aspectRatioVariants> {\r\n /** Width-to-height ratio (e.g. 16/9, 4/3, 1) */\r\n ratio?: number;\r\n}\r\n\r\nconst AspectRatio = React.forwardRef<HTMLDivElement, AspectRatioProps>(\r\n ({ className, ratio = 1, style, children, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={aspectRatioVariants({ className })}\r\n style={{ paddingBottom: `${(1 / ratio) * 100}%`, ...style }}\r\n {...props}\r\n >\r\n <div className=\"absolute inset-0\">{children}</div>\r\n </div>\r\n )\r\n);\r\nAspectRatio.displayName = 'AspectRatio';\r\n\r\nexport { AspectRatio, aspectRatioVariants };\r\n"
102
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst aspectRatioVariants = tv({\n base: 'relative w-full overflow-hidden',\n});\n\n/** Props for the AspectRatio component */\nexport interface AspectRatioProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof aspectRatioVariants> {\n /** Width-to-height ratio (e.g. 16/9, 4/3, 1) */\n ratio?: number;\n}\n\nconst AspectRatio = React.forwardRef<HTMLDivElement, AspectRatioProps>(\n ({ className, ratio = 1, style, children, ...props }, ref) => (\n <div\n ref={ref}\n className={aspectRatioVariants({ className })}\n style={{ paddingBottom: `${(1 / ratio) * 100}%`, ...style }}\n {...props}\n >\n <div className=\"absolute inset-0\">{children}</div>\n </div>\n )\n);\nAspectRatio.displayName = 'AspectRatio';\n\nexport { AspectRatio, aspectRatioVariants };\n"
86
103
  }
87
104
  ]
88
105
  },
@@ -97,7 +114,7 @@
97
114
  "files": [
98
115
  {
99
116
  "path": "src/components/ui/autocomplete/Autocomplete.tsx",
100
- "content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n description?: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n clearOnSelect?: boolean;\r\n /** Value emitted when clear button is clicked (default: empty string) */\r\n clearValue?: string | null;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n clearOnSelect = false,\r\n clearValue,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const getClearValue = (): string | null => {\r\n return clearValue ?? null;\r\n };\r\n\r\n const handleValueChange = (newVal: string | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(clearOnSelect ? null : newVal);\r\n if (newVal !== null) {\r\n onValueChange?.(newVal);\r\n } else {\r\n const clear = getClearValue();\r\n // Emit '' to form if clearValue is null\r\n onValueChange?.(clear ?? '');\r\n }\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n if (clearOnSelect) {\r\n setInputValue('');\r\n setOpen(false);\r\n }\r\n return;\r\n }\r\n setInputValue(val);\r\n setOpen(val.length > 0);\r\n };\r\n\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n const clear = getClearValue();\r\n if (value === undefined) setInternalValue(null);\r\n // Emit '' to form if clearValue is null\r\n onValueChange?.(clear ?? '');\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n // Đóng popup trước khi browser paint nếu input rỗng — loại bỏ hoàn toàn nháy 1 frame\r\n React.useLayoutEffect(() => {\r\n if (!inputValue && open) setOpen(false);\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [inputValue]);\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return [];\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={input()}\r\n />\r\n\r\n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\r\n {isLoading ? (\r\n <Loader2 className=\"h-4 w-4 animate-spin\" />\r\n ) : activeValue && !clearOnSelect ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : null}\r\n </div>\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n inputValue ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : null\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.description ? (\r\n <div className=\"flex flex-col\">\r\n <span>{option.label}</span>\r\n <span className=\"text-xs text-muted-foreground\">{option.description}</span>\r\n </div>\r\n ) : option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
117
+ "content": "import * as React from 'react';\nimport { Combobox as BaseCombobox } from '@base-ui/react';\nimport { Check, X, Loader2 } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst autocompleteVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5 w-full',\n inputContainer: 'flex items-center min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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-top:slide-in-from-bottom-2',\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n },\n});\n\nexport interface AutocompleteOption {\n label: string;\n value: string;\n description?: string;\n}\n\nexport interface AutocompleteProps {\n options: AutocompleteOption[];\n label?: string;\n placeholder?: string;\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n isLoading?: boolean;\n className?: string;\n emptyText?: string;\n leftIcon?: React.ReactNode;\n clearOnSelect?: boolean;\n /** Value emitted when clear button is clicked (default: empty string) */\n clearValue?: string | null;\n}\n\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\n ({\n options,\n label,\n placeholder,\n value,\n defaultValue,\n onValueChange,\n isLoading,\n className,\n emptyText = 'No results found.',\n leftIcon,\n clearOnSelect = false,\n clearValue,\n }, ref) => {\n const [inputValue, setInputValue] = React.useState('');\n const [open, setOpen] = React.useState(false);\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\n const isSelectingRef = React.useRef(false);\n\n const activeValue = value !== undefined ? value : internalValue;\n\n const getClearValue = (): string | null => {\n return clearValue ?? null;\n };\n\n const handleValueChange = (newVal: string | null) => {\n isSelectingRef.current = true;\n if (value === undefined) setInternalValue(clearOnSelect ? null : newVal);\n if (newVal !== null) {\n onValueChange?.(newVal);\n } else {\n const clear = getClearValue();\n // Emit '' to form if clearValue is null\n onValueChange?.(clear ?? '');\n }\n };\n\n const handleInputValueChange = (val: string) => {\n if (isSelectingRef.current) {\n isSelectingRef.current = false;\n if (clearOnSelect) {\n setInputValue('');\n setOpen(false);\n }\n return;\n }\n setInputValue(val);\n setOpen(val.length > 0);\n };\n\n const handleOpenChange = (newOpen: boolean) => {\n if (!newOpen) setOpen(false);\n };\n\n const handleClear = (e: React.SyntheticEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const clear = getClearValue();\n if (value === undefined) setInternalValue(null);\n // Emit '' to form if clearValue is null\n onValueChange?.(clear ?? '');\n setInputValue('');\n setOpen(false);\n };\n\n // Đóng popup trước khi browser paint nếu input rỗng — loại bỏ hoàn toàn nháy 1 frame\n React.useLayoutEffect(() => {\n if (!inputValue && open) setOpen(false);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inputValue]);\n\n const filteredOptions = React.useMemo(() => {\n if (!inputValue) return [];\n return options.filter(o =>\n o.label.toLowerCase().includes(inputValue.toLowerCase())\n );\n }, [options, inputValue]);\n\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\n\n return (\n <BaseCombobox.Root\n value={activeValue}\n onValueChange={handleValueChange}\n onInputValueChange={handleInputValueChange}\n open={open}\n onOpenChange={handleOpenChange}\n autoHighlight\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\n >\n <div className={root({ className })}>\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\n\n <div className=\"relative w-full group\">\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\n {leftIcon && (\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\n {leftIcon}\n </div>\n )}\n\n <BaseCombobox.Input\n ref={ref}\n placeholder={placeholder}\n className={input()}\n />\n\n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\n {isLoading ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : activeValue && !clearOnSelect ? (\n <span\n role=\"button\"\n aria-label=\"Clear selection\"\n onPointerDown={(e) => {\n e.stopPropagation();\n handleClear(e);\n }}\n onClick={(e) => e.stopPropagation()}\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\n >\n <X className=\"h-3.5 w-3.5\" />\n </span>\n ) : null}\n </div>\n </BaseCombobox.InputGroup>\n\n <BaseCombobox.Portal>\n <BaseCombobox.Positioner\n anchor={inputGroupRef}\n sideOffset={4}\n style={{ width: 'var(--anchor-width)' }}\n >\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\n {filteredOptions.length === 0 ? (\n inputValue ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\n ) : null\n ) : (\n filteredOptions.map((option) => (\n <BaseCombobox.Item\n key={option.value}\n value={option.value}\n className={item()}\n >\n <BaseCombobox.ItemIndicator className={indicator()}>\n <Check className=\"h-4 w-4\" />\n </BaseCombobox.ItemIndicator>\n {option.description ? (\n <div className=\"flex flex-col\">\n <span>{option.label}</span>\n <span className=\"text-xs text-muted-foreground\">{option.description}</span>\n </div>\n ) : option.label}\n </BaseCombobox.Item>\n ))\n )}\n </BaseCombobox.List>\n </BaseCombobox.Popup>\n </BaseCombobox.Positioner>\n </BaseCombobox.Portal>\n </div>\n </div>\n </BaseCombobox.Root>\n );\n }\n);\n\nAutocomplete.displayName = 'Autocomplete';\n\nexport { Autocomplete };\n"
101
118
  }
102
119
  ]
103
120
  },
@@ -110,7 +127,7 @@
110
127
  "files": [
111
128
  {
112
129
  "path": "src/components/ui/avatar/Avatar.tsx",
113
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst avatarVariants = tv({\r\n base: 'relative flex shrink-0 overflow-hidden rounded-full items-center justify-center bg-secondary text-secondary-foreground outline-none',\r\n variants: {\r\n size: {\r\n sm: 'h-8 w-8 text-xs',\r\n md: 'h-10 w-10 text-sm',\r\n lg: 'h-12 w-12 text-base',\r\n xl: 'h-16 w-16 text-lg',\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Avatar component */\r\nexport interface AvatarProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof avatarVariants> {\r\n /** Image URL for the avatar */\r\n src?: string;\r\n /** Alt text for the image; first 2 chars used as fallback if no `fallback` prop */\r\n alt?: string;\r\n /** Text shown when the image fails to load or is not provided */\r\n fallback?: string;\r\n}\r\n\r\nconst Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(\r\n ({ className, size, src, alt, fallback, ...props }, ref) => {\r\n const [hasError, setHasError] = React.useState(false);\r\n\r\n return (\r\n <div ref={ref} className={avatarVariants({ size, className })} {...props}>\r\n {src && !hasError ? (\r\n <img\r\n src={src}\r\n alt={alt || \"Avatar\"}\r\n className=\"aspect-square h-full w-full object-cover\"\r\n onError={() => setHasError(true)}\r\n />\r\n ) : (\r\n <span className=\"font-medium uppercase tracking-wider\">\r\n {fallback || (alt ? alt.substring(0, 2) : '??')}\r\n </span>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\nAvatar.displayName = 'Avatar';\r\n\r\nexport { Avatar };\r\n"
130
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst avatarVariants = tv({\n base: 'relative flex shrink-0 overflow-hidden rounded-full items-center justify-center bg-secondary text-secondary-foreground outline-none',\n variants: {\n size: {\n sm: 'h-8 w-8 text-xs',\n md: 'h-10 w-10 text-sm',\n lg: 'h-12 w-12 text-base',\n xl: 'h-16 w-16 text-lg',\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\n/** Props for the Avatar component */\nexport interface AvatarProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof avatarVariants> {\n /** Image URL for the avatar */\n src?: string;\n /** Alt text for the image; first 2 chars used as fallback if no `fallback` prop */\n alt?: string;\n /** Text shown when the image fails to load or is not provided */\n fallback?: string;\n}\n\nconst Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(\n ({ className, size, src, alt, fallback, ...props }, ref) => {\n const [hasError, setHasError] = React.useState(false);\n\n return (\n <div ref={ref} className={avatarVariants({ size, className })} {...props}>\n {src && !hasError ? (\n <img\n src={src}\n alt={alt || \"Avatar\"}\n className=\"aspect-square h-full w-full object-cover\"\n onError={() => setHasError(true)}\n />\n ) : (\n <span className=\"font-medium uppercase tracking-wider\">\n {fallback || (alt ? alt.substring(0, 2) : '??')}\n </span>\n )}\n </div>\n );\n }\n);\nAvatar.displayName = 'Avatar';\n\nexport { Avatar };\n"
114
131
  }
115
132
  ]
116
133
  },
@@ -123,7 +140,7 @@
123
140
  "files": [
124
141
  {
125
142
  "path": "src/components/ui/badge/Badge.tsx",
126
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst badgeVariants = tv({\r\n // Kraken: 6–8px radius for badges, not pill-shaped\r\n base: 'inline-flex items-center justify-center rounded-md border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 w-fit',\r\n variants: {\r\n variant: {\r\n default: 'border-transparent bg-primary text-primary-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n secondary: 'border-transparent bg-secondary text-secondary-foreground',\r\n outline: 'border-border text-foreground hover:bg-muted',\r\n success: 'border-transparent bg-success text-success-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n warning: 'border-transparent bg-warning text-warning-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n danger: 'border-transparent bg-danger text-danger-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n\r\n // Soft variants — Kraken-style: 16% opacity background, dark text\r\n 'soft-primary': 'border-transparent bg-primary/[0.12] text-primary',\r\n 'soft-success': 'border-transparent bg-success/[0.16] text-success',\r\n 'soft-warning': 'border-transparent bg-warning/[0.16] text-warning',\r\n 'soft-danger': 'border-transparent bg-danger/[0.12] text-danger',\r\n\r\n // Glass variant\r\n glass: 'border border-white/20 bg-white/10 text-foreground backdrop-blur-md shadow-sm',\r\n\r\n // Gradient variant\r\n gradient: 'border-transparent bg-gradient-to-r from-primary to-violet-500 text-white shadow-sm',\r\n },\r\n size: {\r\n sm: 'text-[10px] px-2 py-0.5 leading-4',\r\n md: 'text-xs px-2.5 py-0.5 leading-5',\r\n lg: 'text-sm px-3 py-1 leading-6',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n size: 'md',\r\n }\r\n});\r\n\r\n/** Props for the Badge component */\r\nexport interface BadgeProps\r\n extends React.HTMLAttributes<HTMLSpanElement>,\r\n VariantProps<typeof badgeVariants> {\r\n /** Show a pulsing dot indicator before the badge content */\r\n pulse?: boolean;\r\n}\r\n\r\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\r\n ({ className, variant, size, pulse, children, ...props }, ref) => {\r\n return (\r\n <span ref={ref} className={badgeVariants({ variant, size, className })} {...props}>\r\n {pulse && (\r\n <span className=\"relative grid place-items-center h-2 w-2 mr-1.5\">\r\n <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-75\"></span>\r\n <span className=\"relative inline-flex rounded-full h-1.5 w-1.5 bg-current\"></span>\r\n </span>\r\n )}\r\n {children}\r\n </span>\r\n );\r\n }\r\n);\r\nBadge.displayName = 'Badge';\r\n\r\nexport { Badge };\r\n"
143
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst badgeVariants = tv({\n // Kraken: 6–8px radius for badges, not pill-shaped\n base: 'inline-flex items-center justify-center rounded-md border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 w-fit',\n variants: {\n variant: {\n default: 'border-transparent bg-primary text-primary-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\n secondary: 'border-transparent bg-secondary text-secondary-foreground',\n outline: 'border-border text-foreground hover:bg-muted',\n success: 'border-transparent bg-success text-success-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\n warning: 'border-transparent bg-warning text-warning-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\n danger: 'border-transparent bg-danger text-danger-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\n\n // Soft variants — Kraken-style: 16% opacity background, dark text\n 'soft-primary': 'border-transparent bg-primary/[0.12] text-primary',\n 'soft-success': 'border-transparent bg-success/[0.16] text-success',\n 'soft-warning': 'border-transparent bg-warning/[0.16] text-warning',\n 'soft-danger': 'border-transparent bg-danger/[0.12] text-danger',\n\n // Glass variant\n glass: 'border border-white/20 bg-white/10 text-foreground backdrop-blur-md shadow-sm',\n\n // Gradient variant\n gradient: 'border-transparent bg-gradient-to-r from-primary to-violet-500 text-white shadow-sm',\n },\n size: {\n sm: 'text-[10px] px-2 py-0.5 leading-4',\n md: 'text-xs px-2.5 py-0.5 leading-5',\n lg: 'text-sm px-3 py-1 leading-6',\n }\n },\n defaultVariants: {\n variant: 'default',\n size: 'md',\n }\n});\n\n/** Props for the Badge component */\nexport interface BadgeProps\n extends React.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof badgeVariants> {\n /** Show a pulsing dot indicator before the badge content */\n pulse?: boolean;\n}\n\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\n ({ className, variant, size, pulse, children, ...props }, ref) => {\n return (\n <span ref={ref} className={badgeVariants({ variant, size, className })} {...props}>\n {pulse && (\n <span className=\"relative grid place-items-center h-2 w-2 mr-1.5\">\n <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-75\"></span>\n <span className=\"relative inline-flex rounded-full h-1.5 w-1.5 bg-current\"></span>\n </span>\n )}\n {children}\n </span>\n );\n }\n);\nBadge.displayName = 'Badge';\n\nexport { Badge };\n"
127
144
  }
128
145
  ]
129
146
  },
@@ -137,7 +154,7 @@
137
154
  "files": [
138
155
  {
139
156
  "path": "src/components/ui/breadcrumb/Breadcrumb.tsx",
140
- "content": "import * as React from 'react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { ChevronRight, MoreHorizontal } from 'lucide-react';\r\n\r\nconst breadcrumbVariants = tv({\r\n slots: {\r\n nav: '',\r\n list: 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',\r\n item: 'inline-flex items-center gap-1.5',\r\n link: 'transition-colors hover:text-foreground',\r\n page: 'font-medium text-foreground',\r\n separator: 'text-muted-foreground/60 [&>svg]:w-3.5 [&>svg]:h-3.5',\r\n ellipsis: 'flex h-9 w-9 items-center justify-center',\r\n },\r\n});\r\n\r\nconst { nav, list, item, link, page, separator, ellipsis } = breadcrumbVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbProps extends React.ComponentPropsWithoutRef<'nav'> {}\r\n\r\nconst Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(\r\n ({ className, ...props }, ref) => (\r\n <nav ref={ref} aria-label=\"breadcrumb\" className={nav({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumb.displayName = 'Breadcrumb';\r\n\r\n/* ─── List ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbListProps extends React.ComponentPropsWithoutRef<'ol'> {}\r\n\r\nconst BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(\r\n ({ className, ...props }, ref) => (\r\n <ol ref={ref} className={list({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbList.displayName = 'BreadcrumbList';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst BreadcrumbItem = React.forwardRef<HTMLLIElement, BreadcrumbItemProps>(\r\n ({ className, ...props }, ref) => (\r\n <li ref={ref} className={item({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbItem.displayName = 'BreadcrumbItem';\r\n\r\n/* ─── Link ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the BreadcrumbLink component */\r\nexport interface BreadcrumbLinkProps extends React.ComponentPropsWithoutRef<'a'> {\r\n /** Render as a child span instead of an anchor element */\r\n asChild?: boolean;\r\n}\r\n\r\nconst BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(\r\n ({ className, asChild, ...props }, ref) => {\r\n if (asChild) {\r\n return <span ref={ref as React.Ref<HTMLSpanElement>} className={link({ className })} {...(props as React.HTMLAttributes<HTMLSpanElement>)} />;\r\n }\r\n return <a ref={ref} className={link({ className })} {...props} />;\r\n }\r\n);\r\nBreadcrumbLink.displayName = 'BreadcrumbLink';\r\n\r\n/* ─── Page (current) ────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbPageProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst BreadcrumbPage = React.forwardRef<HTMLSpanElement, BreadcrumbPageProps>(\r\n ({ className, ...props }, ref) => (\r\n <span ref={ref} role=\"link\" aria-disabled=\"true\" aria-current=\"page\" className={page({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbPage.displayName = 'BreadcrumbPage';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbSeparatorProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst BreadcrumbSeparator = React.forwardRef<HTMLLIElement, BreadcrumbSeparatorProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <li ref={ref} role=\"presentation\" aria-hidden=\"true\" className={separator({ className })} {...props}>\r\n {children ?? <ChevronRight />}\r\n </li>\r\n )\r\n);\r\nBreadcrumbSeparator.displayName = 'BreadcrumbSeparator';\r\n\r\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst BreadcrumbEllipsis = React.forwardRef<HTMLSpanElement, BreadcrumbEllipsisProps>(\r\n ({ className, ...props }, ref) => (\r\n <span ref={ref} role=\"presentation\" aria-hidden=\"true\" className={ellipsis({ className })} {...props}>\r\n <MoreHorizontal className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">More</span>\r\n </span>\r\n )\r\n);\r\nBreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';\r\n\r\nexport {\r\n Breadcrumb,\r\n BreadcrumbList,\r\n BreadcrumbItem,\r\n BreadcrumbLink,\r\n BreadcrumbPage,\r\n BreadcrumbSeparator,\r\n BreadcrumbEllipsis,\r\n breadcrumbVariants,\r\n};\r\n"
157
+ "content": "import * as React from 'react';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, MoreHorizontal } from 'lucide-react';\n\nconst breadcrumbVariants = tv({\n slots: {\n nav: '',\n list: 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',\n item: 'inline-flex items-center gap-1.5',\n link: 'transition-colors hover:text-foreground',\n page: 'font-medium text-foreground',\n separator: 'text-muted-foreground/60 [&>svg]:w-3.5 [&>svg]:h-3.5',\n ellipsis: 'flex h-9 w-9 items-center justify-center',\n },\n});\n\nconst { nav, list, item, link, page, separator, ellipsis } = breadcrumbVariants();\n\n/* ─── Root ──────────────────────────────────────────────────────────── */\n\nexport interface BreadcrumbProps extends React.ComponentPropsWithoutRef<'nav'> {}\n\nconst Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(\n ({ className, ...props }, ref) => (\n <nav ref={ref} aria-label=\"breadcrumb\" className={nav({ className })} {...props} />\n )\n);\nBreadcrumb.displayName = 'Breadcrumb';\n\n/* ─── List ──────────────────────────────────────────────────────────── */\n\nexport interface BreadcrumbListProps extends React.ComponentPropsWithoutRef<'ol'> {}\n\nconst BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(\n ({ className, ...props }, ref) => (\n <ol ref={ref} className={list({ className })} {...props} />\n )\n);\nBreadcrumbList.displayName = 'BreadcrumbList';\n\n/* ─── Item ──────────────────────────────────────────────────────────── */\n\nexport interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<'li'> {}\n\nconst BreadcrumbItem = React.forwardRef<HTMLLIElement, BreadcrumbItemProps>(\n ({ className, ...props }, ref) => (\n <li ref={ref} className={item({ className })} {...props} />\n )\n);\nBreadcrumbItem.displayName = 'BreadcrumbItem';\n\n/* ─── Link ──────────────────────────────────────────────────────────── */\n\n/** Props for the BreadcrumbLink component */\nexport interface BreadcrumbLinkProps extends React.ComponentPropsWithoutRef<'a'> {\n /** Render as a child span instead of an anchor element */\n asChild?: boolean;\n}\n\nconst BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(\n ({ className, asChild, ...props }, ref) => {\n if (asChild) {\n return <span ref={ref as React.Ref<HTMLSpanElement>} className={link({ className })} {...(props as React.HTMLAttributes<HTMLSpanElement>)} />;\n }\n return <a ref={ref} className={link({ className })} {...props} />;\n }\n);\nBreadcrumbLink.displayName = 'BreadcrumbLink';\n\n/* ─── Page (current) ────────────────────────────────────────────────── */\n\nexport interface BreadcrumbPageProps extends React.ComponentPropsWithoutRef<'span'> {}\n\nconst BreadcrumbPage = React.forwardRef<HTMLSpanElement, BreadcrumbPageProps>(\n ({ className, ...props }, ref) => (\n <span ref={ref} role=\"link\" aria-disabled=\"true\" aria-current=\"page\" className={page({ className })} {...props} />\n )\n);\nBreadcrumbPage.displayName = 'BreadcrumbPage';\n\n/* ─── Separator ─────────────────────────────────────────────────────── */\n\nexport interface BreadcrumbSeparatorProps extends React.ComponentPropsWithoutRef<'li'> {}\n\nconst BreadcrumbSeparator = React.forwardRef<HTMLLIElement, BreadcrumbSeparatorProps>(\n ({ className, children, ...props }, ref) => (\n <li ref={ref} role=\"presentation\" aria-hidden=\"true\" className={separator({ className })} {...props}>\n {children ?? <ChevronRight />}\n </li>\n )\n);\nBreadcrumbSeparator.displayName = 'BreadcrumbSeparator';\n\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\n\nexport interface BreadcrumbEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\n\nconst BreadcrumbEllipsis = React.forwardRef<HTMLSpanElement, BreadcrumbEllipsisProps>(\n ({ className, ...props }, ref) => (\n <span ref={ref} role=\"presentation\" aria-hidden=\"true\" className={ellipsis({ className })} {...props}>\n <MoreHorizontal className=\"h-4 w-4\" />\n <span className=\"sr-only\">More</span>\n </span>\n )\n);\nBreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';\n\nexport {\n Breadcrumb,\n BreadcrumbList,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbPage,\n BreadcrumbSeparator,\n BreadcrumbEllipsis,\n breadcrumbVariants,\n};\n"
141
158
  }
142
159
  ]
143
160
  },
@@ -153,7 +170,7 @@
153
170
  "files": [
154
171
  {
155
172
  "path": "src/components/ui/button/Button.tsx",
156
- "content": "import * as React from 'react';\r\nimport { Button as BaseButton } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Spinner } from '../spinner/Spinner';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst buttonVariants = tv({\r\n base: 'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30 disabled:hover:bg-transparent data-open:bg-muted cursor-pointer disabled:cursor-not-allowed data-loading:opacity-50 data-loading:cursor-not-allowed data-loading:pointer-events-none',\r\n variants: {\r\n variant: {\r\n // Kraken Primary Purple\r\n solid:\r\n \"bg-primary text-primary-foreground hover:bg-primary/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]\",\r\n // Kraken Purple Outlined — border + text use primary colour\r\n outline:\r\n \"border border-primary/40 bg-transparent text-primary hover:bg-primary/5 hover:border-primary/70\",\r\n // Kraken Secondary Gray — subtle bg, neutral text\r\n ghost: \"hover:bg-accent hover:text-accent-foreground\",\r\n // Kraken Purple Subtle — secondary surface\r\n secondary:\r\n \"bg-secondary text-secondary-foreground hover:bg-secondary/70 shadow-[rgba(0,0,0,0.04)_0px_1px_4px]\",\r\n danger:\r\n \"bg-danger text-danger-foreground hover:bg-danger/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]\",\r\n link: \"text-primary underline-offset-4 hover:underline h-auto px-0 py-0 font-normal\",\r\n // Kính mờ tối — trên nền tối\r\n glass:\r\n \"bg-white/15 backdrop-blur-md border border-white/30 text-accent hover:bg-white/25 hover:border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_4px_20px_rgba(0,0,0,0.2)] transition-all\",\r\n // ─── Glossy Bubble Variants ───────────────────────────────────────────────\r\n // Gradient from white highlight (top-left) → tinted color (bottom-right)\r\n // + inset top border = hiệu ứng gương bong bóng xà phòng\r\n \"glass-white\":\r\n \"bg-gradient-to-br from-white/70 to-slate-100/60 backdrop-blur-md border border-black/5 text-slate-700 hover:from-white/85 hover:to-slate-100/70 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\r\n \"glass-amber\":\r\n \"bg-gradient-to-br from-white/70 to-amber-300/40 backdrop-blur-sm border border-amber-100/80 text-amber-700 hover:from-white/85 hover:to-amber-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\r\n \"glass-green\":\r\n \"bg-gradient-to-br from-white/70 to-emerald-300/40 backdrop-blur-sm border border-emerald-100/80 text-emerald-700 hover:from-white/85 hover:to-emerald-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\r\n \"glass-purple\":\r\n \"bg-gradient-to-br from-white/70 to-violet-300/40 backdrop-blur-sm border border-violet-100/80 text-violet-700 hover:from-white/85 hover:to-violet-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\r\n \"glass-pink\":\r\n \"bg-gradient-to-br from-white/70 to-pink-300/40 backdrop-blur-sm border border-pink-100/80 text-pink-700 hover:from-white/85 hover:to-pink-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\r\n\r\n \"danger-outline\":\r\n \"border border-danger/40 text-danger hover:bg-danger/10\",\r\n \"success-outline\":\r\n \"border border-success/40 text-success hover:bg-success/10\",\r\n \"warning-outline\":\r\n \"border border-warning/40 text-warning hover:bg-warning/10\",\r\n \"info-outline\": \"border border-info/40 text-info hover:bg-info/10\",\r\n },\r\n size: {\r\n xs: \"h-7 px-2.5 py-1.5 text-xs\",\r\n sm: \"h-8 px-3 text-sm\",\r\n md: \"h-10 px-4 py-2\",\r\n lg: \"h-11 px-8\",\r\n icon: \"h-10 w-10\",\r\n \"icon-sm\": \"h-8 w-8\",\r\n },\r\n },\r\n defaultVariants: {\r\n variant: \"solid\",\r\n size: \"md\",\r\n },\r\n});\r\n\r\n/** Props for the Button component */\r\nexport interface ButtonProps\r\n extends\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseButton>, \"className\">,\r\n VariantProps<typeof buttonVariants> {\r\n /** Icon rendered before the button label */\r\n leftIcon?: React.ReactNode;\r\n /** Icon rendered after the button label */\r\n rightIcon?: React.ReactNode;\r\n /** Shows a loading spinner and disables interaction */\r\n isLoading?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Button = React.forwardRef<React.ElementRef<typeof BaseButton>, ButtonProps>(\r\n ({ className, variant, size, leftIcon, rightIcon, isLoading, children, ...props }, ref) => {\r\n return (\r\n <BaseButton\r\n ref={ref}\r\n className={buttonVariants({ variant, size, className: className || '' })}\r\n disabled={isLoading || props.disabled}\r\n data-loading={isLoading || undefined}\r\n {...props}\r\n >\r\n {isLoading && <Spinner size=\"xs\" className={cn('mr-2 text-muted')} />}\r\n <div className=\"flex items-center gap-2\">\r\n {!isLoading && leftIcon && <span>{leftIcon}</span>}\r\n {children}\r\n {!isLoading && rightIcon && <span>{rightIcon}</span>}\r\n </div>\r\n </BaseButton>\r\n );\r\n }\r\n);\r\nButton.displayName = 'Button';\r\n\r\nexport { Button };\r\n"
173
+ "content": "import * as React from 'react';\nimport { Button as BaseButton } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Spinner } from '../spinner/Spinner';\nimport { cn } from '@/lib/utils/cn';\n\nconst buttonVariants = tv({\n base: 'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30 disabled:hover:bg-transparent data-open:bg-muted cursor-pointer disabled:cursor-not-allowed data-loading:opacity-50 data-loading:cursor-not-allowed data-loading:pointer-events-none',\n variants: {\n variant: {\n // Kraken Primary Purple\n solid:\n \"bg-primary text-primary-foreground hover:bg-primary/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]\",\n // Kraken Purple Outlined — border + text use primary colour\n outline:\n \"border border-primary/40 bg-transparent text-primary hover:bg-primary/5 hover:border-primary/70\",\n // Kraken Secondary Gray — subtle bg, neutral text\n ghost: \"hover:bg-accent hover:text-accent-foreground\",\n // Kraken Purple Subtle — secondary surface\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/70 shadow-[rgba(0,0,0,0.04)_0px_1px_4px]\",\n danger:\n \"bg-danger text-danger-foreground hover:bg-danger/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]\",\n link: \"text-primary underline-offset-4 hover:underline h-auto px-0 py-0 font-normal\",\n // Kính mờ tối — trên nền tối\n glass:\n \"bg-white/15 backdrop-blur-md border border-white/30 text-accent hover:bg-white/25 hover:border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_4px_20px_rgba(0,0,0,0.2)] transition-all\",\n // ─── Glossy Bubble Variants ───────────────────────────────────────────────\n // Gradient from white highlight (top-left) → tinted color (bottom-right)\n // + inset top border = hiệu ứng gương bong bóng xà phòng\n \"glass-white\":\n \"bg-gradient-to-br from-white/70 to-slate-100/60 backdrop-blur-md border border-black/5 text-slate-700 hover:from-white/85 hover:to-slate-100/70 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\n \"glass-amber\":\n \"bg-gradient-to-br from-white/70 to-amber-300/40 backdrop-blur-sm border border-amber-100/80 text-amber-700 hover:from-white/85 hover:to-amber-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\n \"glass-green\":\n \"bg-gradient-to-br from-white/70 to-emerald-300/40 backdrop-blur-sm border border-emerald-100/80 text-emerald-700 hover:from-white/85 hover:to-emerald-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\n \"glass-purple\":\n \"bg-gradient-to-br from-white/70 to-violet-300/40 backdrop-blur-sm border border-violet-100/80 text-violet-700 hover:from-white/85 hover:to-violet-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\n \"glass-pink\":\n \"bg-gradient-to-br from-white/70 to-pink-300/40 backdrop-blur-sm border border-pink-100/80 text-pink-700 hover:from-white/85 hover:to-pink-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all\",\n\n \"danger-outline\":\n \"border border-danger/40 text-danger hover:bg-danger/10\",\n \"success-outline\":\n \"border border-success/40 text-success hover:bg-success/10\",\n \"warning-outline\":\n \"border border-warning/40 text-warning hover:bg-warning/10\",\n \"info-outline\": \"border border-info/40 text-info hover:bg-info/10\",\n },\n size: {\n xs: \"h-7 px-2.5 py-1.5 text-xs\",\n sm: \"h-8 px-3 text-sm\",\n md: \"h-10 px-4 py-2\",\n lg: \"h-11 px-8\",\n icon: \"h-10 w-10\",\n \"icon-sm\": \"h-8 w-8\",\n },\n },\n defaultVariants: {\n variant: \"solid\",\n size: \"md\",\n },\n});\n\n/** Props for the Button component */\nexport interface ButtonProps\n extends\n Omit<React.ComponentPropsWithoutRef<typeof BaseButton>, \"className\">,\n VariantProps<typeof buttonVariants> {\n /** Icon rendered before the button label */\n leftIcon?: React.ReactNode;\n /** Icon rendered after the button label */\n rightIcon?: React.ReactNode;\n /** Shows a loading spinner and disables interaction */\n isLoading?: boolean;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Button = React.forwardRef<React.ElementRef<typeof BaseButton>, ButtonProps>(\n ({ className, variant, size, leftIcon, rightIcon, isLoading, children, ...props }, ref) => {\n return (\n <BaseButton\n ref={ref}\n className={buttonVariants({ variant, size, className: className || '' })}\n disabled={isLoading || props.disabled}\n data-loading={isLoading || undefined}\n {...props}\n >\n {isLoading && <Spinner size=\"xs\" className={cn('mr-2 text-muted')} />}\n <div className=\"flex items-center gap-2\">\n {!isLoading && leftIcon && <span>{leftIcon}</span>}\n {children}\n {!isLoading && rightIcon && <span>{rightIcon}</span>}\n </div>\n </BaseButton>\n );\n }\n);\nButton.displayName = 'Button';\n\nexport { Button };\n"
157
174
  }
158
175
  ]
159
176
  },
@@ -180,7 +197,7 @@
180
197
  "files": [
181
198
  {
182
199
  "path": "src/components/ui/card/Card.tsx",
183
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst cardVariants = tv({\r\n // Kraken: 16px radius, whisper shadow rgba(0,0,0,0.03)\r\n base: 'rounded-xl border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.03)_0px_4px_24px]',\r\n variants: {\r\n padding: {\r\n none: '',\r\n sm: 'p-4',\r\n md: 'p-6',\r\n },\r\n },\r\n defaultVariants: {\r\n padding: 'none',\r\n },\r\n});\r\n\r\n/** Props for the Card component */\r\nexport interface CardProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof cardVariants> {}\r\n\r\nconst Card = React.forwardRef<HTMLDivElement, CardProps>(\r\n ({ className, padding, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cardVariants({ padding, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nCard.displayName = 'Card';\r\n\r\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex flex-col space-y-1.5 p-6 border-b border-border/50', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardHeader.displayName = 'CardHeader';\r\n\r\nconst CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h3\r\n ref={ref}\r\n className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardTitle.displayName = 'CardTitle';\r\n\r\nconst CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-sm text-muted-foreground', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardDescription.displayName = 'CardDescription';\r\n\r\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={cn('p-6 pt-6', className)} {...props} />\r\n )\r\n);\r\nCardContent.displayName = 'CardContent';\r\n\r\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex items-center p-6 pt-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardFooter.displayName = 'CardFooter';\r\n\r\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\r\n"
200
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst cardVariants = tv({\n // Kraken: 16px radius, whisper shadow rgba(0,0,0,0.03)\n base: 'rounded-xl border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.03)_0px_4px_24px]',\n variants: {\n padding: {\n none: '',\n sm: 'p-4',\n md: 'p-6',\n },\n },\n defaultVariants: {\n padding: 'none',\n },\n});\n\n/** Props for the Card component */\nexport interface CardProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof cardVariants> {}\n\nconst Card = React.forwardRef<HTMLDivElement, CardProps>(\n ({ className, padding, ...props }, ref) => (\n <div\n ref={ref}\n className={cardVariants({ padding, className })}\n {...props}\n />\n )\n);\nCard.displayName = 'Card';\n\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('flex flex-col space-y-1.5 p-6 border-b border-border/50', className)}\n {...props}\n />\n )\n);\nCardHeader.displayName = 'CardHeader';\n\nconst CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(\n ({ className, ...props }, ref) => (\n <h3\n ref={ref}\n className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}\n {...props}\n />\n )\n);\nCardTitle.displayName = 'CardTitle';\n\nconst CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\n ({ className, ...props }, ref) => (\n <p\n ref={ref}\n className={cn('text-sm text-muted-foreground', className)}\n {...props}\n />\n )\n);\nCardDescription.displayName = 'CardDescription';\n\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={cn('p-6 pt-6', className)} {...props} />\n )\n);\nCardContent.displayName = 'CardContent';\n\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('flex items-center p-6 pt-0', className)}\n {...props}\n />\n )\n);\nCardFooter.displayName = 'CardFooter';\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\n"
184
201
  }
185
202
  ]
186
203
  },
@@ -207,12 +224,12 @@
207
224
  "internalDependencies": [],
208
225
  "files": [
209
226
  {
210
- "path": "src/components/ui/chart/chart-tokens.ts",
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"
227
+ "path": "src/components/ui/chart/Chart.tsx",
228
+ "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"
212
229
  },
213
230
  {
214
- "path": "src/components/ui/chart/Chart.tsx",
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"
231
+ "path": "src/components/ui/chart/chart-tokens.ts",
232
+ "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"
216
233
  }
217
234
  ]
218
235
  },
@@ -227,7 +244,7 @@
227
244
  "files": [
228
245
  {
229
246
  "path": "src/components/ui/checkbox/Checkbox.tsx",
230
- "content": "import * as React from 'react';\r\nimport { Checkbox as BaseCheckbox } from '@base-ui/react';\r\nimport { Check, Minus } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst checkboxVariants = tv({\r\n slots: {\r\n root: 'group flex shrink-0 items-center justify-center rounded border transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed border-border bg-background dark:data-checked:bg-primary/90 dark:data-checked:border-primary/90 dark:data-indeterminate:bg-primary/90 dark:data-indeterminate:border-primary/90',\r\n indicator: 'dark:text-primary-foreground text-primary flex items-center justify-center',\r\n icon: 'h-full w-full stroke-[4]',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-4 w-4' },\r\n md: { root: 'h-5 w-5' },\r\n lg: { root: 'h-6 w-6' },\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\nconst { root, indicator, icon } = checkboxVariants();\r\n\r\n/** Props for the Checkbox component */\r\nexport interface CheckboxProps\r\n extends Omit<BaseCheckbox.Root.Props, 'className'>,\r\n VariantProps<typeof checkboxVariants> {\r\n /** Text label displayed next to the checkbox */\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Checkbox = React.forwardRef<React.ElementRef<typeof BaseCheckbox.Root>, CheckboxProps>(\r\n (allProps, ref) => {\r\n const { className, size = 'md', label, id, indeterminate, checked, defaultChecked, ...restProps } = allProps;\r\n const defaultId = React.useId();\r\n const checkboxId = id || defaultId;\r\n const { root, indicator, icon } = checkboxVariants({ size });\r\n\r\n // Nếu checked được truyền tường minh (kể cả undefined), normalize về false\r\n // để tránh chuyển từ uncontrolled → controlled trong suốt vòng đời component\r\n const normalizedChecked = 'checked' in allProps ? (checked ?? false) : undefined;\r\n\r\n return (\r\n <div className={cn(\"flex items-center gap-2\", restProps.disabled && \"opacity-50 cursor-not-allowed\")}>\r\n <BaseCheckbox.Root\r\n ref={ref}\r\n id={checkboxId}\r\n className={root({ className: cn(!restProps.disabled && 'cursor-pointer', className) })}\r\n indeterminate={indeterminate}\r\n checked={normalizedChecked}\r\n defaultChecked={defaultChecked}\r\n {...restProps}\r\n >\r\n <BaseCheckbox.Indicator className={indicator()}>\r\n {indeterminate ? (\r\n <Minus className={icon()} />\r\n ) : (\r\n <Check className={icon()} />\r\n )}\r\n </BaseCheckbox.Indicator>\r\n </BaseCheckbox.Root>\r\n {label && (\r\n <label\r\n htmlFor={checkboxId}\r\n className={cn(\"text-sm font-medium leading-none select-none\", restProps.disabled ? \"cursor-not-allowed\" : \"cursor-pointer\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nCheckbox.displayName = 'Checkbox';\r\n\r\nexport { Checkbox };\r\n"
247
+ "content": "import * as React from 'react';\nimport { Checkbox as BaseCheckbox } from '@base-ui/react';\nimport { Check, Minus } from 'lucide-react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst checkboxVariants = tv({\n slots: {\n root: 'group flex shrink-0 items-center justify-center rounded border transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed border-border bg-background dark:data-checked:bg-primary/90 dark:data-checked:border-primary/90 dark:data-indeterminate:bg-primary/90 dark:data-indeterminate:border-primary/90',\n indicator: 'dark:text-primary-foreground text-primary flex items-center justify-center',\n icon: 'h-full w-full stroke-[4]',\n },\n variants: {\n size: {\n sm: { root: 'h-4 w-4' },\n md: { root: 'h-5 w-5' },\n lg: { root: 'h-6 w-6' },\n }\n },\n defaultVariants: {\n size: 'md'\n }\n});\n\nconst { root, indicator, icon } = checkboxVariants();\n\n/** Props for the Checkbox component */\nexport interface CheckboxProps\n extends Omit<BaseCheckbox.Root.Props, 'className'>,\n VariantProps<typeof checkboxVariants> {\n /** Text label displayed next to the checkbox */\n label?: string;\n className?: string;\n}\n\nconst Checkbox = React.forwardRef<React.ElementRef<typeof BaseCheckbox.Root>, CheckboxProps>(\n (allProps, ref) => {\n const { className, size = 'md', label, id, indeterminate, checked, defaultChecked, ...restProps } = allProps;\n const defaultId = React.useId();\n const checkboxId = id || defaultId;\n const { root, indicator, icon } = checkboxVariants({ size });\n\n // Nếu checked được truyền tường minh (kể cả undefined), normalize về false\n // để tránh chuyển từ uncontrolled → controlled trong suốt vòng đời component\n const normalizedChecked = 'checked' in allProps ? (checked ?? false) : undefined;\n\n return (\n <div className={cn(\"flex items-center gap-2\", restProps.disabled && \"opacity-50 cursor-not-allowed\")}>\n <BaseCheckbox.Root\n ref={ref}\n id={checkboxId}\n className={root({ className: cn(!restProps.disabled && 'cursor-pointer', className) })}\n indeterminate={indeterminate}\n checked={normalizedChecked}\n defaultChecked={defaultChecked}\n {...restProps}\n >\n <BaseCheckbox.Indicator className={indicator()}>\n {indeterminate ? (\n <Minus className={icon()} />\n ) : (\n <Check className={icon()} />\n )}\n </BaseCheckbox.Indicator>\n </BaseCheckbox.Root>\n {label && (\n <label\n htmlFor={checkboxId}\n className={cn(\"text-sm font-medium leading-none select-none\", restProps.disabled ? \"cursor-not-allowed\" : \"cursor-pointer\")}\n >\n {label}\n </label>\n )}\n </div>\n );\n }\n);\n\nCheckbox.displayName = 'Checkbox';\n\nexport { Checkbox };\n"
231
248
  }
232
249
  ]
233
250
  },
@@ -242,7 +259,7 @@
242
259
  "files": [
243
260
  {
244
261
  "path": "src/components/ui/collapsible/Collapsible.tsx",
245
- "content": "import * as React from 'react';\r\nimport { Collapsible as BaseCollapsible } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst collapsibleVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n trigger: 'flex w-full items-center justify-between py-3 px-4 text-sm font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n content: 'pt-2',\r\n }\r\n});\r\n\r\n/** Props for the Collapsible component */\r\nexport interface CollapsibleProps {\r\n /** Content rendered inside the trigger button */\r\n trigger: React.ReactNode;\r\n children: React.ReactNode;\r\n /** Whether the panel is open by default (uncontrolled) */\r\n defaultOpen?: boolean;\r\n /** Controlled open state */\r\n open?: boolean;\r\n /** Callback fired when the open state changes */\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n /** Additional class name applied to the trigger button */\r\n triggerClassName?: string;\r\n}\r\n\r\nconst Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(({\r\n trigger,\r\n children,\r\n defaultOpen,\r\n open,\r\n onOpenChange,\r\n className,\r\n triggerClassName,\r\n}, ref) => {\r\n const { root, trigger: triggerCls, panel, content } = collapsibleVariants();\r\n\r\n return (\r\n <BaseCollapsible.Root\r\n ref={ref}\r\n className={root({ className })}\r\n defaultOpen={defaultOpen}\r\n open={open}\r\n onOpenChange={onOpenChange}\r\n >\r\n <BaseCollapsible.Trigger className={triggerCls({ className: triggerClassName })}>\r\n {trigger}\r\n <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\r\n </BaseCollapsible.Trigger>\r\n <BaseCollapsible.Panel className={panel()}>\r\n <div className={content()}>\r\n {children}\r\n </div>\r\n </BaseCollapsible.Panel>\r\n </BaseCollapsible.Root>\r\n );\r\n});\r\n\r\nCollapsible.displayName = 'Collapsible';\r\n\r\nexport { Collapsible };\r\n"
262
+ "content": "import * as React from 'react';\nimport { Collapsible as BaseCollapsible } from '@base-ui/react';\nimport { ChevronDown } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\n\nconst collapsibleVariants = tv({\n slots: {\n root: 'w-full',\n trigger: 'flex w-full items-center justify-between py-3 px-4 text-sm font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary [&[data-panel-open]>svg]:rotate-180',\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\n content: 'pt-2',\n }\n});\n\n/** Props for the Collapsible component */\nexport interface CollapsibleProps {\n /** Content rendered inside the trigger button */\n trigger: React.ReactNode;\n children: React.ReactNode;\n /** Whether the panel is open by default (uncontrolled) */\n defaultOpen?: boolean;\n /** Controlled open state */\n open?: boolean;\n /** Callback fired when the open state changes */\n onOpenChange?: (open: boolean) => void;\n className?: string;\n /** Additional class name applied to the trigger button */\n triggerClassName?: string;\n}\n\nconst Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(({\n trigger,\n children,\n defaultOpen,\n open,\n onOpenChange,\n className,\n triggerClassName,\n}, ref) => {\n const { root, trigger: triggerCls, panel, content } = collapsibleVariants();\n\n return (\n <BaseCollapsible.Root\n ref={ref}\n className={root({ className })}\n defaultOpen={defaultOpen}\n open={open}\n onOpenChange={onOpenChange}\n >\n <BaseCollapsible.Trigger className={triggerCls({ className: triggerClassName })}>\n {trigger}\n <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\n </BaseCollapsible.Trigger>\n <BaseCollapsible.Panel className={panel()}>\n <div className={content()}>\n {children}\n </div>\n </BaseCollapsible.Panel>\n </BaseCollapsible.Root>\n );\n});\n\nCollapsible.displayName = 'Collapsible';\n\nexport { Collapsible };\n"
246
263
  }
247
264
  ]
248
265
  },
@@ -257,7 +274,7 @@
257
274
  "files": [
258
275
  {
259
276
  "path": "src/components/ui/combobox/ComboBox.tsx",
260
- "content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst comboboxVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex flex-wrap items-center gap-1.5 min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-999 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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 item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\r\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\r\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-999',\r\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\r\n }\r\n});\r\n\r\n/** A single option in the ComboBox dropdown */\r\nexport interface ComboBoxOption {\r\n /** Display text for the option */\r\n label: string;\r\n /** Unique value identifying the option */\r\n value: string;\r\n}\r\n\r\n/** Props for the ComboBox component */\r\nexport interface ComboBoxProps {\r\n /** Array of selectable options */\r\n options: ComboBoxOption[];\r\n /** Label text displayed above the combobox */\r\n label?: string;\r\n placeholder?: string;\r\n /** Controlled selected value (string for single, string[] for multiple) */\r\n value?: string | string[];\r\n /** Initial value for uncontrolled usage */\r\n defaultValue?: string | string[];\r\n /** Callback fired when the selected value changes */\r\n onValueChange?: (value: string | string[]) => void;\r\n /** Alias for onValueChange — compatible with React Hook Form field.onChange */\r\n onChange?: (value: string | string[]) => void;\r\n /** Enable multi-select mode with chip display */\r\n multiple?: boolean;\r\n /** Shows a loading spinner on the dropdown trigger */\r\n isLoading?: boolean;\r\n className?: string;\r\n /** Enable type-ahead filtering of options (default: true) */\r\n autocomplete?: boolean;\r\n /** Text shown when no options match the filter */\r\n emptyText?: string;\r\n /** Label for the \"select all\" action in multi-select mode */\r\n selectAllText?: string;\r\n /** Label for the \"clear all\" action in multi-select mode */\r\n clearAllText?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n leftIcon?: React.ReactNode;\r\n /** Mark the field as required — renders asterisk next to label */\r\n required?: boolean;\r\n /** Error message displayed below the combobox */\r\n error?: string;\r\n /** Value emitted when clear button is clicked (default: empty string or empty array) */\r\n clearValue?: string | null;\r\n}\r\n\r\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\r\n ({ options, label, placeholder, value, defaultValue, onValueChange, onChange, multiple, isLoading, className, autocomplete = true, emptyText = 'No results found.', selectAllText = 'Select all', clearAllText = 'Clear all', leftIcon, required, error, clearValue }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const getClearValue = (): string | string[] | null => {\r\n if (clearValue !== undefined) return clearValue;\r\n return multiple ? [] : null;\r\n };\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) {\r\n setInternalValue(newVal);\r\n }\r\n if (newVal !== null) {\r\n onValueChange?.(newVal);\r\n onChange?.(newVal);\r\n } else {\r\n // Khi clear, dùng clearValue (default: null hoặc [])\r\n const clear = getClearValue();\r\n if (clear !== null) {\r\n onValueChange?.(clear);\r\n onChange?.(clear);\r\n } else {\r\n // Nếu clearValue = null, emit '' để React Hook Form nhận được value\r\n onValueChange?.('');\r\n onChange?.('');\r\n }\r\n }\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n const clear = getClearValue();\r\n if (value === undefined) {\r\n setInternalValue(clear);\r\n }\r\n if (clear !== null) {\r\n onValueChange?.(clear);\r\n onChange?.(clear);\r\n } else {\r\n onValueChange?.('');\r\n onChange?.('');\r\n }\r\n setInputValue('');\r\n };\r\n\r\n const hasValue = multiple\r\n ? Array.isArray(activeValue) && activeValue.length > 0\r\n : !!activeValue;\r\n\r\n // Lọc options theo text người dùng đang gõ\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue || !autocomplete) return options;\r\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\r\n if (!multiple && activeValue) {\r\n const selectedOption = options.find((o) => o.value === activeValue);\r\n if (selectedOption && inputValue === selectedOption.label) return options;\r\n }\r\n return options.filter(opt =>\r\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, autocomplete, multiple, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n multiple={multiple}\r\n onInputValueChange={handleInputValueChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find((o) => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\r\n </label>\r\n )}\r\n\r\n <div className=\"relative w-full group\" data-invalid={!!error || undefined}>\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n {multiple ? (\r\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\r\n {Array.isArray(activeValue) && activeValue.map((val) => {\r\n const option = options.find(o => o.value === val);\r\n return (\r\n <BaseCombobox.Chip key={val} className={chip()}>\r\n {option?.label || val}\r\n <BaseCombobox.ChipRemove className={chipRemove()}>\r\n <X className=\"h-3 w-3\" />\r\n </BaseCombobox.ChipRemove>\r\n </BaseCombobox.Chip>\r\n );\r\n })}\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\r\n className={input()}\r\n />\r\n </BaseCombobox.Chips>\r\n ) : (\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={placeholder}\r\n className={cn(input(), !autocomplete && 'cursor-pointer')}\r\n />\r\n )}\r\n\r\n <div className=\"flex items-center gap-1 shrink-0 ml-auto text-muted-foreground\">\r\n {hasValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseCombobox.Trigger className=\"transition-transform group-data-open:rotate-180\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\r\n )}\r\n </div>\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)', zIndex: 9999 }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n {multiple && options.length > 0 && (\r\n <div className={actionsHeader()}>\r\n <button\r\n type=\"button\"\r\n aria-label={selectAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(options.map((o) => o.value));\r\n }}\r\n className={actionButton()}\r\n >\r\n {selectAllText}\r\n </button>\r\n <div className=\"w-px h-3 bg-border\" />\r\n <button\r\n type=\"button\"\r\n aria-label={clearAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange([]);\r\n }}\r\n className={actionButton()}\r\n >\r\n {clearAllText}\r\n </button>\r\n </div>\r\n )}\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n {error && <p className=\"text-xs text-destructive\">{error}</p>}\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nComboBox.displayName = 'ComboBox';\r\n\r\nexport { ComboBox };\r\n"
277
+ "content": "import * as React from 'react';\nimport { Combobox as BaseCombobox } from '@base-ui/react';\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst comboboxVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5 w-full',\n inputContainer: 'flex flex-wrap items-center gap-1.5 min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\n input: 'flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\n popup: 'z-999 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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 item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-999',\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\n }\n});\n\n/** A single option in the ComboBox dropdown */\nexport interface ComboBoxOption {\n /** Display text for the option */\n label: string;\n /** Unique value identifying the option */\n value: string;\n}\n\n/** Props for the ComboBox component */\nexport interface ComboBoxProps {\n /** Array of selectable options */\n options: ComboBoxOption[];\n /** Label text displayed above the combobox */\n label?: string;\n placeholder?: string;\n /** Controlled selected value (string for single, string[] for multiple) */\n value?: string | string[];\n /** Initial value for uncontrolled usage */\n defaultValue?: string | string[];\n /** Callback fired when the selected value changes */\n onValueChange?: (value: string | string[]) => void;\n /** Alias for onValueChange — compatible with React Hook Form field.onChange */\n onChange?: (value: string | string[]) => void;\n /** Enable multi-select mode with chip display */\n multiple?: boolean;\n /** Shows a loading spinner on the dropdown trigger */\n isLoading?: boolean;\n className?: string;\n /** Enable type-ahead filtering of options (default: true) */\n autocomplete?: boolean;\n /** Text shown when no options match the filter */\n emptyText?: string;\n /** Label for the \"select all\" action in multi-select mode */\n selectAllText?: string;\n /** Label for the \"clear all\" action in multi-select mode */\n clearAllText?: string;\n /** Icon rendered at the start (left side) of the input */\n leftIcon?: React.ReactNode;\n /** Mark the field as required — renders asterisk next to label */\n required?: boolean;\n /** Error message displayed below the combobox */\n error?: string;\n /** Value emitted when clear button is clicked (default: empty string or empty array) */\n clearValue?: string | null;\n}\n\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\n ({ options, label, placeholder, value, defaultValue, onValueChange, onChange, multiple, isLoading, className, autocomplete = true, emptyText = 'No results found.', selectAllText = 'Select all', clearAllText = 'Clear all', leftIcon, required, error, clearValue }, ref) => {\n const [inputValue, setInputValue] = React.useState('');\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\n const isSelectingRef = React.useRef(false);\n\n const activeValue = value !== undefined ? value : internalValue;\n\n const getClearValue = (): string | string[] | null => {\n if (clearValue !== undefined) return clearValue;\n return multiple ? [] : null;\n };\n\n const handleValueChange = (newVal: string | string[] | null) => {\n isSelectingRef.current = true;\n if (value === undefined) {\n setInternalValue(newVal);\n }\n if (newVal !== null) {\n onValueChange?.(newVal);\n onChange?.(newVal);\n } else {\n // Khi clear, dùng clearValue (default: null hoặc [])\n const clear = getClearValue();\n if (clear !== null) {\n onValueChange?.(clear);\n onChange?.(clear);\n } else {\n // Nếu clearValue = null, emit '' để React Hook Form nhận được value\n onValueChange?.('');\n onChange?.('');\n }\n }\n };\n\n const handleInputValueChange = (val: string) => {\n if (isSelectingRef.current) {\n isSelectingRef.current = false;\n return;\n }\n setInputValue(val);\n };\n\n const handleClear = (e: React.SyntheticEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const clear = getClearValue();\n if (value === undefined) {\n setInternalValue(clear);\n }\n if (clear !== null) {\n onValueChange?.(clear);\n onChange?.(clear);\n } else {\n onValueChange?.('');\n onChange?.('');\n }\n setInputValue('');\n };\n\n const hasValue = multiple\n ? Array.isArray(activeValue) && activeValue.length > 0\n : !!activeValue;\n\n // Lọc options theo text người dùng đang gõ\n const filteredOptions = React.useMemo(() => {\n if (!inputValue || !autocomplete) return options;\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\n if (!multiple && activeValue) {\n const selectedOption = options.find((o) => o.value === activeValue);\n if (selectedOption && inputValue === selectedOption.label) return options;\n }\n return options.filter(opt =>\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\n );\n }, [options, inputValue, autocomplete, multiple, activeValue]);\n\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\n\n return (\n <BaseCombobox.Root\n value={activeValue}\n onValueChange={handleValueChange}\n multiple={multiple}\n onInputValueChange={handleInputValueChange}\n autoHighlight\n itemToStringLabel={(val: string) => options.find((o) => o.value === val)?.label ?? val}\n >\n <div className={root({ className })}>\n {label && (\n <label className=\"text-sm font-medium text-foreground\">\n {label}\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\n </label>\n )}\n\n <div className=\"relative w-full group\" data-invalid={!!error || undefined}>\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\n {leftIcon && (\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\n {leftIcon}\n </div>\n )}\n {multiple ? (\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\n {Array.isArray(activeValue) && activeValue.map((val) => {\n const option = options.find(o => o.value === val);\n return (\n <BaseCombobox.Chip key={val} className={chip()}>\n {option?.label || val}\n <BaseCombobox.ChipRemove className={chipRemove()}>\n <X className=\"h-3 w-3\" />\n </BaseCombobox.ChipRemove>\n </BaseCombobox.Chip>\n );\n })}\n <BaseCombobox.Input\n ref={ref}\n readOnly={!autocomplete}\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\n className={input()}\n />\n </BaseCombobox.Chips>\n ) : (\n <BaseCombobox.Input\n ref={ref}\n readOnly={!autocomplete}\n placeholder={placeholder}\n className={cn(input(), !autocomplete && 'cursor-pointer')}\n />\n )}\n\n <div className=\"flex items-center gap-1 shrink-0 ml-auto text-muted-foreground\">\n {hasValue ? (\n <span\n role=\"button\"\n aria-label=\"Clear selection\"\n onPointerDown={(e) => {\n e.stopPropagation();\n handleClear(e);\n }}\n onClick={(e) => e.stopPropagation()}\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\n >\n <X className=\"h-3.5 w-3.5\" />\n </span>\n ) : (\n <BaseCombobox.Trigger className=\"transition-transform group-data-open:rotate-180\">\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\n </BaseCombobox.Trigger>\n )}\n </div>\n </BaseCombobox.InputGroup>\n\n <BaseCombobox.Portal>\n <BaseCombobox.Positioner\n anchor={inputGroupRef}\n sideOffset={4}\n style={{ width: 'var(--anchor-width)', zIndex: 9999 }}\n >\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\n {multiple && options.length > 0 && (\n <div className={actionsHeader()}>\n <button\n type=\"button\"\n aria-label={selectAllText}\n onClick={(e) => {\n e.preventDefault();\n handleValueChange(options.map((o) => o.value));\n }}\n className={actionButton()}\n >\n {selectAllText}\n </button>\n <div className=\"w-px h-3 bg-border\" />\n <button\n type=\"button\"\n aria-label={clearAllText}\n onClick={(e) => {\n e.preventDefault();\n handleValueChange([]);\n }}\n className={actionButton()}\n >\n {clearAllText}\n </button>\n </div>\n )}\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\n {filteredOptions.length === 0 ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\n ) : (\n filteredOptions.map((option) => (\n <BaseCombobox.Item\n key={option.value}\n value={option.value}\n className={item()}\n >\n <BaseCombobox.ItemIndicator className={indicator()}>\n <Check className=\"h-4 w-4\" />\n </BaseCombobox.ItemIndicator>\n {option.label}\n </BaseCombobox.Item>\n ))\n )}\n </BaseCombobox.List>\n </BaseCombobox.Popup>\n </BaseCombobox.Positioner>\n </BaseCombobox.Portal>\n </div>\n {error && <p className=\"text-xs text-destructive\">{error}</p>}\n </div>\n </BaseCombobox.Root>\n );\n }\n);\n\nComboBox.displayName = 'ComboBox';\n\nexport { ComboBox };\n"
261
278
  }
262
279
  ]
263
280
  },
@@ -272,7 +289,7 @@
272
289
  "files": [
273
290
  {
274
291
  "path": "src/components/ui/command/Command.tsx",
275
- "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 visibleItemCount: number;\r\n setVisibleItemCount: 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\ninterface GroupContextValue {\r\n groupId: string;\r\n visibleCount: number;\r\n setVisibleCount: React.Dispatch<React.SetStateAction<number>>;\r\n}\r\n\r\nconst GroupContext = React.createContext<GroupContextValue | null>(null);\r\n\r\nfunction useGroup() {\r\n return React.useContext(GroupContext);\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 const [visibleItemCount, setVisibleItemCount] = React.useState(0);\r\n\r\n React.useEffect(() => {\r\n if (open) {\r\n setSearch('');\r\n setHighlightedIndex(0);\r\n setVisibleItemCount(0);\r\n }\r\n }, [open]);\r\n\r\n return (\r\n <CommandContext.Provider value={{ search, setSearch, highlightedIndex, setHighlightedIndex, visibleItemCount, setVisibleItemCount }}>\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 const [groupId] = React.useState(() => Math.random().toString(36));\r\n const [visibleCount, setVisibleCount] = React.useState(0);\r\n\r\n return (\r\n <GroupContext.Provider value={{ groupId, visibleCount, setVisibleCount }}>\r\n <div ref={ref} className={cn(styles.group(), className)} role=\"group\" {...props}>\r\n {heading && visibleCount > 0 && <div className={styles.groupLabel()}>{heading}</div>}\r\n {children}\r\n </div>\r\n </GroupContext.Provider>\r\n );\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, setVisibleItemCount } = 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 const group = useGroup();\r\n\r\n React.useEffect(() => {\r\n setVisibleItemCount((prev) => prev + (isVisible ? 1 : 0));\r\n group?.setVisibleCount((prev) => prev + (isVisible ? 1 : 0));\r\n return () => {\r\n setVisibleItemCount((prev) => prev - (isVisible ? 1 : 0));\r\n group?.setVisibleCount((prev) => prev - (isVisible ? 1 : 0));\r\n };\r\n }, [isVisible, setVisibleItemCount, group]);\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 const { visibleItemCount } = useCommand();\r\n if (visibleItemCount > 0) return null;\r\n return (\r\n <div ref={ref} className={cn(styles.empty(), className)} {...props}>\r\n {children}\r\n </div>\r\n );\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"
292
+ "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 ',\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 visibleItemCount: number;\n setVisibleItemCount: 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\ninterface GroupContextValue {\n groupId: string;\n visibleCount: number;\n setVisibleCount: React.Dispatch<React.SetStateAction<number>>;\n}\n\nconst GroupContext = React.createContext<GroupContextValue | null>(null);\n\nfunction useGroup() {\n return React.useContext(GroupContext);\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 const [visibleItemCount, setVisibleItemCount] = React.useState(0);\n\n React.useEffect(() => {\n if (open) {\n setSearch('');\n setHighlightedIndex(0);\n setVisibleItemCount(0);\n }\n }, [open]);\n\n return (\n <CommandContext.Provider value={{ search, setSearch, highlightedIndex, setHighlightedIndex, visibleItemCount, setVisibleItemCount }}>\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 const [groupId] = React.useState(() => Math.random().toString(36));\n const [visibleCount, setVisibleCount] = React.useState(0);\n\n return (\n <GroupContext.Provider value={{ groupId, visibleCount, setVisibleCount }}>\n <div ref={ref} className={cn(styles.group(), className)} role=\"group\" {...props}>\n {heading && visibleCount > 0 && <div className={styles.groupLabel()}>{heading}</div>}\n {children}\n </div>\n </GroupContext.Provider>\n );\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, setVisibleItemCount } = 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 const group = useGroup();\n\n React.useEffect(() => {\n setVisibleItemCount((prev) => prev + (isVisible ? 1 : 0));\n group?.setVisibleCount((prev) => prev + (isVisible ? 1 : 0));\n return () => {\n setVisibleItemCount((prev) => prev - (isVisible ? 1 : 0));\n group?.setVisibleCount((prev) => prev - (isVisible ? 1 : 0));\n };\n }, [isVisible, setVisibleItemCount, group]);\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 const { visibleItemCount } = useCommand();\n if (visibleItemCount > 0) return null;\n return (\n <div ref={ref} className={cn(styles.empty(), className)} {...props}>\n {children}\n </div>\n );\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};\n"
276
293
  }
277
294
  ]
278
295
  },
@@ -305,7 +322,7 @@
305
322
  "files": [
306
323
  {
307
324
  "path": "src/components/ui/datepicker/DatePicker.tsx",
308
- "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { DayPicker, type DateRange } from 'react-day-picker';\r\nimport { format } from 'date-fns';\r\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\nimport { Button } from '../button/Button';\r\n\r\n// ---------- types ----------\r\n\r\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\nexport type TimePickerStyle = 'input' | 'select';\r\n\r\ninterface TimeParts {\r\n h: string;\r\n m: string;\r\n s: string;\r\n}\r\n\r\n/** Props for the DatePicker component */\r\nexport interface DatePickerProps {\r\n /** Picker mode: single date, date range, or time-only */\r\n mode?: DatePickerMode;\r\n /** Selected date (Date for single, DateRange for range) */\r\n value?: Date | DateRange | string;\r\n /** Callback fired when the date changes */\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n /** Current time string, only used when mode is 'time-only' */\r\n timeValue?: string;\r\n /** Callback fired when the time value changes (time-only mode) */\r\n onTimeChange?: (time: string) => void;\r\n /** Label text displayed above the picker */\r\n label?: string;\r\n /** Placeholder text when no date is selected */\r\n placeholder?: string;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Show time picker alongside the calendar */\r\n showTime?: boolean;\r\n /** Time format: hours only, hours:minutes, or hours:minutes:seconds */\r\n timeFormat?: TimeFormat;\r\n /** Time picker UI style: native input or dropdown selects */\r\n timePickerStyle?: TimePickerStyle;\r\n /** Disable the entire picker */\r\n disabled?: boolean;\r\n className?: string;\r\n /** Helper text displayed below the picker */\r\n description?: string;\r\n /** Error message displayed below the picker (replaces description) */\r\n error?: string;\r\n required?: boolean;\r\n captionLayout?: \"label\" | \"dropdown\" | \"dropdown-months\" | \"dropdown-years\" | undefined;\r\n}\r\n\r\n// ---------- helpers ----------\r\n\r\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\r\n\r\nfunction 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\nfunction 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\nfunction 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\nfunction 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\nfunction 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\nconst hoursOptions = padOptions(24);\r\nconst minutesOptions = padOptions(60);\r\nconst secondsOptions = padOptions(60);\r\n\r\n// ---------- styles ----------\r\n\r\nconst popoverContent = tv({\r\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n});\r\n\r\n// ---------- sub-components ----------\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\ninterface TimePickerProps {\r\n parts: TimeParts;\r\n onChange: (parts: TimeParts) => void;\r\n timeFormat: TimeFormat;\r\n timePickerStyle: TimePickerStyle;\r\n}\r\n\r\nconst 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\r\n// ---------- main component ----------\r\n\r\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\r\n mode = 'single',\r\n value,\r\n onChange,\r\n timeValue,\r\n onTimeChange,\r\n label,\r\n placeholder = 'Select date...',\r\n disablePastDates = false,\r\n showTime = false,\r\n timeFormat = 'HH:mm:ss',\r\n timePickerStyle = 'select',\r\n disabled = false,\r\n className,\r\n description,\r\n error,\r\n required,\r\n captionLayout = undefined,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n // Controlled nếu có onChange — value prop được trust kể cả khi undefined\r\n const isControlled = onChange !== undefined;\r\n const [internalDate, setInternalDate] = React.useState<Date | DateRange | undefined>(undefined);\r\n const date = isControlled ? value : internalDate;\r\n\r\n const [calendarMonth, setCalendarMonth] = React.useState<Date>(new Date());\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (newOpen) {\r\n const selectedDate = date instanceof Date ? date : (date as DateRange)?.from;\r\n setCalendarMonth(selectedDate ?? new Date());\r\n }\r\n setOpen(newOpen);\r\n };\r\n\r\n const timeParts = React.useMemo<TimeParts>(() => {\r\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\r\n if (date instanceof Date) return dateToTimeParts(date);\r\n return DEFAULT_TIME;\r\n }, [date, timeValue, mode]);\r\n\r\n const handlePartsChange = (newParts: TimeParts) => {\r\n if (mode === 'time-only') {\r\n onTimeChange?.(buildTimeString(newParts, timeFormat));\r\n return;\r\n }\r\n if (date instanceof Date) {\r\n const newDate = applyTimeToDate(date, newParts);\r\n if (!isControlled) setInternalDate(newDate);\r\n onChange?.(newDate);\r\n }\r\n };\r\n\r\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\r\n if (!selectedDate) {\r\n if (!isControlled) setInternalDate(undefined);\r\n onChange?.(undefined);\r\n return;\r\n }\r\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\r\n const newDate = applyTimeToDate(selectedDate, timeParts);\r\n if (!isControlled) setInternalDate(newDate);\r\n onChange?.(newDate);\r\n } else {\r\n if (!isControlled) setInternalDate(selectedDate as Date | DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n if (mode === 'single' && !showTime) setOpen(false);\r\n }\r\n };\r\n\r\n const triggerLabel = React.useMemo(() => {\r\n if (mode === 'time-only') {\r\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\r\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\r\n return <span className=\"text-muted-foreground\">{placeholder || 'Select time...'}</span>;\r\n return <span>{val}</span>;\r\n }\r\n\r\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n\r\n if (mode === 'single' && date instanceof Date) {\r\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\r\n }\r\n\r\n if (mode === 'range') {\r\n const range = date as DateRange;\r\n if (range.from && range.to) {\r\n return (\r\n <span>\r\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\r\n </span>\r\n );\r\n }\r\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\r\n }\r\n\r\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\r\n\r\n const isTimeMode = mode === 'time-only';\r\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\r\n\r\n return (\r\n <div ref={ref} className={`flex flex-col gap-1.5 w-full ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\r\n </label>\r\n )}\r\n\r\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : handleOpenChange}>\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n ref={triggerRef}\r\n type=\"button\"\r\n disabled={disabled}\r\n className={[\r\n 'flex h-10 w-full items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm',\r\n 'ring-offset-background transition-shadow',\r\n 'hover:border-primary focus:border-primary focus:outline-none',\r\n 'disabled:cursor-not-allowed disabled:opacity-50',\r\n error ? 'border-danger focus:border-danger' : 'border-border',\r\n 'group',\r\n ].join(' ')}\r\n >\r\n {isTimeMode ? (\r\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n ) : (\r\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n )}\r\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\r\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\r\n </button>\r\n }\r\n />\r\n\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} className=\"z-50\">\r\n <BasePopover.Popup className={popoverContent()}>\r\n {!isTimeMode && mode === 'single' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"single\"\r\n locale={locales.vi}\r\n selected={date as Date | undefined}\r\n month={calendarMonth}\r\n onMonthChange={setCalendarMonth}\r\n onSelect={(d) => handleDateSelect(d)}\r\n captionLayout={captionLayout}\r\n startMonth={new Date(1900, 0)}\r\n endMonth={new Date(2100, 11)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n {!isTimeMode && mode === 'range' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"range\"\r\n locale={locales.vi}\r\n selected={date as DateRange | undefined}\r\n month={calendarMonth}\r\n onMonthChange={setCalendarMonth}\r\n onSelect={(d) => handleDateSelect(d)}\r\n captionLayout={captionLayout}\r\n startMonth={new Date(1900, 0)}\r\n endMonth={new Date(2100, 11)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n\r\n {needsTimePicker && (\r\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\r\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\r\n <Clock className=\"w-3.5 h-3.5\" />\r\n <span>\r\n {timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second'}\r\n </span>\r\n </div>\r\n <TimePicker\r\n parts={timeParts}\r\n onChange={handlePartsChange}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n )}\r\n\r\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\r\n <button\r\n type=\"button\"\r\n onClick={() => {\r\n if (mode === 'time-only') {\r\n onTimeChange?.('');\r\n } else {\r\n if (!isControlled) setInternalDate(undefined);\r\n onChange?.(undefined);\r\n }\r\n }}\r\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\r\n >\r\n Clear\r\n </button>\r\n <Button size=\"sm\" onClick={() => setOpen(false)}>\r\n Confirm\r\n </Button>\r\n </div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nDatePicker.displayName = \"DatePicker\";\r\n"
325
+ "content": "import * as React from 'react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { DayPicker, type DateRange } from 'react-day-picker';\nimport { format } from 'date-fns';\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport * as locales from 'react-day-picker/locale';\n\nimport 'react-day-picker/dist/style.css';\nimport { Button } from '../button/Button';\n\n// ---------- types ----------\n\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\nexport type TimePickerStyle = 'input' | 'select';\n\ninterface TimeParts {\n h: string;\n m: string;\n s: string;\n}\n\n/** Props for the DatePicker component */\nexport interface DatePickerProps {\n /** Picker mode: single date, date range, or time-only */\n mode?: DatePickerMode;\n /** Selected date (Date for single, DateRange for range) */\n value?: Date | DateRange | string;\n /** Callback fired when the date changes */\n onChange?: (date: Date | DateRange | undefined) => void;\n /** Current time string, only used when mode is 'time-only' */\n timeValue?: string;\n /** Callback fired when the time value changes (time-only mode) */\n onTimeChange?: (time: string) => void;\n /** Label text displayed above the picker */\n label?: string;\n /** Placeholder text when no date is selected */\n placeholder?: string;\n /** Disable all dates before today */\n disablePastDates?: boolean;\n /** Show time picker alongside the calendar */\n showTime?: boolean;\n /** Time format: hours only, hours:minutes, or hours:minutes:seconds */\n timeFormat?: TimeFormat;\n /** Time picker UI style: native input or dropdown selects */\n timePickerStyle?: TimePickerStyle;\n /** Disable the entire picker */\n disabled?: boolean;\n className?: string;\n /** Helper text displayed below the picker */\n description?: string;\n /** Error message displayed below the picker (replaces description) */\n error?: string;\n required?: boolean;\n captionLayout?: \"label\" | \"dropdown\" | \"dropdown-months\" | \"dropdown-years\" | undefined;\n}\n\n// ---------- helpers ----------\n\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\n\nfunction 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\nfunction 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\nfunction 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\nfunction 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\nfunction 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\nconst hoursOptions = padOptions(24);\nconst minutesOptions = padOptions(60);\nconst secondsOptions = padOptions(60);\n\n// ---------- styles ----------\n\nconst popoverContent = tv({\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\n});\n\n// ---------- sub-components ----------\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\ninterface TimePickerProps {\n parts: TimeParts;\n onChange: (parts: TimeParts) => void;\n timeFormat: TimeFormat;\n timePickerStyle: TimePickerStyle;\n}\n\nconst 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\n// ---------- main component ----------\n\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\n mode = 'single',\n value,\n onChange,\n timeValue,\n onTimeChange,\n label,\n placeholder = 'Select date...',\n disablePastDates = false,\n showTime = false,\n timeFormat = 'HH:mm:ss',\n timePickerStyle = 'select',\n disabled = false,\n className,\n description,\n error,\n required,\n captionLayout = undefined,\n}, ref) => {\n const [open, setOpen] = React.useState(false);\n const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n // Controlled nếu có onChange — value prop được trust kể cả khi undefined\n const isControlled = onChange !== undefined;\n const [internalDate, setInternalDate] = React.useState<Date | DateRange | undefined>(undefined);\n const date = isControlled ? value : internalDate;\n\n const [calendarMonth, setCalendarMonth] = React.useState<Date>(new Date());\n const handleOpenChange = (newOpen: boolean) => {\n if (newOpen) {\n const selectedDate = date instanceof Date ? date : (date as DateRange)?.from;\n setCalendarMonth(selectedDate ?? new Date());\n }\n setOpen(newOpen);\n };\n\n const timeParts = React.useMemo<TimeParts>(() => {\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\n if (date instanceof Date) return dateToTimeParts(date);\n return DEFAULT_TIME;\n }, [date, timeValue, mode]);\n\n const handlePartsChange = (newParts: TimeParts) => {\n if (mode === 'time-only') {\n onTimeChange?.(buildTimeString(newParts, timeFormat));\n return;\n }\n if (date instanceof Date) {\n const newDate = applyTimeToDate(date, newParts);\n if (!isControlled) setInternalDate(newDate);\n onChange?.(newDate);\n }\n };\n\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\n if (!selectedDate) {\n if (!isControlled) setInternalDate(undefined);\n onChange?.(undefined);\n return;\n }\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\n const newDate = applyTimeToDate(selectedDate, timeParts);\n if (!isControlled) setInternalDate(newDate);\n onChange?.(newDate);\n } else {\n if (!isControlled) setInternalDate(selectedDate as Date | DateRange);\n onChange?.(selectedDate as DateRange);\n if (mode === 'single' && !showTime) setOpen(false);\n }\n };\n\n const triggerLabel = React.useMemo(() => {\n if (mode === 'time-only') {\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\n return <span className=\"text-muted-foreground\">{placeholder || 'Select time...'}</span>;\n return <span>{val}</span>;\n }\n\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\n\n if (mode === 'single' && date instanceof Date) {\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\n }\n\n if (mode === 'range') {\n const range = date as DateRange;\n if (range.from && range.to) {\n return (\n <span>\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\n </span>\n );\n }\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\n }\n\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\n\n const isTimeMode = mode === 'time-only';\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\n\n return (\n <div ref={ref} className={`flex flex-col gap-1.5 w-full ${className || ''}`}>\n {label && (\n <label className=\"text-sm font-medium text-foreground\">\n {label}\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\n </label>\n )}\n\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : handleOpenChange}>\n <BasePopover.Trigger\n render={\n <button\n ref={triggerRef}\n type=\"button\"\n disabled={disabled}\n className={[\n 'flex h-10 w-full items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-shadow',\n 'hover:border-primary focus:border-primary focus:outline-none',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n error ? 'border-danger focus:border-danger' : 'border-border',\n 'group',\n ].join(' ')}\n >\n {isTimeMode ? (\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n ) : (\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n )}\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\n </button>\n }\n />\n\n <BasePopover.Portal>\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} className=\"z-50\">\n <BasePopover.Popup className={popoverContent()}>\n {!isTimeMode && mode === 'single' && (\n <div className=\"p-2 flex justify-center\">\n <DayPicker\n mode=\"single\"\n locale={locales.vi}\n selected={date as Date | undefined}\n month={calendarMonth}\n onMonthChange={setCalendarMonth}\n onSelect={(d) => handleDateSelect(d)}\n captionLayout={captionLayout}\n startMonth={new Date(1900, 0)}\n endMonth={new Date(2100, 11)}\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\n className=\"rdp-custom\"\n />\n </div>\n )}\n {!isTimeMode && mode === 'range' && (\n <div className=\"p-2 flex justify-center\">\n <DayPicker\n mode=\"range\"\n locale={locales.vi}\n selected={date as DateRange | undefined}\n month={calendarMonth}\n onMonthChange={setCalendarMonth}\n onSelect={(d) => handleDateSelect(d)}\n captionLayout={captionLayout}\n startMonth={new Date(1900, 0)}\n endMonth={new Date(2100, 11)}\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\n className=\"rdp-custom\"\n />\n </div>\n )}\n\n {needsTimePicker && (\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\n <Clock className=\"w-3.5 h-3.5\" />\n <span>\n {timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second'}\n </span>\n </div>\n <TimePicker\n parts={timeParts}\n onChange={handlePartsChange}\n timeFormat={timeFormat}\n timePickerStyle={timePickerStyle}\n />\n </div>\n )}\n\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\n <button\n type=\"button\"\n onClick={() => {\n if (mode === 'time-only') {\n onTimeChange?.('');\n } else {\n if (!isControlled) setInternalDate(undefined);\n onChange?.(undefined);\n }\n }}\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\n >\n Clear\n </button>\n <Button size=\"sm\" onClick={() => setOpen(false)}>\n Confirm\n </Button>\n </div>\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n </BasePopover.Root>\n {description && !error && (\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n </div>\n );\n});\n\nDatePicker.displayName = \"DatePicker\";\n"
309
326
  },
310
327
  {
311
328
  "path": "src/components/ui/datepicker/index.ts",
@@ -324,7 +341,27 @@
324
341
  "files": [
325
342
  {
326
343
  "path": "src/components/ui/dialog/Dialog.tsx",
327
- "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 = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ComponentPropsWithoutRef<typeof BaseDialog.Close>\r\n>(({ children, render: renderProp, ...props }, ref) => {\r\n const isElement = React.isValidElement(children);\r\n return (\r\n <BaseDialog.Close\r\n ref={ref}\r\n render={renderProp ?? (isElement ? (children as React.ReactElement) : undefined)}\r\n {...props}\r\n >\r\n {isElement ? undefined : children}\r\n </BaseDialog.Close>\r\n );\r\n});\r\nDialogClose.displayName = 'DialogClose';\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"
344
+ "content": "import * as React from 'react';\nimport { Dialog as BaseDialog } from '@base-ui/react';\nimport { X } from 'lucide-react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst dialogVariants = 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 p-6',\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 ',\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n ].join(' '),\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\n title: 'text-lg font-semibold leading-none tracking-tight',\n description: 'text-sm text-muted-foreground',\n close:\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',\n },\n variants: {\n size: {\n default: {\n content: 'max-w-lg sm:rounded-lg',\n },\n fullScreen: {\n content:\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\n },\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\n/* ─── Root ─── */\nconst Dialog = BaseDialog.Root;\n\n/* ─── Trigger ─── */\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\n// để tránh nested button (<button><button>…</button></button>).\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseDialog.Trigger>;\n\ninterface DialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\n render?: BaseTriggerProps['render'];\n children?: React.ReactNode;\n}\n\nconst DialogTrigger = React.forwardRef<HTMLElement, DialogTriggerProps>(\n ({ render: renderProp, children, ...props }, ref) => {\n const resolvedRender =\n renderProp ?? (React.isValidElement(children) ? children : undefined);\n\n return (\n <BaseDialog.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n render={resolvedRender}\n {...props}\n >\n {resolvedRender ? undefined : children}\n </BaseDialog.Trigger>\n );\n },\n);\nDialogTrigger.displayName = 'DialogTrigger';\n\n\n/* ─── Close (re-export for custom close buttons) ─── */\nconst DialogClose = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BaseDialog.Close>\n>(({ children, render: renderProp, ...props }, ref) => {\n const isElement = React.isValidElement(children);\n return (\n <BaseDialog.Close\n ref={ref}\n render={renderProp ?? (isElement ? (children as React.ReactElement) : undefined)}\n {...props}\n >\n {isElement ? undefined : children}\n </BaseDialog.Close>\n );\n});\nDialogClose.displayName = 'DialogClose';\n\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\ninterface DialogContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\n VariantProps<typeof dialogVariants> {\n className?: string;\n}\n\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\n ({ className, children, size, ...props }, ref) => {\n const slots = dialogVariants({ size });\n return (\n <BaseDialog.Portal>\n <BaseDialog.Backdrop className={slots.overlay()} />\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\n {children}\n <BaseDialog.Close className={slots.close()}>\n <X className=\"h-4 w-4\" />\n <span className=\"sr-only\">Close</span>\n </BaseDialog.Close>\n </BaseDialog.Popup>\n </BaseDialog.Portal>\n );\n },\n);\nDialogContent.displayName = 'DialogContent';\n\n/* ─── Header ─── */\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const slots = dialogVariants();\n return <div ref={ref} className={slots.header({ className })} {...props} />;\n },\n);\nDialogHeader.displayName = 'DialogHeader';\n\n/* ─── Footer ─── */\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const slots = dialogVariants();\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\n },\n);\nDialogFooter.displayName = 'DialogFooter';\n\n/* ─── Title ─── */\nconst DialogTitle = React.forwardRef<\n HTMLHeadingElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\n>(({ className, ...props }, ref) => {\n const slots = dialogVariants();\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\n});\nDialogTitle.displayName = 'DialogTitle';\n\n/* ─── Description ─── */\nconst DialogDescription = React.forwardRef<\n HTMLParagraphElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\n>(({ className, ...props }, ref) => {\n const slots = dialogVariants();\n return (\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\n );\n});\nDialogDescription.displayName = 'DialogDescription';\n\nexport {\n Dialog,\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogFooter,\n DialogTitle,\n DialogDescription,\n DialogClose,\n};\n"
345
+ }
346
+ ]
347
+ },
348
+ "dock": {
349
+ "name": "dock",
350
+ "dependencies": [
351
+ "motion",
352
+ "tailwind-variants"
353
+ ],
354
+ "internalDependencies": [
355
+ "tooltip"
356
+ ],
357
+ "files": [
358
+ {
359
+ "path": "src/components/ui/dock/Dock.tsx",
360
+ "content": "'use client';\n\nimport * as React from 'react';\nimport {\n motion,\n useMotionValue,\n useSpring,\n useTransform,\n type HTMLMotionProps,\n type MotionValue,\n} from 'motion/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/ui/tooltip/Tooltip';\n\n// ─── Defaults (match Magic UI) ───────────────────────────────────────────────\n\nconst DEFAULT_SIZE = 40;\nconst DEFAULT_MAGNIFICATION = 60;\nconst DEFAULT_DISTANCE = 140;\nconst SPRING = { mass: 0.1, stiffness: 150, damping: 12 } as const;\n\ntype Orientation = 'horizontal' | 'vertical';\n\n// ─── Styling ─────────────────────────────────────────────────────────────────\n\nconst dockVariants = tv({\n base: 'flex w-max gap-3 rounded-2xl border border-border/60 bg-background/70 p-3 backdrop-blur-md',\n variants: {\n orientation: {\n horizontal: 'flex-row items-end',\n vertical: 'flex-col items-center',\n },\n },\n defaultVariants: { orientation: 'horizontal' },\n});\n\nconst dockIconVariants = tv({\n base: 'flex aspect-square cursor-pointer items-center justify-center rounded-full bg-muted/60 text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',\n});\n\n// ─── Context ─────────────────────────────────────────────────────────────────\n\ninterface DockContextValue {\n /** Pointer position along the active axis (clientX or clientY); Infinity when idle */\n pointer: MotionValue<number>;\n orientation: Orientation;\n iconSize: number;\n iconMagnification: number;\n iconDistance: number;\n}\n\nconst DockContext = React.createContext<DockContextValue | null>(null);\n\nconst useDockContext = (): DockContextValue => {\n const ctx = React.useContext(DockContext);\n if (!ctx) throw new Error('Dock sub-components must be used within <Dock>');\n return ctx;\n};\n\n// ─── Prop-based item ─────────────────────────────────────────────────────────\n\nexport interface DockItem {\n icon: React.ReactNode;\n label?: string;\n onClick?: React.MouseEventHandler<HTMLButtonElement>;\n className?: string;\n}\n\n// ─── Dock ────────────────────────────────────────────────────────────────────\n\nexport interface DockProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Layout axis (default: 'horizontal') */\n orientation?: Orientation;\n /** Resting icon size in px (default: 40) */\n iconSize?: number;\n /** Peak magnified icon size in px (default: 60) */\n iconMagnification?: number;\n /** Cursor distance over which magnification falls off, in px (default: 140) */\n iconDistance?: number;\n /** Prop-based convenience API. When provided, renders instead of `children`. */\n items?: DockItem[];\n}\n\nconst Dock = React.forwardRef<HTMLDivElement, DockProps>(\n (\n {\n orientation = 'horizontal',\n iconSize = DEFAULT_SIZE,\n iconMagnification = DEFAULT_MAGNIFICATION,\n iconDistance = DEFAULT_DISTANCE,\n items,\n className,\n children,\n onMouseMove,\n onMouseLeave,\n ...props\n },\n ref\n ) => {\n const pointer = useMotionValue(Infinity);\n\n const ctx = React.useMemo<DockContextValue>(\n () => ({ pointer, orientation, iconSize, iconMagnification, iconDistance }),\n [pointer, orientation, iconSize, iconMagnification, iconDistance]\n );\n\n return (\n <DockContext.Provider value={ctx}>\n <TooltipProvider>\n <div\n ref={ref}\n role=\"toolbar\"\n aria-orientation={orientation}\n className={cn(dockVariants({ orientation }), className)}\n onMouseMove={(e) => {\n pointer.set(orientation === 'horizontal' ? e.clientX : e.clientY);\n onMouseMove?.(e);\n }}\n onMouseLeave={(e) => {\n pointer.set(Infinity);\n onMouseLeave?.(e);\n }}\n {...props}\n >\n {items\n ? items.map((item, i) => (\n <DockIcon\n key={i}\n label={item.label}\n onClick={item.onClick}\n className={item.className}\n >\n {item.icon}\n </DockIcon>\n ))\n : children}\n </div>\n </TooltipProvider>\n </DockContext.Provider>\n );\n }\n);\nDock.displayName = 'Dock';\n\n// ─── DockIcon ────────────────────────────────────────────────────────────────\n\nexport interface DockIconProps\n extends Omit<HTMLMotionProps<'button'>, 'style' | 'ref'> {\n /** Accessible label; also shown as a tooltip on hover */\n label?: string;\n}\n\nconst DockIcon = React.forwardRef<HTMLButtonElement, DockIconProps>(\n ({ label, className, children, 'aria-label': ariaLabel, ...props }, forwardedRef) => {\n const { pointer, orientation, iconSize, iconMagnification, iconDistance } =\n useDockContext();\n const internalRef = React.useRef<HTMLButtonElement>(null);\n\n const setRefs = React.useCallback(\n (node: HTMLButtonElement | null) => {\n internalRef.current = node;\n if (typeof forwardedRef === 'function') forwardedRef(node);\n else if (forwardedRef) forwardedRef.current = node;\n },\n [forwardedRef]\n );\n\n // Distance from the cursor to this icon's center along the active axis.\n const distance = useTransform(pointer, (val) => {\n const bounds = internalRef.current?.getBoundingClientRect() ?? {\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n };\n const center =\n orientation === 'horizontal'\n ? bounds.x + bounds.width / 2\n : bounds.y + bounds.height / 2;\n return val - center;\n });\n\n const sizeTarget = useTransform(\n distance,\n [-iconDistance, 0, iconDistance],\n [iconSize, iconMagnification, iconSize]\n );\n const size = useSpring(sizeTarget, SPRING);\n\n const button = (\n <motion.button\n ref={setRefs}\n type=\"button\"\n style={{ width: size, height: size }}\n className={cn(dockIconVariants(), className)}\n aria-label={ariaLabel ?? label}\n {...props}\n >\n {children}\n </motion.button>\n );\n\n if (!label) return button;\n\n return (\n <Tooltip>\n <TooltipTrigger render={button} />\n <TooltipContent side={orientation === 'horizontal' ? 'top' : 'right'}>\n {label}\n </TooltipContent>\n </Tooltip>\n );\n }\n);\nDockIcon.displayName = 'DockIcon';\n\n// ─── DockSeparator ───────────────────────────────────────────────────────────\n\nexport type DockSeparatorProps = React.HTMLAttributes<HTMLDivElement>;\n\nconst DockSeparator = React.forwardRef<HTMLDivElement, DockSeparatorProps>(\n ({ className, ...props }, ref) => {\n const { orientation } = useDockContext();\n return (\n <div\n ref={ref}\n role=\"separator\"\n aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}\n className={cn(\n 'shrink-0 self-center bg-border/70',\n orientation === 'horizontal' ? 'mx-1 h-8 w-px' : 'my-1 h-px w-8',\n className\n )}\n {...props}\n />\n );\n }\n);\nDockSeparator.displayName = 'DockSeparator';\n\nexport { Dock, DockIcon, DockSeparator };\n"
361
+ },
362
+ {
363
+ "path": "src/components/ui/dock/index.ts",
364
+ "content": "export * from './Dock';\n"
328
365
  }
329
366
  ]
330
367
  },
@@ -339,7 +376,7 @@
339
376
  "files": [
340
377
  {
341
378
  "path": "src/components/ui/drawer/Drawer.tsx",
342
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay: [\r\n 'fixed inset-0 z-50 bg-black/40',\r\n 'transition-opacity duration-200 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n 'transition duration-300 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel: [\r\n 'inset-y-0 left-0 h-full',\r\n 'data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full',\r\n ],\r\n },\r\n right: {\r\n panel: [\r\n 'inset-y-0 right-0 h-full',\r\n 'data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full',\r\n ],\r\n },\r\n top: {\r\n panel: [\r\n 'inset-x-0 top-0 w-full',\r\n 'data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full',\r\n ],\r\n },\r\n bottom: {\r\n panel: [\r\n 'inset-x-0 bottom-0 w-full',\r\n 'data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full',\r\n ],\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'top',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n keepMounted?: boolean;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, keepMounted, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal keepMounted={keepMounted}>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
379
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Dialog as BaseDialog } from '@base-ui/react';\nimport { X } from 'lucide-react';\n\nconst drawerVariants = tv({\n slots: {\n overlay: [\n 'fixed inset-0 z-50 bg-black/40',\n 'transition-opacity duration-200 ease-out',\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\n ],\n panel: [\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\n 'transition duration-300 ease-out',\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\n ],\n header:\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\n title: 'text-base font-semibold text-foreground',\n description: 'text-sm text-muted-foreground mt-0.5',\n body: 'flex-1 overflow-y-auto px-6 py-4',\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\n close:\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\n },\n variants: {\n direction: {\n left: {\n panel: [\n 'inset-y-0 left-0 h-full',\n 'data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full',\n ],\n },\n right: {\n panel: [\n 'inset-y-0 right-0 h-full',\n 'data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full',\n ],\n },\n top: {\n panel: [\n 'inset-x-0 top-0 w-full',\n 'data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full',\n ],\n },\n bottom: {\n panel: [\n 'inset-x-0 bottom-0 w-full',\n 'data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full',\n ],\n },\n },\n size: {\n sm: {},\n md: {},\n lg: {},\n full: {},\n },\n backdropBlur: {\n true: { overlay: 'backdrop-blur-sm' },\n false: { overlay: '' },\n },\n },\n compoundVariants: [\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\n ],\n defaultVariants: {\n direction: 'top',\n size: 'md',\n backdropBlur: true,\n },\n});\n\n/* ─── Root ─── */\nconst Drawer = BaseDialog.Root;\n\n/* ─── Trigger ─── */\nconst DrawerTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BaseDialog.Trigger>\n>(({ children, render: renderProp, ...props }, ref) => {\n const isElement = React.isValidElement(children);\n return (\n <BaseDialog.Trigger\n ref={ref}\n render={renderProp ?? (isElement ? (children as React.ReactElement) : undefined)}\n {...props}\n >\n {isElement ? undefined : children}\n </BaseDialog.Trigger>\n );\n});\nDrawerTrigger.displayName = 'DrawerTrigger';\n\n/* ─── Close (re-export for custom close buttons) ─── */\nconst DrawerClose = BaseDialog.Close;\n\n/* ─── Content (Portal + Backdrop + Popup) ─── */\ninterface DrawerContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\n VariantProps<typeof drawerVariants> {\n className?: string;\n keepMounted?: boolean;\n}\n\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\n ({ className, children, direction, size, backdropBlur, keepMounted, ...props }, ref) => {\n const slots = drawerVariants({ direction, size, backdropBlur });\n return (\n <BaseDialog.Portal keepMounted={keepMounted}>\n <BaseDialog.Backdrop className={slots.overlay()} />\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\n {children}\n </BaseDialog.Popup>\n </BaseDialog.Portal>\n );\n },\n);\nDrawerContent.displayName = 'DrawerContent';\n\n/* ─── Header (includes close button by default) ─── */\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\n hideClose?: boolean;\n}\n\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\n ({ className, children, hideClose, ...props }, ref) => {\n const slots = drawerVariants();\n return (\n <div ref={ref} className={slots.header({ className })} {...props}>\n <div>{children}</div>\n {!hideClose && (\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\n <X className=\"h-4 w-4\" />\n </BaseDialog.Close>\n )}\n </div>\n );\n },\n);\nDrawerHeader.displayName = 'DrawerHeader';\n\n/* ─── Title ─── */\nconst DrawerTitle = React.forwardRef<\n HTMLHeadingElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\n className?: string;\n }\n>(({ className, ...props }, ref) => {\n const slots = drawerVariants();\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\n});\nDrawerTitle.displayName = 'DrawerTitle';\n\n/* ─── Description ─── */\nconst DrawerDescription = React.forwardRef<\n HTMLParagraphElement,\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\n className?: string;\n }\n>(({ className, ...props }, ref) => {\n const slots = drawerVariants();\n return (\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\n );\n});\nDrawerDescription.displayName = 'DrawerDescription';\n\n/* ─── Body (scrollable content area) ─── */\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const slots = drawerVariants();\n return <div ref={ref} className={slots.body({ className })} {...props} />;\n },\n);\nDrawerBody.displayName = 'DrawerBody';\n\n/* ─── Footer ─── */\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const slots = drawerVariants();\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\n },\n);\nDrawerFooter.displayName = 'DrawerFooter';\n\nexport {\n Drawer,\n DrawerTrigger,\n DrawerContent,\n DrawerHeader,\n DrawerTitle,\n DrawerDescription,\n DrawerBody,\n DrawerFooter,\n DrawerClose,\n};\nexport type { DrawerContentProps, DrawerHeaderProps };\n"
343
380
  }
344
381
  ]
345
382
  },
@@ -354,7 +391,7 @@
354
391
  "files": [
355
392
  {
356
393
  "path": "src/components/ui/dropdown-menu/DropdownMenu.tsx",
357
- "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, ChevronRight, Circle } from 'lucide-react';\r\n\r\nconst dropdownMenuVariants = tv({\r\n slots: {\r\n content:\r\n '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 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 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 subTrigger:\r\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-open:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg 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 indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = dropdownMenuVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenu = BaseMenu.Root;\r\n\r\n/* ─── Trigger ───────────────────────────────────────────────────────── */\r\n\r\nexport type DropdownMenuTriggerProps = React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>;\r\n\r\nconst DropdownMenuTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n DropdownMenuTriggerProps\r\n>(({ ...props }, ref) => <BaseMenu.Trigger ref={ref as React.Ref<HTMLButtonElement>} {...props} />);\r\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuContent component */\r\nexport interface DropdownMenuContentProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n /** Which side of the trigger to render the menu */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger */\r\n align?: 'start' | 'center' | 'end';\r\n /** Distance in px between the trigger and the menu */\r\n sideOffset?: number;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuContent.displayName = 'DropdownMenuContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuItem component */\r\nexport interface DropdownMenuItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Item>,\r\n DropdownMenuItemProps\r\n>(({ className, inset, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n />\r\n));\r\nDropdownMenuItem.displayName = 'DropdownMenuItem';\r\n\r\n/* ─── CheckboxItem ──────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuCheckboxItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuCheckboxItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.CheckboxItem>,\r\n DropdownMenuCheckboxItemProps\r\n>(({ className, children, checked, ...props }, ref) => (\r\n <BaseMenu.CheckboxItem\r\n ref={ref}\r\n className={styles.checkboxItem({ className })}\r\n checked={checked}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.CheckboxItemIndicator>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseMenu.CheckboxItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.CheckboxItem>\r\n));\r\nDropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';\r\n\r\n/* ─── RadioGroup ────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuRadioGroup = BaseMenu.RadioGroup;\r\n\r\n/* ─── RadioItem ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuRadioItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuRadioItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.RadioItem>,\r\n DropdownMenuRadioItemProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.RadioItem ref={ref} className={styles.radioItem({ className })} {...props}>\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.RadioItemIndicator>\r\n <Circle className=\"h-2 w-2 fill-current\" />\r\n </BaseMenu.RadioItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.RadioItem>\r\n));\r\nDropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';\r\n\r\n/* ─── Label ─────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuLabel component */\r\nexport interface DropdownMenuLabelProps extends React.ComponentPropsWithoutRef<'div'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n}\r\n\r\nconst DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(\r\n ({ className, inset, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })} {...props} />\r\n )\r\n);\r\nDropdownMenuLabel.displayName = 'DropdownMenuLabel';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport type DropdownMenuSeparatorProps = React.ComponentPropsWithoutRef<'div'>;\r\n\r\nconst DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\r\n\r\n/* ─── Shortcut ──────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\r\n\r\n/* ─── Sub ───────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuSub = BaseMenu.SubmenuRoot;\r\n\r\n/** Props for the DropdownMenuSubTrigger component */\r\nexport interface DropdownMenuSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n DropdownMenuSubTriggerProps\r\n>(({ className, inset, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger\r\n ref={ref}\r\n className={styles.subTrigger({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nDropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';\r\n\r\nexport interface DropdownMenuSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuSubContent.displayName = 'DropdownMenuSubContent';\r\n\r\n/* ─── Group ─────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuGroup = BaseMenu.Group;\r\n\r\nexport {\r\n DropdownMenu,\r\n DropdownMenuTrigger,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuCheckboxItem,\r\n DropdownMenuRadioGroup,\r\n DropdownMenuRadioItem,\r\n DropdownMenuLabel,\r\n DropdownMenuSeparator,\r\n DropdownMenuShortcut,\r\n DropdownMenuSub,\r\n DropdownMenuSubTrigger,\r\n DropdownMenuSubContent,\r\n DropdownMenuGroup,\r\n};\r\n"
394
+ "content": "import * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\n\nconst dropdownMenuVariants = tv({\n slots: {\n content:\n '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 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 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 subTrigger:\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-open:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg 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 indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n },\n});\n\nconst styles = dropdownMenuVariants();\n\n/* ─── Root ──────────────────────────────────────────────────────────── */\n\nconst DropdownMenu = BaseMenu.Root;\n\n/* ─── Trigger ───────────────────────────────────────────────────────── */\n\nexport type DropdownMenuTriggerProps = React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>;\n\nconst DropdownMenuTrigger = React.forwardRef<\n HTMLButtonElement,\n DropdownMenuTriggerProps\n>(({ ...props }, ref) => <BaseMenu.Trigger ref={ref as React.Ref<HTMLButtonElement>} {...props} />);\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\n\n/* ─── Content ───────────────────────────────────────────────────────── */\n\n/** Props for the DropdownMenuContent component */\nexport interface DropdownMenuContentProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n /** Which side of the trigger to render the menu */\n side?: 'top' | 'right' | 'bottom' | 'left';\n /** Alignment relative to the trigger */\n align?: 'start' | 'center' | 'end';\n /** Distance in px between the trigger and the menu */\n sideOffset?: number;\n className?: string;\n}\n\nconst DropdownMenuContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n DropdownMenuContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nDropdownMenuContent.displayName = 'DropdownMenuContent';\n\n/* ─── Item ──────────────────────────────────────────────────────────── */\n\n/** Props for the DropdownMenuItem component */\nexport interface DropdownMenuItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n /** Add left padding to align with items that have icons */\n inset?: boolean;\n className?: string;\n}\n\nconst DropdownMenuItem = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Item>,\n DropdownMenuItemProps\n>(({ className, inset, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\n {...props}\n />\n));\nDropdownMenuItem.displayName = 'DropdownMenuItem';\n\n/* ─── CheckboxItem ──────────────────────────────────────────────────── */\n\nexport interface DropdownMenuCheckboxItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>, 'className'> {\n className?: string;\n}\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.CheckboxItem>,\n DropdownMenuCheckboxItemProps\n>(({ className, children, checked, ...props }, ref) => (\n <BaseMenu.CheckboxItem\n ref={ref}\n className={styles.checkboxItem({ className })}\n checked={checked}\n {...props}\n >\n <span className={styles.indicatorWrapper()}>\n <BaseMenu.CheckboxItemIndicator>\n <Check className=\"h-4 w-4\" />\n </BaseMenu.CheckboxItemIndicator>\n </span>\n {children}\n </BaseMenu.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';\n\n/* ─── RadioGroup ────────────────────────────────────────────────────── */\n\nconst DropdownMenuRadioGroup = BaseMenu.RadioGroup;\n\n/* ─── RadioItem ─────────────────────────────────────────────────────── */\n\nexport interface DropdownMenuRadioItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>, 'className'> {\n className?: string;\n}\n\nconst DropdownMenuRadioItem = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.RadioItem>,\n DropdownMenuRadioItemProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.RadioItem ref={ref} className={styles.radioItem({ className })} {...props}>\n <span className={styles.indicatorWrapper()}>\n <BaseMenu.RadioItemIndicator>\n <Circle className=\"h-2 w-2 fill-current\" />\n </BaseMenu.RadioItemIndicator>\n </span>\n {children}\n </BaseMenu.RadioItem>\n));\nDropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';\n\n/* ─── Label ─────────────────────────────────────────────────────────── */\n\n/** Props for the DropdownMenuLabel component */\nexport interface DropdownMenuLabelProps extends React.ComponentPropsWithoutRef<'div'> {\n /** Add left padding to align with items that have icons */\n inset?: boolean;\n}\n\nconst DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(\n ({ className, inset, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })} {...props} />\n )\n);\nDropdownMenuLabel.displayName = 'DropdownMenuLabel';\n\n/* ─── Separator ─────────────────────────────────────────────────────── */\n\nexport type DropdownMenuSeparatorProps = React.ComponentPropsWithoutRef<'div'>;\n\nconst DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\n\n/* ─── Shortcut ──────────────────────────────────────────────────────── */\n\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n\n/* ─── Sub ───────────────────────────────────────────────────────────── */\n\nconst DropdownMenuSub = BaseMenu.SubmenuRoot;\n\n/** Props for the DropdownMenuSubTrigger component */\nexport interface DropdownMenuSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n /** Add left padding to align with items that have icons */\n inset?: boolean;\n className?: string;\n}\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n DropdownMenuSubTriggerProps\n>(({ className, inset, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger\n ref={ref}\n className={styles.subTrigger({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\n {...props}\n >\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nDropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';\n\nexport interface DropdownMenuSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst DropdownMenuSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n DropdownMenuSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nDropdownMenuSubContent.displayName = 'DropdownMenuSubContent';\n\n/* ─── Group ─────────────────────────────────────────────────────────── */\n\nconst DropdownMenuGroup = BaseMenu.Group;\n\nexport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuCheckboxItem,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuSub,\n DropdownMenuSubTrigger,\n DropdownMenuSubContent,\n DropdownMenuGroup,\n};\n"
358
395
  }
359
396
  ]
360
397
  },
@@ -367,7 +404,7 @@
367
404
  "files": [
368
405
  {
369
406
  "path": "src/components/ui/empty/Empty.tsx",
370
- "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"
407
+ "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"
371
408
  }
372
409
  ]
373
410
  },
@@ -381,7 +418,7 @@
381
418
  "files": [
382
419
  {
383
420
  "path": "src/components/ui/file-upload/FileUpload.tsx",
384
- "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, Pencil } 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 fillPreview: [\r\n 'relative overflow-hidden rounded-xl border border-border group',\r\n 'cursor-pointer transition-all duration-200',\r\n ].join(' '),\r\n fillImage: 'w-full h-full object-cover',\r\n fillOverlay: [\r\n 'absolute inset-0 flex flex-col items-center justify-center gap-1',\r\n 'bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity',\r\n ].join(' '),\r\n fillRemoveBtn: [\r\n 'absolute top-2 right-2 z-10 p-1.5 rounded-md bg-black/60 text-white',\r\n 'hover:bg-danger transition-colors opacity-0 group-hover:opacity-100',\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', fillPreview: 'h-32 text-xs' },\r\n md: { dropzone: 'px-6 py-10 text-sm', fillPreview: 'h-48 text-sm' },\r\n lg: { dropzone: 'px-8 py-14 text-base', fillPreview: 'h-64 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 /** Hiển thị preview thumbnail cho file ảnh */\r\n showPreview?: boolean;\r\n /** Kiểu preview: 'thumbnail' hiển thị trong danh sách, 'fill' lấp đầy khung dropzone (chỉ dùng cho single image) */\r\n previewVariant?: 'thumbnail' | 'fill';\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 showPreview = false,\r\n previewVariant = 'thumbnail',\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 [isLightboxOpen, setIsLightboxOpen] = React.useState(false);\r\n const inputRef = React.useRef<HTMLInputElement>(null);\r\n const rootId = React.useId();\r\n\r\n React.useEffect(() => {\r\n if (!isLightboxOpen) return;\r\n const onKey = (e: KeyboardEvent) => {\r\n if (e.key === 'Escape') setIsLightboxOpen(false);\r\n };\r\n document.addEventListener('keydown', onKey);\r\n const prevOverflow = document.body.style.overflow;\r\n document.body.style.overflow = 'hidden';\r\n return () => {\r\n document.removeEventListener('keydown', onKey);\r\n document.body.style.overflow = prevOverflow;\r\n };\r\n }, [isLightboxOpen]);\r\n\r\n const previews = React.useMemo(() => {\r\n if (!showPreview) return new Map<File, string>();\r\n const map = new Map<File, string>();\r\n for (const file of value) {\r\n if (file.type.startsWith('image/')) {\r\n map.set(file, URL.createObjectURL(file));\r\n }\r\n }\r\n return map;\r\n }, [value, showPreview]);\r\n\r\n React.useEffect(() => {\r\n return () => {\r\n previews.forEach((url) => URL.revokeObjectURL(url));\r\n };\r\n }, [previews]);\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 const isFillMode = showPreview && previewVariant === 'fill';\r\n const fillFile = isFillMode ? value.find((f) => f.type.startsWith('image/')) : undefined;\r\n const fillUrl = fillFile ? previews.get(fillFile) : undefined;\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 {isFillMode && fillUrl ? (\r\n <div\r\n className={styles.fillPreview()}\r\n onClick={() => !disabled && setIsLightboxOpen(true)}\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 <img src={fillUrl} alt={fillFile?.name} className={styles.fillImage()} />\r\n <div className={styles.fillOverlay()}>\r\n <ImageIcon className=\"h-6 w-6\" />\r\n <p className=\"text-sm font-medium\">Nhấn để xem ảnh</p>\r\n </div>\r\n <div className=\"absolute top-2 right-2 z-10 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity\">\r\n <button\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n if (!disabled) inputRef.current?.click();\r\n }}\r\n className=\"p-1.5 rounded-md bg-black/60 text-white hover:bg-primary transition-colors\"\r\n aria-label=\"Change image\"\r\n >\r\n <Pencil className=\"h-4 w-4\" />\r\n </button>\r\n <button\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n if (fillFile) removeFile(value.indexOf(fillFile));\r\n }}\r\n className=\"p-1.5 rounded-md bg-black/60 text-white hover:bg-danger transition-colors\"\r\n aria-label={`Remove ${fillFile?.name}`}\r\n >\r\n <X className=\"h-4 w-4\" />\r\n </button>\r\n </div>\r\n </div>\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\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n\r\n {isFillMode && fillUrl && isLightboxOpen && (\r\n <div\r\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 animate-in fade-in\"\r\n onClick={() => setIsLightboxOpen(false)}\r\n role=\"dialog\"\r\n aria-modal=\"true\"\r\n aria-label={fillFile?.name}\r\n >\r\n <button\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.stopPropagation();\r\n setIsLightboxOpen(false);\r\n }}\r\n className=\"absolute top-4 right-4 p-2 rounded-md bg-white/10 text-white hover:bg-white/20 transition-colors\"\r\n aria-label=\"Close\"\r\n >\r\n <X className=\"h-5 w-5\" />\r\n </button>\r\n <img\r\n src={fillUrl}\r\n alt={fillFile?.name}\r\n className=\"max-h-[90vh] max-w-[90vw] object-contain rounded-lg shadow-2xl\"\r\n onClick={(e) => e.stopPropagation()}\r\n />\r\n </div>\r\n )}\r\n\r\n {!isFillMode && value.length > 0 && (\r\n <div className={styles.fileList()}>\r\n {value.map((file, i) => {\r\n const Icon = getFileIcon(file.type);\r\n const previewUrl = previews.get(file);\r\n return (\r\n <div key={`${file.name}-${i}`} className={styles.fileItem()}>\r\n {previewUrl ? (\r\n <img\r\n src={previewUrl}\r\n alt={file.name}\r\n className=\"h-10 w-10 shrink-0 rounded-lg object-cover border border-border\"\r\n />\r\n ) : (\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 )}\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 };\r\n"
421
+ "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, Pencil } 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 fillPreview: [\n 'relative overflow-hidden rounded-xl border border-border group',\n 'cursor-pointer transition-all duration-200',\n ].join(' '),\n fillImage: 'w-full h-full object-cover',\n fillOverlay: [\n 'absolute inset-0 flex flex-col items-center justify-center gap-1',\n 'bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity',\n ].join(' '),\n fillRemoveBtn: [\n 'absolute top-2 right-2 z-10 p-1.5 rounded-md bg-black/60 text-white',\n 'hover:bg-danger transition-colors opacity-0 group-hover:opacity-100',\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', fillPreview: 'h-32 text-xs' },\n md: { dropzone: 'px-6 py-10 text-sm', fillPreview: 'h-48 text-sm' },\n lg: { dropzone: 'px-8 py-14 text-base', fillPreview: 'h-64 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 /** Hiển thị preview thumbnail cho file ảnh */\n showPreview?: boolean;\n /** Kiểu preview: 'thumbnail' hiển thị trong danh sách, 'fill' lấp đầy khung dropzone (chỉ dùng cho single image) */\n previewVariant?: 'thumbnail' | 'fill';\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 showPreview = false,\n previewVariant = 'thumbnail',\n size = 'md',\n className,\n children,\n },\n ref,\n ) => {\n const [isDragActive, setIsDragActive] = React.useState(false);\n const [isLightboxOpen, setIsLightboxOpen] = React.useState(false);\n const inputRef = React.useRef<HTMLInputElement>(null);\n const rootId = React.useId();\n\n React.useEffect(() => {\n if (!isLightboxOpen) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setIsLightboxOpen(false);\n };\n document.addEventListener('keydown', onKey);\n const prevOverflow = document.body.style.overflow;\n document.body.style.overflow = 'hidden';\n return () => {\n document.removeEventListener('keydown', onKey);\n document.body.style.overflow = prevOverflow;\n };\n }, [isLightboxOpen]);\n\n const previews = React.useMemo(() => {\n if (!showPreview) return new Map<File, string>();\n const map = new Map<File, string>();\n for (const file of value) {\n if (file.type.startsWith('image/')) {\n map.set(file, URL.createObjectURL(file));\n }\n }\n return map;\n }, [value, showPreview]);\n\n React.useEffect(() => {\n return () => {\n previews.forEach((url) => URL.revokeObjectURL(url));\n };\n }, [previews]);\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 const isFillMode = showPreview && previewVariant === 'fill';\n const fillFile = isFillMode ? value.find((f) => f.type.startsWith('image/')) : undefined;\n const fillUrl = fillFile ? previews.get(fillFile) : undefined;\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 {isFillMode && fillUrl ? (\n <div\n className={styles.fillPreview()}\n onClick={() => !disabled && setIsLightboxOpen(true)}\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 <img src={fillUrl} alt={fillFile?.name} className={styles.fillImage()} />\n <div className={styles.fillOverlay()}>\n <ImageIcon className=\"h-6 w-6\" />\n <p className=\"text-sm font-medium\">Nhấn để xem ảnh</p>\n </div>\n <div className=\"absolute top-2 right-2 z-10 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity\">\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n if (!disabled) inputRef.current?.click();\n }}\n className=\"p-1.5 rounded-md bg-black/60 text-white hover:bg-primary transition-colors\"\n aria-label=\"Change image\"\n >\n <Pencil className=\"h-4 w-4\" />\n </button>\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n if (fillFile) removeFile(value.indexOf(fillFile));\n }}\n className=\"p-1.5 rounded-md bg-black/60 text-white hover:bg-danger transition-colors\"\n aria-label={`Remove ${fillFile?.name}`}\n >\n <X className=\"h-4 w-4\" />\n </button>\n </div>\n </div>\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\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n\n {isFillMode && fillUrl && isLightboxOpen && (\n <div\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 animate-in fade-in\"\n onClick={() => setIsLightboxOpen(false)}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={fillFile?.name}\n >\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n setIsLightboxOpen(false);\n }}\n className=\"absolute top-4 right-4 p-2 rounded-md bg-white/10 text-white hover:bg-white/20 transition-colors\"\n aria-label=\"Close\"\n >\n <X className=\"h-5 w-5\" />\n </button>\n <img\n src={fillUrl}\n alt={fillFile?.name}\n className=\"max-h-[90vh] max-w-[90vw] object-contain rounded-lg shadow-2xl\"\n onClick={(e) => e.stopPropagation()}\n />\n </div>\n )}\n\n {!isFillMode && value.length > 0 && (\n <div className={styles.fileList()}>\n {value.map((file, i) => {\n const Icon = getFileIcon(file.type);\n const previewUrl = previews.get(file);\n return (\n <div key={`${file.name}-${i}`} className={styles.fileItem()}>\n {previewUrl ? (\n <img\n src={previewUrl}\n alt={file.name}\n className=\"h-10 w-10 shrink-0 rounded-lg object-cover border border-border\"\n />\n ) : (\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 )}\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 };\n"
385
422
  }
386
423
  ]
387
424
  },
@@ -394,7 +431,7 @@
394
431
  "files": [
395
432
  {
396
433
  "path": "src/components/ui/form/Form.tsx",
397
- "content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport {\r\n Controller,\r\n FormProvider,\r\n useFormContext,\r\n} from 'react-hook-form';\r\nimport type {\r\n ControllerProps,\r\n FieldPath,\r\n FieldValues,\r\n} from 'react-hook-form';\r\n\r\nconst Form = FormProvider;\r\n\r\ntype FormFieldContextValue<\r\n TFieldValues extends FieldValues = FieldValues,\r\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\r\n> = {\r\n name: TName;\r\n};\r\n\r\nconst FormFieldContext = React.createContext<FormFieldContextValue | null>(null);\r\n\r\n/** Connects a form field to react-hook-form via Controller and provides field context */\r\nconst FormField = <\r\n TFieldValues extends FieldValues = FieldValues,\r\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\r\n>({\r\n ...props\r\n}: ControllerProps<TFieldValues, TName>) => {\r\n return (\r\n <FormFieldContext.Provider value={{ name: props.name }}>\r\n <Controller {...props} />\r\n </FormFieldContext.Provider>\r\n );\r\n};\r\n\r\nconst useFormField = () => {\r\n const fieldContext = React.useContext(FormFieldContext);\r\n const itemContext = React.useContext(FormItemContext);\r\n const { getFieldState, formState } = useFormContext();\r\n\r\n if (!fieldContext) {\r\n throw new Error('useFormField must be used within <FormField>');\r\n }\r\n\r\n const fieldState = getFieldState(fieldContext.name, formState);\r\n\r\n if (!itemContext) {\r\n throw new Error('useFormField should be used within <FormItem>');\r\n }\r\n\r\n const { id } = itemContext;\r\n\r\n return {\r\n id,\r\n name: fieldContext.name,\r\n formItemId: `${id}-form-item`,\r\n formDescriptionId: `${id}-form-item-description`,\r\n formMessageId: `${id}-form-item-message`,\r\n ...fieldState,\r\n };\r\n};\r\n\r\ntype FormItemContextValue = {\r\n id: string;\r\n};\r\n\r\nconst FormItemContext = React.createContext<FormItemContextValue | null>(null);\r\n\r\n/** Container for a single form field; provides a unique ID via context */\r\nconst FormItem = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const id = React.useId();\r\n\r\n return (\r\n <FormItemContext.Provider value={{ id }}>\r\n <div ref={ref} className={cn('space-y-2', className)} {...props} />\r\n </FormItemContext.Provider>\r\n );\r\n});\r\nFormItem.displayName = 'FormItem';\r\n\r\n/** Label that auto-associates with its parent FormItem's control via htmlFor */\r\nconst FormLabel = React.forwardRef<\r\n HTMLLabelElement,\r\n React.LabelHTMLAttributes<HTMLLabelElement>\r\n>(({ className, ...props }, ref) => {\r\n const { formItemId } = useFormField();\r\n\r\n return (\r\n <label\r\n ref={ref}\r\n htmlFor={formItemId}\r\n className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormLabel.displayName = 'FormLabel';\r\n\r\n/** Wrapper for the form input; applies aria-describedby and aria-invalid attributes */\r\nconst FormControl = React.forwardRef<\r\n React.ElementRef<'div'>,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ ...props }, ref) => {\r\n const { error, formItemId, formDescriptionId, formMessageId } = useFormField();\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n id={formItemId}\r\n aria-describedby={\r\n !error\r\n ? `${formDescriptionId}`\r\n : `${formDescriptionId} ${formMessageId}`\r\n }\r\n aria-invalid={!!error}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormControl.displayName = 'FormControl';\r\n\r\n/** Helper text displayed below a form control */\r\nconst FormDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n React.HTMLAttributes<HTMLParagraphElement>\r\n>(({ className, ...props }, ref) => {\r\n const { formDescriptionId } = useFormField();\r\n\r\n return (\r\n <p\r\n ref={ref}\r\n id={formDescriptionId}\r\n className={cn('text-[0.8rem] text-muted-foreground', className)}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormDescription.displayName = 'FormDescription';\r\n\r\n/** Displays the field's validation error message, or custom children as fallback */\r\nconst FormMessage = React.forwardRef<\r\n HTMLParagraphElement,\r\n React.HTMLAttributes<HTMLParagraphElement>\r\n>(({ className, children, ...props }, ref) => {\r\n const { error, formMessageId } = useFormField();\r\n const body = error ? String(error?.message) : children;\r\n\r\n if (!body) {\r\n return null;\r\n }\r\n\r\n return (\r\n <p\r\n ref={ref}\r\n id={formMessageId}\r\n className={cn('text-[0.8rem] font-medium text-danger', className)}\r\n {...props}\r\n >\r\n {body}\r\n </p>\r\n );\r\n});\r\nFormMessage.displayName = 'FormMessage';\r\n\r\nexport {\r\n useFormField,\r\n Form,\r\n FormItem,\r\n FormLabel,\r\n FormControl,\r\n FormDescription,\r\n FormMessage,\r\n FormField,\r\n};\r\n"
434
+ "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\nimport {\n Controller,\n FormProvider,\n useFormContext,\n} from 'react-hook-form';\nimport type {\n ControllerProps,\n FieldPath,\n FieldValues,\n} from 'react-hook-form';\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue | null>(null);\n\n/** Connects a form field to react-hook-form via Controller and provides field context */\nconst FormField = <\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n ...props\n}: ControllerProps<TFieldValues, TName>) => {\n return (\n <FormFieldContext.Provider value={{ name: props.name }}>\n <Controller {...props} />\n </FormFieldContext.Provider>\n );\n};\n\nconst useFormField = () => {\n const fieldContext = React.useContext(FormFieldContext);\n const itemContext = React.useContext(FormItemContext);\n const { getFieldState, formState } = useFormContext();\n\n if (!fieldContext) {\n throw new Error('useFormField must be used within <FormField>');\n }\n\n const fieldState = getFieldState(fieldContext.name, formState);\n\n if (!itemContext) {\n throw new Error('useFormField should be used within <FormItem>');\n }\n\n const { id } = itemContext;\n\n return {\n id,\n name: fieldContext.name,\n formItemId: `${id}-form-item`,\n formDescriptionId: `${id}-form-item-description`,\n formMessageId: `${id}-form-item-message`,\n ...fieldState,\n };\n};\n\ntype FormItemContextValue = {\n id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue | null>(null);\n\n/** Container for a single form field; provides a unique ID via context */\nconst FormItem = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const id = React.useId();\n\n return (\n <FormItemContext.Provider value={{ id }}>\n <div ref={ref} className={cn('space-y-2', className)} {...props} />\n </FormItemContext.Provider>\n );\n});\nFormItem.displayName = 'FormItem';\n\n/** Label that auto-associates with its parent FormItem's control via htmlFor */\nconst FormLabel = React.forwardRef<\n HTMLLabelElement,\n React.LabelHTMLAttributes<HTMLLabelElement>\n>(({ className, ...props }, ref) => {\n const { formItemId } = useFormField();\n\n return (\n <label\n ref={ref}\n htmlFor={formItemId}\n className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}\n {...props}\n />\n );\n});\nFormLabel.displayName = 'FormLabel';\n\n/** Wrapper for the form input; applies aria-describedby and aria-invalid attributes */\nconst FormControl = React.forwardRef<\n React.ElementRef<'div'>,\n React.HTMLAttributes<HTMLDivElement>\n>(({ ...props }, ref) => {\n const { error, formItemId, formDescriptionId, formMessageId } = useFormField();\n\n return (\n <div\n ref={ref}\n id={formItemId}\n aria-describedby={\n !error\n ? `${formDescriptionId}`\n : `${formDescriptionId} ${formMessageId}`\n }\n aria-invalid={!!error}\n {...props}\n />\n );\n});\nFormControl.displayName = 'FormControl';\n\n/** Helper text displayed below a form control */\nconst FormDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n const { formDescriptionId } = useFormField();\n\n return (\n <p\n ref={ref}\n id={formDescriptionId}\n className={cn('text-[0.8rem] text-muted-foreground', className)}\n {...props}\n />\n );\n});\nFormDescription.displayName = 'FormDescription';\n\n/** Displays the field's validation error message, or custom children as fallback */\nconst FormMessage = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n const { error, formMessageId } = useFormField();\n const body = error ? String(error?.message) : children;\n\n if (!body) {\n return null;\n }\n\n return (\n <p\n ref={ref}\n id={formMessageId}\n className={cn('text-[0.8rem] font-medium text-danger', className)}\n {...props}\n >\n {body}\n </p>\n );\n});\nFormMessage.displayName = 'FormMessage';\n\nexport {\n useFormField,\n Form,\n FormItem,\n FormLabel,\n FormControl,\n FormDescription,\n FormMessage,\n FormField,\n};\n"
398
435
  }
399
436
  ]
400
437
  },
@@ -411,7 +448,7 @@
411
448
  "files": [
412
449
  {
413
450
  "path": "src/components/ui/input/Input.tsx",
414
- "content": "'use client';\r\nimport * as React from 'react';\r\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Toggle } from '@/components/ui/toggle/Toggle';\r\nimport { Eye, EyeOff } from 'lucide-react';\r\n\r\nconst inputVariants = tv({\r\n base: 'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus:border-primary',\r\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Input component */\r\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\r\n /** Label text displayed above the input */\r\n label?: string;\r\n /** Error message displayed below the input; also applies danger styling */\r\n error?: string;\r\n /** Helper text displayed below the input (hidden when error is present) */\r\n description?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n icon?: React.ReactNode;\r\n /** Icon rendered at the end (right side) of the input; ignored for password type */\r\n endIcon?: React.ReactNode;\r\n placeholder?: string;\r\n className?: string;\r\n required?: boolean;\r\n}\r\n\r\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\r\n ({ className, variant, label, error, description, icon, endIcon, id, type, required, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const inputId = id || defaultId;\r\n const [showPassword, setShowPassword] = React.useState(false);\r\n\r\n const isPassword = type === 'password';\r\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground \">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\r\n </BaseField.Label>\r\n )}\r\n <div className=\"relative\">\r\n {icon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {icon}\r\n </div>\r\n )}\r\n <BaseInput\r\n ref={ref}\r\n id={inputId}\r\n type={inputType || 'text'}\r\n className={cn(\r\n inputVariants({ variant }),\r\n icon && 'pl-9',\r\n (isPassword || endIcon) && 'pr-10',\r\n error && 'border-danger focus:border-danger',\r\n className\r\n )}\r\n {...props}\r\n />\r\n {isPassword ? (\r\n <Toggle\r\n type=\"button\"\r\n variant=\"ghost\"\r\n size=\"icon\"\r\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\r\n pressed={showPassword}\r\n onPressedChange={setShowPassword}\r\n aria-label={showPassword ? 'Hide password' : 'Show password'}\r\n >\r\n {showPassword ? <EyeOff className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />}\r\n </Toggle>\r\n ) : endIcon ? (\r\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {endIcon}\r\n </div>\r\n ) : null}\r\n </div>\r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nInput.displayName = 'Input';\r\n\r\nexport { Input };\r\n"
451
+ "content": "'use client';\nimport * as React from 'react';\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Toggle } from '@/components/ui/toggle/Toggle';\nimport { Eye, EyeOff } from 'lucide-react';\n\nconst inputVariants = tv({\n base: 'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\n variants: {\n variant: {\n default: '',\n filled: 'bg-accent border-transparent focus:border-primary',\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\n }\n },\n defaultVariants: {\n variant: 'default'\n }\n});\n\n/** Props for the Input component */\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\n /** Label text displayed above the input */\n label?: string;\n /** Error message displayed below the input; also applies danger styling */\n error?: string;\n /** Helper text displayed below the input (hidden when error is present) */\n description?: string;\n /** Icon rendered at the start (left side) of the input */\n icon?: React.ReactNode;\n /** Icon rendered at the end (right side) of the input; ignored for password type */\n endIcon?: React.ReactNode;\n placeholder?: string;\n className?: string;\n required?: boolean;\n}\n\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\n ({ className, variant, label, error, description, icon, endIcon, id, type, required, ...props }, ref) => {\n const defaultId = React.useId();\n const inputId = id || defaultId;\n const [showPassword, setShowPassword] = React.useState(false);\n\n const isPassword = type === 'password';\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\n\n return (\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\n {label && (\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground \">\n {label}\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\n </BaseField.Label>\n )}\n <div className=\"relative\">\n {icon && (\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\n {icon}\n </div>\n )}\n <BaseInput\n ref={ref}\n id={inputId}\n type={inputType || 'text'}\n className={cn(\n inputVariants({ variant }),\n icon && 'pl-9',\n (isPassword || endIcon) && 'pr-10',\n error && 'border-danger focus:border-danger',\n className\n )}\n {...props}\n />\n {isPassword ? (\n <Toggle\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\n pressed={showPassword}\n onPressedChange={setShowPassword}\n aria-label={showPassword ? 'Hide password' : 'Show password'}\n >\n {showPassword ? <EyeOff className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />}\n </Toggle>\n ) : endIcon ? (\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\n {endIcon}\n </div>\n ) : null}\n </div>\n {description && !error && (\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\n {description}\n </BaseField.Description>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">\n {error}\n </p>\n )}\n </BaseField.Root>\n );\n }\n);\nInput.displayName = 'Input';\n\nexport { Input };\n"
415
452
  }
416
453
  ]
417
454
  },
@@ -424,52 +461,52 @@
424
461
  "files": [
425
462
  {
426
463
  "path": "src/components/ui/input-otp/InputOtp.tsx",
427
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useInputOTP, getSeparatorPositions } from './useInputOTP';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst inputOTPVariants = tv({\r\n slots: {\r\n root: 'flex items-center gap-2',\r\n slot: [\r\n 'relative flex items-center justify-center',\r\n 'border border-border bg-background text-foreground font-semibold',\r\n 'transition-all duration-200 outline-none',\r\n 'select-none',\r\n ].join(' '),\r\n separator: 'flex items-center justify-center text-muted-foreground shrink-0',\r\n caret: 'absolute inset-0 flex items-center justify-center pointer-events-none',\r\n caretBlink: 'w-px bg-foreground animate-blink',\r\n },\r\n variants: {\r\n variant: {\r\n outline: {\r\n slot: 'rounded-md focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\r\n },\r\n filled: {\r\n slot: 'rounded-md bg-muted border-transparent focus-within:bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\r\n },\r\n underline: {\r\n slot: 'border-0 border-b-2 border-b-border rounded-none bg-transparent focus-within:border-b-primary',\r\n },\r\n glass: {\r\n slot: 'rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border-white/20 dark:border-white/10 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 shadow-sm',\r\n },\r\n },\r\n size: {\r\n sm: {\r\n slot: 'h-9 w-9 text-sm',\r\n separator: 'text-lg px-0.5',\r\n caretBlink: 'h-4',\r\n },\r\n md: {\r\n slot: 'h-12 w-12 text-lg',\r\n separator: 'text-xl px-1',\r\n caretBlink: 'h-5',\r\n },\r\n lg: {\r\n slot: 'h-14 w-14 text-2xl',\r\n separator: 'text-2xl px-1.5',\r\n caretBlink: 'h-6',\r\n },\r\n },\r\n shape: {\r\n square: { slot: '' },\r\n rounded: { slot: '' },\r\n circle: { slot: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { shape: 'rounded', variant: 'outline', className: { slot: 'rounded-xl' } },\r\n { shape: 'rounded', variant: 'filled', className: { slot: 'rounded-xl' } },\r\n { shape: 'rounded', variant: 'glass', className: { slot: 'rounded-2xl' } },\r\n { shape: 'circle', variant: 'outline', className: { slot: 'rounded-full' } },\r\n { shape: 'circle', variant: 'filled', className: { slot: 'rounded-full' } },\r\n { shape: 'circle', variant: 'glass', className: { slot: 'rounded-full' } },\r\n ],\r\n defaultVariants: {\r\n variant: 'outline',\r\n size: 'md',\r\n shape: 'square',\r\n },\r\n});\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\r\ntype SeparatorType = 'dash' | 'dot' | 'space' | React.ReactNode;\r\n\r\nexport interface InputOTPProps\r\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'inputMode'>,\r\n VariantProps<typeof inputOTPVariants> {\r\n length?: number;\r\n value?: 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 mask?: string | boolean;\r\n disabled?: boolean;\r\n error?: boolean;\r\n errorMessage?: string;\r\n label?: string;\r\n description?: string;\r\n autoFocus?: boolean;\r\n separatorAfter?: number[] | number;\r\n separator?: SeparatorType;\r\n autoSubmit?: boolean;\r\n slotClassName?: string;\r\n successOnComplete?: boolean;\r\n placeholder?: string;\r\n}\r\n\r\n// ─── Separator Renderer ─────────────────────────────────────────────────────\r\n\r\nfunction renderSeparatorContent(separator: SeparatorType) {\r\n if (separator === 'dash') return <span>&ndash;</span>;\r\n if (separator === 'dot') return <span>&bull;</span>;\r\n if (separator === 'space') return <span>&nbsp;&nbsp;</span>;\r\n return separator;\r\n}\r\n\r\n// ─── Display Char ───────────────────────────────────────────────────────────\r\n\r\nfunction getDisplayChar(\r\n char: string,\r\n index: number,\r\n mask: string | boolean | undefined,\r\n placeholder: string | undefined,\r\n) {\r\n if (char === '') {\r\n if (placeholder && placeholder[index]) {\r\n return <span className=\"text-muted-foreground/50 font-normal\">{placeholder[index]}</span>;\r\n }\r\n return null;\r\n }\r\n if (mask) {\r\n const maskChar = typeof mask === 'string' ? mask : '\\u2022';\r\n return <span>{maskChar}</span>;\r\n }\r\n return <span>{char}</span>;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────────────────────\r\n\r\nconst InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(\r\n (\r\n {\r\n className,\r\n variant,\r\n size,\r\n shape,\r\n length = 6,\r\n value: controlledValue,\r\n defaultValue = '',\r\n onChange,\r\n onComplete,\r\n inputMode = 'numeric',\r\n pattern: customPattern,\r\n mask,\r\n disabled = false,\r\n error = false,\r\n errorMessage,\r\n label,\r\n description,\r\n autoFocus = false,\r\n separatorAfter,\r\n separator = 'dash',\r\n autoSubmit = false,\r\n slotClassName,\r\n successOnComplete = false,\r\n placeholder,\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const rootId = React.useId();\r\n const slots = inputOTPVariants({ variant, size, shape });\r\n const separatorPositions = getSeparatorPositions(separatorAfter, length);\r\n\r\n const {\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 } = 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 });\r\n\r\n return (\r\n <div ref={ref} className=\"flex flex-col gap-1.5\" {...props}>\r\n {label && (\r\n <label htmlFor={`${rootId}-0`} 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={cn(slots.root(), className)}\r\n role=\"group\"\r\n aria-label={label || 'OTP Input'}\r\n aria-describedby={description ? `${rootId}-desc` : errorMessage ? `${rootId}-err` : undefined}\r\n >\r\n {Array.from({ length }).map((_, i) => {\r\n const isFocused = focusedIndex === i;\r\n const isFilled = chars[i] !== '';\r\n const showSuccess = successOnComplete && isComplete;\r\n const showError = error && !showSuccess;\r\n\r\n return (\r\n <React.Fragment key={i}>\r\n <div\r\n className={cn(\r\n slots.slot(),\r\n isFocused && !showError && 'border-primary ring-2 ring-primary/20',\r\n showError && 'border-danger',\r\n showSuccess && 'border-success text-success',\r\n disabled && 'opacity-50 cursor-not-allowed',\r\n slotClassName,\r\n )}\r\n >\r\n <input\r\n ref={(el) => { inputRefs.current[i] = el; }}\r\n id={i === 0 ? `${rootId}-0` : undefined}\r\n type=\"text\"\r\n inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}\r\n autoComplete={i === 0 ? 'one-time-code' : 'off'}\r\n aria-label={`Digit ${i + 1} of ${length}`}\r\n aria-invalid={error || undefined}\r\n maxLength={1}\r\n value=\"\"\r\n disabled={disabled}\r\n className=\"sr-only\"\r\n onFocus={() => setFocusedIndex(i)}\r\n onBlur={() => setFocusedIndex(null)}\r\n onChange={(e) => {\r\n const char = e.target.value;\r\n if (char) handleInput(i, char);\r\n }}\r\n onKeyDown={(e) => handleKeyDown(i, e)}\r\n onPaste={handlePaste}\r\n />\r\n <div\r\n className=\"flex items-center justify-center w-full h-full cursor-text\"\r\n onClick={() => !disabled && focusSlot(i)}\r\n >\r\n {getDisplayChar(chars[i], i, mask, placeholder)}\r\n </div>\r\n {isFocused && !isFilled && !disabled && (\r\n <div className={slots.caret()}>\r\n <div className={slots.caretBlink()} />\r\n </div>\r\n )}\r\n </div>\r\n\r\n {separatorPositions.has(i) && (\r\n <div className={slots.separator()} aria-hidden=\"true\">\r\n {renderSeparatorContent(separator)}\r\n </div>\r\n )}\r\n </React.Fragment>\r\n );\r\n })}\r\n </div>\r\n\r\n {description && !error && !errorMessage && (\r\n <p id={`${rootId}-desc`} className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {(error || errorMessage) && (\r\n <p id={`${rootId}-err`} className=\"text-[0.8rem] font-medium text-danger\">{errorMessage || 'Invalid code'}</p>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nInputOTP.displayName = 'InputOTP';\r\n\r\nexport { InputOTP, inputOTPVariants };\r\n"
464
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { useInputOTP, getSeparatorPositions } from './useInputOTP';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst inputOTPVariants = tv({\n slots: {\n root: 'flex items-center gap-2',\n slot: [\n 'relative flex items-center justify-center',\n 'border border-border bg-background text-foreground font-semibold',\n 'transition-all duration-200 outline-none',\n 'select-none',\n ].join(' '),\n separator: 'flex items-center justify-center text-muted-foreground shrink-0',\n caret: 'absolute inset-0 flex items-center justify-center pointer-events-none',\n caretBlink: 'w-px bg-foreground animate-blink',\n },\n variants: {\n variant: {\n outline: {\n slot: 'rounded-md focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n filled: {\n slot: 'rounded-md bg-muted border-transparent focus-within:bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n underline: {\n slot: 'border-0 border-b-2 border-b-border rounded-none bg-transparent focus-within:border-b-primary',\n },\n glass: {\n slot: 'rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border-white/20 dark:border-white/10 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 shadow-sm',\n },\n },\n size: {\n sm: {\n slot: 'h-9 w-9 text-sm',\n separator: 'text-lg px-0.5',\n caretBlink: 'h-4',\n },\n md: {\n slot: 'h-12 w-12 text-lg',\n separator: 'text-xl px-1',\n caretBlink: 'h-5',\n },\n lg: {\n slot: 'h-14 w-14 text-2xl',\n separator: 'text-2xl px-1.5',\n caretBlink: 'h-6',\n },\n },\n shape: {\n square: { slot: '' },\n rounded: { slot: '' },\n circle: { slot: '' },\n },\n },\n compoundVariants: [\n { shape: 'rounded', variant: 'outline', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'filled', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'glass', className: { slot: 'rounded-2xl' } },\n { shape: 'circle', variant: 'outline', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'filled', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'glass', className: { slot: 'rounded-full' } },\n ],\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n shape: 'square',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\ntype SeparatorType = 'dash' | 'dot' | 'space' | React.ReactNode;\n\nexport interface InputOTPProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'inputMode'>,\n VariantProps<typeof inputOTPVariants> {\n length?: number;\n value?: string;\n defaultValue?: string;\n onChange?: (value: string) => void;\n onComplete?: (value: string) => void;\n inputMode?: InputMode;\n pattern?: RegExp;\n mask?: string | boolean;\n disabled?: boolean;\n error?: boolean;\n errorMessage?: string;\n label?: string;\n description?: string;\n autoFocus?: boolean;\n separatorAfter?: number[] | number;\n separator?: SeparatorType;\n autoSubmit?: boolean;\n slotClassName?: string;\n successOnComplete?: boolean;\n placeholder?: string;\n}\n\n// ─── Separator Renderer ─────────────────────────────────────────────────────\n\nfunction renderSeparatorContent(separator: SeparatorType) {\n if (separator === 'dash') return <span>&ndash;</span>;\n if (separator === 'dot') return <span>&bull;</span>;\n if (separator === 'space') return <span>&nbsp;&nbsp;</span>;\n return separator;\n}\n\n// ─── Display Char ───────────────────────────────────────────────────────────\n\nfunction getDisplayChar(\n char: string,\n index: number,\n mask: string | boolean | undefined,\n placeholder: string | undefined,\n) {\n if (char === '') {\n if (placeholder && placeholder[index]) {\n return <span className=\"text-muted-foreground/50 font-normal\">{placeholder[index]}</span>;\n }\n return null;\n }\n if (mask) {\n const maskChar = typeof mask === 'string' ? mask : '\\u2022';\n return <span>{maskChar}</span>;\n }\n return <span>{char}</span>;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(\n (\n {\n className,\n variant,\n size,\n shape,\n length = 6,\n value: controlledValue,\n defaultValue = '',\n onChange,\n onComplete,\n inputMode = 'numeric',\n pattern: customPattern,\n mask,\n disabled = false,\n error = false,\n errorMessage,\n label,\n description,\n autoFocus = false,\n separatorAfter,\n separator = 'dash',\n autoSubmit = false,\n slotClassName,\n successOnComplete = false,\n placeholder,\n ...props\n },\n ref,\n ) => {\n const rootId = React.useId();\n const slots = inputOTPVariants({ variant, size, shape });\n const separatorPositions = getSeparatorPositions(separatorAfter, length);\n\n const {\n chars,\n isComplete,\n focusedIndex,\n setFocusedIndex,\n inputRefs,\n focusSlot,\n handleInput,\n handleKeyDown,\n handlePaste,\n } = useInputOTP({\n length,\n controlledValue,\n defaultValue,\n onChange,\n onComplete,\n inputMode,\n pattern: customPattern,\n autoFocus,\n autoSubmit,\n });\n\n return (\n <div ref={ref} className=\"flex flex-col gap-1.5\" {...props}>\n {label && (\n <label htmlFor={`${rootId}-0`} className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <div\n className={cn(slots.root(), className)}\n role=\"group\"\n aria-label={label || 'OTP Input'}\n aria-describedby={description ? `${rootId}-desc` : errorMessage ? `${rootId}-err` : undefined}\n >\n {Array.from({ length }).map((_, i) => {\n const isFocused = focusedIndex === i;\n const isFilled = chars[i] !== '';\n const showSuccess = successOnComplete && isComplete;\n const showError = error && !showSuccess;\n\n return (\n <React.Fragment key={i}>\n <div\n className={cn(\n slots.slot(),\n isFocused && !showError && 'border-primary ring-2 ring-primary/20',\n showError && 'border-danger',\n showSuccess && 'border-success text-success',\n disabled && 'opacity-50 cursor-not-allowed',\n slotClassName,\n )}\n >\n <input\n ref={(el) => { inputRefs.current[i] = el; }}\n id={i === 0 ? `${rootId}-0` : undefined}\n type=\"text\"\n inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}\n autoComplete={i === 0 ? 'one-time-code' : 'off'}\n aria-label={`Digit ${i + 1} of ${length}`}\n aria-invalid={error || undefined}\n maxLength={1}\n value=\"\"\n disabled={disabled}\n className=\"sr-only\"\n onFocus={() => setFocusedIndex(i)}\n onBlur={() => setFocusedIndex(null)}\n onChange={(e) => {\n const char = e.target.value;\n if (char) handleInput(i, char);\n }}\n onKeyDown={(e) => handleKeyDown(i, e)}\n onPaste={handlePaste}\n />\n <div\n className=\"flex items-center justify-center w-full h-full cursor-text\"\n onClick={() => !disabled && focusSlot(i)}\n >\n {getDisplayChar(chars[i], i, mask, placeholder)}\n </div>\n {isFocused && !isFilled && !disabled && (\n <div className={slots.caret()}>\n <div className={slots.caretBlink()} />\n </div>\n )}\n </div>\n\n {separatorPositions.has(i) && (\n <div className={slots.separator()} aria-hidden=\"true\">\n {renderSeparatorContent(separator)}\n </div>\n )}\n </React.Fragment>\n );\n })}\n </div>\n\n {description && !error && !errorMessage && (\n <p id={`${rootId}-desc`} className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {(error || errorMessage) && (\n <p id={`${rootId}-err`} className=\"text-[0.8rem] font-medium text-danger\">{errorMessage || 'Invalid code'}</p>\n )}\n </div>\n );\n },\n);\n\nInputOTP.displayName = 'InputOTP';\n\nexport { InputOTP, inputOTPVariants };\n"
428
465
  },
429
466
  {
430
467
  "path": "src/components/ui/input-otp/useInputOTP.ts",
431
- "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"
468
+ "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"
432
469
  }
433
470
  ]
434
471
  },
435
472
  "layout-demo1": {
436
473
  "name": "layout-demo1",
437
474
  "dependencies": [
475
+ "react-router-dom",
438
476
  "lucide-react",
439
- "react-resizable-panels",
440
- "react-router-dom"
477
+ "react-resizable-panels"
441
478
  ],
442
479
  "internalDependencies": [
443
- "sidebar",
444
- "resizable",
445
480
  "skeleton",
446
481
  "main",
447
- "constants"
482
+ "sidebar",
483
+ "constants",
484
+ "resizable"
448
485
  ],
449
486
  "files": [
450
487
  {
451
- "path": "src/components/ui/layout-demo1/constants/route.constants.tsx",
452
- "content": "import React from \"react\";\r\nimport { BookOpen, Info, Settings } from \"lucide-react\";\r\n\r\ninterface SidebarItem {\r\n title: string;\r\n href: string;\r\n icon?: React.ReactNode;\r\n badge?: string;\r\n}\r\n\r\ninterface SidebarSection {\r\n title: string;\r\n icon?: React.ReactNode;\r\n collapsible: boolean;\r\n defaultOpen?: boolean;\r\n items: SidebarItem[];\r\n}\r\n\r\nexport const routesConfig: SidebarSection[] = [\r\n {\r\n title: \"Tổng quan\",\r\n icon: <BookOpen className=\"w-4 h-4\" />,\r\n collapsible: false,\r\n items: [\r\n {\r\n title: \"Giới thiệu\",\r\n href: \"/blocks\",\r\n icon: <Info className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Guideline & Cài đặt CLI\",\r\n href: \"/#\",\r\n icon: <Settings className=\"w-4 h-4\" />,\r\n badge: \"CLI\",\r\n },\r\n ],\r\n },\r\n];\r\n"
488
+ "path": "src/components/ui/layout-demo1/_components/Test.tsx",
489
+ "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\nimport Main from \"../main\";\n\nconst ContentSkeleton = () => (\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\n {/* Header bar */}\n <div className=\"flex items-center justify-between\">\n <Skeleton className=\"h-6 w-40\" />\n <div className=\"flex gap-2\">\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n </div>\n </div>\n\n {/* Stats cards */}\n <div className=\"grid grid-cols-3 gap-4\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div key={i} className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\">\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n </div>\n ))}\n </div>\n\n {/* Table */}\n <div className=\"flex flex-col gap-2\">\n {/* Table header */}\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\n {Array.from({ length: 4 }).map((_, i) => (\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\n ))}\n </div>\n {/* Table rows */}\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\">\n <div className=\"flex items-center gap-2\">\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\n <Skeleton className=\"h-3 flex-1\" />\n </div>\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\n </div>\n ))}\n </div>\n </div>\n);\n\nconst Test = () => {\n return (\n <Main>\n <div className=\"flex h-full -m-6\" data-lenis-prevent>\n <ContentSkeleton />\n </div>\n </Main>\n );\n};\n\nexport default Test;\n"
453
490
  },
454
491
  {
455
- "path": "src/components/ui/layout-demo1/layout.tsx",
456
- "content": "'use client'\r\n\r\nimport { useCallback, useEffect, useRef } from 'react'\r\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\r\nimport {\r\n SidebarProvider,\r\n SidebarInset,\r\n useSidebar,\r\n} from '@/components/ui/sidebar/Sidebar'\r\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\r\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\r\nimport SideBar from './_layout/SideBar'\r\nimport Header from './_layout/Header'\r\n\r\ninterface Props {\r\n children: React.ReactNode\r\n}\r\n\r\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\r\n const { open, setOpen, isMobile } = useSidebar();\r\n const panelRef = useRef<PanelImperativeHandle>(null);\r\n // Prevent onResize from echoing back the programmatic change\r\n const syncingRef = useRef(false);\r\n\r\n // Keyboard toggle (Ctrl+B) collapse / expand panel\r\n useEffect(() => {\r\n const panel = panelRef.current;\r\n if (!panel || isMobile) return;\r\n syncingRef.current = true;\r\n if (open) panel.expand();\r\n else panel.collapse();\r\n requestAnimationFrame(() => { syncingRef.current = false; });\r\n }, [open, isMobile]);\r\n\r\n // Drag-to-collapse/expand → update sidebar context\r\n const handleSidebarResize = useCallback(\r\n (size: { asPercentage: number; inPixels: number }) => {\r\n if (syncingRef.current) return;\r\n // collapsedSize = \"64px\"; add a small threshold to avoid floating-point edge cases\r\n setOpen(size.inPixels > 72);\r\n },\r\n [setOpen],\r\n );\r\n\r\n const content = (\r\n <SidebarInset className=\"flex flex-col\">\r\n <Header />\r\n <main className=\"flex-1 overflow-y-auto\">\r\n <div className=\"p-6 h-full overflow-auto\">\r\n <ThemeProvider defaultTheme=\"\">\r\n {children}\r\n </ThemeProvider>\r\n </div>\r\n </main>\r\n </SidebarInset>\r\n );\r\n\r\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\r\n if (isMobile) {\r\n return (\r\n <div className=\"flex h-screen w-full\">\r\n <SideBar />\r\n {content}\r\n </div>\r\n );\r\n }\r\n\r\n // ── Desktop: sidebar + handle + content as resizable panels ───\r\n return (\r\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\r\n <ResizablePanel\r\n id=\"app-sidebar\"\r\n defaultSize=\"256px\"\r\n minSize=\"160px\"\r\n maxSize=\"480px\"\r\n collapsible\r\n collapsedSize=\"64px\"\r\n groupResizeBehavior=\"preserve-pixel-size\"\r\n panelRef={panelRef}\r\n onResize={handleSidebarResize}\r\n >\r\n <SideBar />\r\n </ResizablePanel>\r\n <ResizableHandle variant=\"bar\" />\r\n\r\n\r\n <ResizablePanel>\r\n {content}\r\n </ResizablePanel>\r\n \r\n </ResizablePanelGroup>\r\n );\r\n}\r\n\r\nexport default function DocLayout({ children }: Props) {\r\n return (\r\n <div className=\"h-screen w-full overflow-hidden\">\r\n <SidebarProvider className=\"h-full \">\r\n <LayoutBody>{children}</LayoutBody>\r\n </SidebarProvider>\r\n </div>\r\n )\r\n}\r\n"
492
+ "path": "src/components/ui/layout-demo1/_layout/Header.tsx",
493
+ "content": "import { SidebarTrigger } from \"@/components/ui/sidebar/Sidebar\";\n\n/**\n * Header chỉ hiển thị trên mobile (< 768px).\n * Desktop không header sidebar luôn hiện sẵn.\n */\nconst Header = () => {\n return (\n <header className=\"md:hidden flex items-center gap-3 h-12 px-4 border-b border-border bg-background shrink-0\">\n <SidebarTrigger />\n <div className=\"flex items-center gap-2\">\n <div className=\"w-6 h-6 bg-primary rounded-md flex items-center justify-center shrink-0\">\n <span className=\"text-primary-foreground text-xs font-bold select-none\">\n UI\n </span>\n </div>\n <span className=\"text-sm font-semibold text-foreground\">UI Library</span>\n </div>\n </header>\n );\n};\n\nexport default Header;\n"
457
494
  },
458
495
  {
459
- "path": "src/components/ui/layout-demo1/main.tsx",
460
- "content": "import React from 'react'\r\nimport Layout from './layout'\r\n\r\nconst Main = ({ children }: { children: React.ReactNode }) => {\r\n return (\r\n <Layout>\r\n <main className=\"flex-1 p-6\">\r\n {children}\r\n </main>\r\n </Layout>\r\n )\r\n}\r\n\r\nexport default Main"
496
+ "path": "src/components/ui/layout-demo1/_layout/SideBar.tsx",
497
+ "content": "import React from \"react\";\nimport { Link, useLocation } from \"react-router-dom\";\nimport { cn } from \"@/lib/utils/cn\";\nimport {\n Sidebar,\n SidebarHeader,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n SidebarMenuCollapsible,\n SidebarMenuSubItem,\n useSidebar,\n} from \"@/components/ui/sidebar/Sidebar\";\nimport { routesConfig } from \"../constants/route.constants\";\n\nconst SideBar = () => {\n const { pathname } = useLocation();\n const { state } = useSidebar();\n const isCollapsed = state === \"collapsed\";\n return (\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\">\n <SidebarHeader>\n <Link to=\"/blocks\">\n <div\n className={cn(\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\n isCollapsed && \"justify-center\",\n )}\n >\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\n UI\n </span>\n </div>\n {!isCollapsed && (\n <div className=\"overflow-hidden min-w-0\">\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\n UI Library\n </p>\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\n Component Showcase\n </p>\n </div>\n )}\n </div>\n </Link>\n </SidebarHeader>\n\n <SidebarContent>\n {routesConfig.map((route) => {\n const isChildActive = route.items.some(\n (item) => pathname === item.href,\n );\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\n\n return (\n <SidebarGroup key={route.title}>\n {route.collapsible ? (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuCollapsible\n id={route.title}\n icon={\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {route.icon}\n </span>\n }\n label={route.title}\n defaultOpen={shouldDefaultOpen}\n isChildActive={isChildActive}\n >\n {route.items.map((item) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuSubItem key={item.href}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuSubItem>\n );\n })}\n </SidebarMenuCollapsible>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n ) : (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n {route.items.map((item) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuItem key={item.href}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuItem>\n );\n })}\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n )}\n </SidebarGroup>\n );\n })}\n </SidebarContent>\n </Sidebar>\n );\n};\n\nexport default SideBar;\n"
461
498
  },
462
499
  {
463
- "path": "src/components/ui/layout-demo1/_components/Test.tsx",
464
- "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\r\nimport Main from \"../main\";\r\n\r\nconst ContentSkeleton = () => (\r\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\r\n {/* Header bar */}\r\n <div className=\"flex items-center justify-between\">\r\n <Skeleton className=\"h-6 w-40\" />\r\n <div className=\"flex gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n </div>\r\n </div>\r\n\r\n {/* Stats cards */}\r\n <div className=\"grid grid-cols-3 gap-4\">\r\n {Array.from({ length: 3 }).map((_, i) => (\r\n <div key={i} className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\">\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n </div>\r\n ))}\r\n </div>\r\n\r\n {/* Table */}\r\n <div className=\"flex flex-col gap-2\">\r\n {/* Table header */}\r\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\r\n {Array.from({ length: 4 }).map((_, i) => (\r\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\r\n ))}\r\n </div>\r\n {/* Table rows */}\r\n {Array.from({ length: 6 }).map((_, i) => (\r\n <div key={i} className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\">\r\n <div className=\"flex items-center gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\r\n <Skeleton className=\"h-3 flex-1\" />\r\n </div>\r\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\r\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\r\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n);\r\n\r\nconst Test = () => {\r\n return (\r\n <Main>\r\n <div className=\"flex h-full -m-6\" data-lenis-prevent>\r\n <ContentSkeleton />\r\n </div>\r\n </Main>\r\n );\r\n};\r\n\r\nexport default Test;\r\n"
500
+ "path": "src/components/ui/layout-demo1/constants/route.constants.tsx",
501
+ "content": "import React from \"react\";\nimport { BookOpen, Info, Settings } from \"lucide-react\";\n\ninterface SidebarItem {\n title: string;\n href: string;\n icon?: React.ReactNode;\n badge?: string;\n}\n\ninterface SidebarSection {\n title: string;\n icon?: React.ReactNode;\n collapsible: boolean;\n defaultOpen?: boolean;\n items: SidebarItem[];\n}\n\nexport const routesConfig: SidebarSection[] = [\n {\n title: \"Tổng quan\",\n icon: <BookOpen className=\"w-4 h-4\" />,\n collapsible: false,\n items: [\n {\n title: \"Giới thiệu\",\n href: \"/blocks\",\n icon: <Info className=\"w-4 h-4\" />,\n },\n {\n title: \"Guideline & Cài đặt CLI\",\n href: \"/#\",\n icon: <Settings className=\"w-4 h-4\" />,\n badge: \"CLI\",\n },\n ],\n },\n];\n"
465
502
  },
466
503
  {
467
- "path": "src/components/ui/layout-demo1/_layout/Header.tsx",
468
- "content": "import { SidebarTrigger } from \"@/components/ui/sidebar/Sidebar\";\r\n\r\n/**\r\n * Header chỉ hiển thị trên mobile (< 768px).\r\n * Desktop không header sidebar luôn hiện sẵn.\r\n */\r\nconst Header = () => {\r\n return (\r\n <header className=\"md:hidden flex items-center gap-3 h-12 px-4 border-b border-border bg-background shrink-0\">\r\n <SidebarTrigger />\r\n <div className=\"flex items-center gap-2\">\r\n <div className=\"w-6 h-6 bg-primary rounded-md flex items-center justify-center shrink-0\">\r\n <span className=\"text-primary-foreground text-xs font-bold select-none\">\r\n UI\r\n </span>\r\n </div>\r\n <span className=\"text-sm font-semibold text-foreground\">UI Library</span>\r\n </div>\r\n </header>\r\n );\r\n};\r\n\r\nexport default Header;\r\n"
504
+ "path": "src/components/ui/layout-demo1/layout.tsx",
505
+ "content": "'use client'\n\nimport { useCallback, useEffect, useRef } from 'react'\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\nimport {\n SidebarProvider,\n SidebarInset,\n useSidebar,\n} from '@/components/ui/sidebar/Sidebar'\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\nimport SideBar from './_layout/SideBar'\nimport Header from './_layout/Header'\n\ninterface Props {\n children: React.ReactNode\n}\n\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\n const { open, setOpen, isMobile } = useSidebar();\n const panelRef = useRef<PanelImperativeHandle>(null);\n // Prevent onResize from echoing back the programmatic change\n const syncingRef = useRef(false);\n\n // Keyboard toggle (Ctrl+B) collapse / expand panel\n useEffect(() => {\n const panel = panelRef.current;\n if (!panel || isMobile) return;\n syncingRef.current = true;\n if (open) panel.expand();\n else panel.collapse();\n requestAnimationFrame(() => { syncingRef.current = false; });\n }, [open, isMobile]);\n\n // Drag-to-collapse/expand → update sidebar context\n const handleSidebarResize = useCallback(\n (size: { asPercentage: number; inPixels: number }) => {\n if (syncingRef.current) return;\n // collapsedSize = \"64px\"; add a small threshold to avoid floating-point edge cases\n setOpen(size.inPixels > 72);\n },\n [setOpen],\n );\n\n const content = (\n <SidebarInset className=\"flex flex-col\">\n <Header />\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"p-6 h-full overflow-auto\">\n <ThemeProvider defaultTheme=\"\">\n {children}\n </ThemeProvider>\n </div>\n </main>\n </SidebarInset>\n );\n\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\n if (isMobile) {\n return (\n <div className=\"flex h-screen w-full\">\n <SideBar />\n {content}\n </div>\n );\n }\n\n // ── Desktop: sidebar + handle + content as resizable panels ───\n return (\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\n <ResizablePanel\n id=\"app-sidebar\"\n defaultSize=\"256px\"\n minSize=\"160px\"\n maxSize=\"480px\"\n collapsible\n collapsedSize=\"64px\"\n groupResizeBehavior=\"preserve-pixel-size\"\n panelRef={panelRef}\n onResize={handleSidebarResize}\n >\n <SideBar />\n </ResizablePanel>\n <ResizableHandle variant=\"bar\" />\n\n\n <ResizablePanel>\n {content}\n </ResizablePanel>\n \n </ResizablePanelGroup>\n );\n}\n\nexport default function DocLayout({ children }: Props) {\n return (\n <div className=\"h-screen w-full overflow-hidden\">\n <SidebarProvider className=\"h-full \">\n <LayoutBody>{children}</LayoutBody>\n </SidebarProvider>\n </div>\n )\n}\n"
469
506
  },
470
507
  {
471
- "path": "src/components/ui/layout-demo1/_layout/SideBar.tsx",
472
- "content": "import React from \"react\";\r\nimport { Link, useLocation } from \"react-router-dom\";\r\nimport { cn } from \"@/lib/utils/cn\";\r\nimport {\r\n Sidebar,\r\n SidebarHeader,\r\n SidebarContent,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSubItem,\r\n useSidebar,\r\n} from \"@/components/ui/sidebar/Sidebar\";\r\nimport { routesConfig } from \"../constants/route.constants\";\r\n\r\nconst SideBar = () => {\r\n const { pathname } = useLocation();\r\n const { state } = useSidebar();\r\n const isCollapsed = state === \"collapsed\";\r\n return (\r\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\">\r\n <SidebarHeader>\r\n <Link to=\"/blocks\">\r\n <div\r\n className={cn(\r\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\r\n isCollapsed && \"justify-center\",\r\n )}\r\n >\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\r\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\r\n UI\r\n </span>\r\n </div>\r\n {!isCollapsed && (\r\n <div className=\"overflow-hidden min-w-0\">\r\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\r\n UI Library\r\n </p>\r\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\r\n Component Showcase\r\n </p>\r\n </div>\r\n )}\r\n </div>\r\n </Link>\r\n </SidebarHeader>\r\n\r\n <SidebarContent>\r\n {routesConfig.map((route) => {\r\n const isChildActive = route.items.some(\r\n (item) => pathname === item.href,\r\n );\r\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\r\n\r\n return (\r\n <SidebarGroup key={route.title}>\r\n {route.collapsible ? (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <SidebarMenuCollapsible\r\n id={route.title}\r\n icon={\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {route.icon}\r\n </span>\r\n }\r\n label={route.title}\r\n defaultOpen={shouldDefaultOpen}\r\n isChildActive={isChildActive}\r\n >\r\n {route.items.map((item) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuSubItem key={item.href}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuSubItem>\r\n );\r\n })}\r\n </SidebarMenuCollapsible>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n ) : (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {route.items.map((item) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuItem key={item.href}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuItem>\r\n );\r\n })}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n )}\r\n </SidebarGroup>\r\n );\r\n })}\r\n </SidebarContent>\r\n </Sidebar>\r\n );\r\n};\r\n\r\nexport default SideBar;\r\n"
508
+ "path": "src/components/ui/layout-demo1/main.tsx",
509
+ "content": "import React from 'react'\nimport Layout from './layout'\n\nconst Main = ({ children }: { children: React.ReactNode }) => {\n return (\n <Layout>\n <main className=\"flex-1 p-6\">\n {children}\n </main>\n </Layout>\n )\n}\n\nexport default Main"
473
510
  }
474
511
  ]
475
512
  },
@@ -477,41 +514,41 @@
477
514
  "name": "layout-demo2",
478
515
  "dependencies": [
479
516
  "lucide-react",
480
- "react-resizable-panels",
481
- "react-router-dom"
517
+ "react-router-dom",
518
+ "react-resizable-panels"
482
519
  ],
483
520
  "internalDependencies": [
484
- "sidebar",
485
- "resizable",
486
521
  "skeleton",
487
522
  "main",
488
523
  "autocomplete",
489
- "constants"
524
+ "sidebar",
525
+ "constants",
526
+ "resizable"
490
527
  ],
491
528
  "files": [
492
529
  {
493
- "path": "src/components/ui/layout-demo2/constants/route.constants.tsx",
494
- "content": "import React from \"react\";\r\nimport {\r\n BookOpen,\r\n ChevronsUpDown,\r\n FormInput,\r\n Info,\r\n LayoutGrid,\r\n ListChecks,\r\n Minus,\r\n ScrollText,\r\n Settings,\r\n Square,\r\n Tag,\r\n TextCursorInput,\r\n UserCircle,\r\n} from \"lucide-react\";\r\n\r\ninterface SidebarItem {\r\n title: string;\r\n href: string;\r\n icon?: React.ReactNode;\r\n badge?: string;\r\n}\r\n\r\ninterface SidebarSection {\r\n title: string;\r\n icon?: React.ReactNode;\r\n collapsible: boolean;\r\n defaultOpen?: boolean;\r\n items: SidebarItem[];\r\n}\r\n\r\nexport const routesConfig: SidebarSection[] = [\r\n {\r\n title: \"Tổng quan\",\r\n icon: <BookOpen className=\"w-4 h-4\" />,\r\n collapsible: false,\r\n items: [\r\n {\r\n title: \"Giới thiệu\",\r\n href: \"/blocks\",\r\n icon: <Info className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Guideline & Cài đặt CLI\",\r\n href: \"/#\",\r\n icon: <Settings className=\"w-4 h-4\" />,\r\n badge: \"CLI\",\r\n },\r\n ],\r\n },\r\n {\r\n title: \"General\",\r\n icon: <LayoutGrid className=\"w-4 h-4\" />,\r\n collapsible: true,\r\n defaultOpen: true,\r\n items: [\r\n {\r\n title: \"Button\",\r\n href: \"/#\",\r\n icon: <Square className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Badge\",\r\n href: \"/#\",\r\n icon: <Tag className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Avatar\",\r\n href: \"/#\",\r\n icon: <UserCircle className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Separator\",\r\n href: \"/#\",\r\n icon: <Minus className=\"w-4 h-4\" />,\r\n },\r\n ],\r\n },\r\n {\r\n title: \"Forms\",\r\n icon: <FormInput className=\"w-4 h-4\" />,\r\n collapsible: true,\r\n defaultOpen: true,\r\n items: [\r\n {\r\n title: \"Input\",\r\n href: \"/components-page/input\",\r\n icon: <TextCursorInput className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Textarea\",\r\n href: \"/components-page/textarea\",\r\n icon: <ScrollText className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Select\",\r\n href: \"/components-page/select\",\r\n icon: <ChevronsUpDown className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Checkbox\",\r\n href: \"/components-page/checkbox\",\r\n icon: <ListChecks className=\"w-4 h-4\" />,\r\n },\r\n ],\r\n },\r\n];\r\n"
530
+ "path": "src/components/ui/layout-demo2/_components/Test2.tsx",
531
+ "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\nimport Main from \"../main\";\n\nconst ContentSkeleton = () => (\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\n {/* Header bar */}\n <div className=\"flex items-center justify-between\">\n <Skeleton className=\"h-6 w-40\" />\n <div className=\"flex gap-2\">\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n </div>\n </div>\n\n {/* Stats cards */}\n <div className=\"grid grid-cols-3 gap-4\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div key={i} className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\">\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n </div>\n ))}\n </div>\n\n {/* Table */}\n <div className=\"flex flex-col gap-2\">\n {/* Table header */}\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\n {Array.from({ length: 4 }).map((_, i) => (\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\n ))}\n </div>\n {/* Table rows */}\n {Array.from({ length: 12}).map((_, i) => (\n <div key={i} className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\">\n <div className=\"flex items-center gap-2\">\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\n <Skeleton className=\"h-3 flex-1\" />\n </div>\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\n </div>\n ))}\n </div>\n </div>\n);\n\nconst Test = () => {\n return (\n <Main>\n <div data-lenis-prevent>\n <ContentSkeleton />\n </div>\n </Main>\n );\n};\n\nexport default Test;\n"
495
532
  },
496
533
  {
497
- "path": "src/components/ui/layout-demo2/layout.tsx",
498
- "content": "'use client'\r\n\r\nimport { useCallback, useEffect, useRef } from 'react'\r\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\r\nimport {\r\n SidebarProvider,\r\n SidebarInset,\r\n useSidebar,\r\n} from '@/components/ui/sidebar/Sidebar'\r\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\r\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\r\nimport SideBar from './_layout/SideBar'\r\nimport Header from './_layout/Header'\r\n\r\ninterface Props {\r\n children: React.ReactNode\r\n}\r\n\r\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\r\n const { open, setOpen, isMobile } = useSidebar();\r\n const panelRef = useRef<PanelImperativeHandle>(null);\r\n // Prevent onResize from echoing back the programmatic change\r\n const syncingRef = useRef(false);\r\n\r\n // Keyboard toggle (Ctrl+B) → collapse / expand panel\r\n useEffect(() => {\r\n const panel = panelRef.current;\r\n if (!panel || isMobile) return;\r\n syncingRef.current = true;\r\n if (open) panel.expand();\r\n else panel.collapse();\r\n requestAnimationFrame(() => { syncingRef.current = false; });\r\n }, [open, isMobile]);\r\n\r\n // Drag-to-collapse/expand update sidebar context\r\n const handleSidebarResize = useCallback(\r\n (size: { asPercentage: number; inPixels: number }) => {\r\n if (syncingRef.current) return;\r\n // collapsedSize = \"64px\"; add a small threshold to avoid floating-point edge cases\r\n setOpen(size.inPixels > 72);\r\n },\r\n [setOpen],\r\n );\r\n\r\n const content = (\r\n <SidebarInset className=\"h-full\">\r\n <Header />\r\n <main className=\"flex-1 overflow-y-auto\">\r\n <div className=\"p-6 h-full\">\r\n <ThemeProvider defaultTheme=\"\">\r\n {children}\r\n </ThemeProvider>\r\n </div>\r\n </main>\r\n </SidebarInset>\r\n );\r\n\r\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\r\n if (isMobile) {\r\n return (\r\n <div className=\"flex h-screen w-full\">\r\n <SideBar />\r\n {content}\r\n </div>\r\n );\r\n }\r\n\r\n // ── Desktop: sidebar + handle + content as resizable panels ───\r\n return (\r\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\r\n <ResizablePanel\r\n id=\"app-sidebar\"\r\n defaultSize=\"256px\"\r\n minSize=\"160px\"\r\n maxSize=\"480px\"\r\n collapsible\r\n collapsedSize=\"64px\"\r\n groupResizeBehavior=\"preserve-pixel-size\"\r\n panelRef={panelRef}\r\n onResize={handleSidebarResize}\r\n >\r\n <SideBar />\r\n </ResizablePanel>\r\n <ResizableHandle variant=\"bar\" />\r\n\r\n\r\n <ResizablePanel>\r\n {content}\r\n </ResizablePanel>\r\n \r\n </ResizablePanelGroup>\r\n );\r\n}\r\n\r\nexport default function Layout({ children }: Props) {\r\n return (\r\n <div className=\"h-screen w-full overflow-hidden\">\r\n <SidebarProvider className=\"h-full \">\r\n <LayoutBody>{children}</LayoutBody>\r\n </SidebarProvider>\r\n </div>\r\n )\r\n}\r\n"
534
+ "path": "src/components/ui/layout-demo2/_layout/Header.tsx",
535
+ "content": "import { Button, Code, Separator, SidebarTrigger } from '@/components/ui'\nimport { Autocomplete } from '@/components/ui/autocomplete/Autocomplete'\nimport { Bell, Home, Search, Settings } from 'lucide-react'\nimport React from 'react'\nimport { Link } from 'react-router-dom'\n\nconst Header = () => {\n return (\n <header className=\"h-[60px] sticky top-0 flex items-center gap-3 border-b border-border bg-background/80 backdrop-blur-md px-4 py-2 shrink-0\">\n <SidebarTrigger />\n <Separator orientation=\"vertical\" className=\"h-4\" />\n\n {/* Breadcrumb */}\n <nav className=\"flex items-center w-full gap-1.5 text-sm min-w-0\">\n <Link\n to=\"/\"\n className=\"text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2 shrink-0\"\n >\n <Home className=\"h-3.5 w-3.5\" /><span className=\"hidden sm:inline\">Home</span>\n </Link>\n \n\n {/* Search Autocomplete */}\n <Autocomplete\n options={[]}\n placeholder=\"Tìm kiếm component...\"\n leftIcon={<Search className=\"h-4 w-4\" />}\n clearOnSelect\n // onValueChange={(href) => router.push(href)}\n emptyText=\"Không tìm thấy component\"\n className=\"ml-auto hidden md:flex md:w-[200px] lg:w-[240px]\"\n />\n\n {/* Action Buttons */}\n <div className=\"flex items-center gap-1.5 sm:gap-2 ml-auto md:ml-3 shrink-0\">\n <Link to=\"https://github.com\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"hidden sm:block\">\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\n <Code className=\"h-4 w-4\" />\n </Button>\n </Link>\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\n <Bell className=\"h-4 w-4\" />\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\n <Settings className=\"h-4 w-4\" />\n </Button>\n </div>\n </nav>\n </header>\n )\n}\n\nexport default Header"
499
536
  },
500
537
  {
501
- "path": "src/components/ui/layout-demo2/main.tsx",
502
- "content": "import React from 'react'\r\nimport Layout from './layout'\r\n\r\nconst Main = ({ children }: { children: React.ReactNode }) => {\r\n return (\r\n <Layout>\r\n {children}\r\n </Layout>\r\n )\r\n}\r\n\r\nexport default Main"
538
+ "path": "src/components/ui/layout-demo2/_layout/SideBar.tsx",
539
+ "content": "import React from \"react\";\nimport { Link, useLocation } from \"react-router-dom\";\nimport { cn } from \"@/lib/utils/cn\";\nimport {\n Sidebar,\n SidebarHeader,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n SidebarMenuCollapsible,\n SidebarMenuSubItem,\n useSidebar,\n} from \"@/components/ui/sidebar/Sidebar\";\nimport { routesConfig } from \"../constants/route.constants\";\n\nconst SideBar = () => {\n const { pathname } = useLocation();\n const { state } = useSidebar();\n const isCollapsed = state === \"collapsed\";\n return (\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\" data-lenis-prevent>\n <SidebarHeader>\n <Link to=\"/blocks\">\n <div\n className={cn(\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\n isCollapsed && \"justify-center\",\n )}\n >\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\n UI\n </span>\n </div>\n {!isCollapsed && (\n <div className=\"overflow-hidden min-w-0\">\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\n UI Library\n </p>\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\n Component Showcase\n </p>\n </div>\n )}\n </div>\n </Link>\n </SidebarHeader>\n\n <SidebarContent>\n {routesConfig.map((route, idx) => {\n const isChildActive = route.items.some(\n (item) => pathname === item.href,\n );\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\n\n return (\n <SidebarGroup key={idx}>\n {route.collapsible ? (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuCollapsible\n id={route.title}\n icon={\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {route.icon}\n </span>\n }\n label={route.title}\n defaultOpen={shouldDefaultOpen}\n isChildActive={isChildActive}\n >\n {route.items.map((item, i) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuSubItem key={i}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuSubItem>\n );\n })}\n </SidebarMenuCollapsible>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n ) : (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n {route.items.map((item, i) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuItem key={i}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuItem>\n );\n })}\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n )}\n </SidebarGroup>\n );\n })}\n </SidebarContent>\n </Sidebar>\n );\n};\n\nexport default SideBar;\n"
503
540
  },
504
541
  {
505
- "path": "src/components/ui/layout-demo2/_components/Test2.tsx",
506
- "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\r\nimport Main from \"../main\";\r\n\r\nconst ContentSkeleton = () => (\r\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\r\n {/* Header bar */}\r\n <div className=\"flex items-center justify-between\">\r\n <Skeleton className=\"h-6 w-40\" />\r\n <div className=\"flex gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n </div>\r\n </div>\r\n\r\n {/* Stats cards */}\r\n <div className=\"grid grid-cols-3 gap-4\">\r\n {Array.from({ length: 3 }).map((_, i) => (\r\n <div key={i} className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\">\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n </div>\r\n ))}\r\n </div>\r\n\r\n {/* Table */}\r\n <div className=\"flex flex-col gap-2\">\r\n {/* Table header */}\r\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\r\n {Array.from({ length: 4 }).map((_, i) => (\r\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\r\n ))}\r\n </div>\r\n {/* Table rows */}\r\n {Array.from({ length: 12}).map((_, i) => (\r\n <div key={i} className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\">\r\n <div className=\"flex items-center gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\r\n <Skeleton className=\"h-3 flex-1\" />\r\n </div>\r\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\r\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\r\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n);\r\n\r\nconst Test = () => {\r\n return (\r\n <Main>\r\n <div data-lenis-prevent>\r\n <ContentSkeleton />\r\n </div>\r\n </Main>\r\n );\r\n};\r\n\r\nexport default Test;\r\n"
542
+ "path": "src/components/ui/layout-demo2/constants/route.constants.tsx",
543
+ "content": "import React from \"react\";\nimport {\n BookOpen,\n ChevronsUpDown,\n FormInput,\n Info,\n LayoutGrid,\n ListChecks,\n Minus,\n ScrollText,\n Settings,\n Square,\n Tag,\n TextCursorInput,\n UserCircle,\n} from \"lucide-react\";\n\ninterface SidebarItem {\n title: string;\n href: string;\n icon?: React.ReactNode;\n badge?: string;\n}\n\ninterface SidebarSection {\n title: string;\n icon?: React.ReactNode;\n collapsible: boolean;\n defaultOpen?: boolean;\n items: SidebarItem[];\n}\n\nexport const routesConfig: SidebarSection[] = [\n {\n title: \"Tổng quan\",\n icon: <BookOpen className=\"w-4 h-4\" />,\n collapsible: false,\n items: [\n {\n title: \"Giới thiệu\",\n href: \"/blocks\",\n icon: <Info className=\"w-4 h-4\" />,\n },\n {\n title: \"Guideline & Cài đặt CLI\",\n href: \"/#\",\n icon: <Settings className=\"w-4 h-4\" />,\n badge: \"CLI\",\n },\n ],\n },\n {\n title: \"General\",\n icon: <LayoutGrid className=\"w-4 h-4\" />,\n collapsible: true,\n defaultOpen: true,\n items: [\n {\n title: \"Button\",\n href: \"/#\",\n icon: <Square className=\"w-4 h-4\" />,\n },\n {\n title: \"Badge\",\n href: \"/#\",\n icon: <Tag className=\"w-4 h-4\" />,\n },\n {\n title: \"Avatar\",\n href: \"/#\",\n icon: <UserCircle className=\"w-4 h-4\" />,\n },\n {\n title: \"Separator\",\n href: \"/#\",\n icon: <Minus className=\"w-4 h-4\" />,\n },\n ],\n },\n {\n title: \"Forms\",\n icon: <FormInput className=\"w-4 h-4\" />,\n collapsible: true,\n defaultOpen: true,\n items: [\n {\n title: \"Input\",\n href: \"/components-page/input\",\n icon: <TextCursorInput className=\"w-4 h-4\" />,\n },\n {\n title: \"Textarea\",\n href: \"/components-page/textarea\",\n icon: <ScrollText className=\"w-4 h-4\" />,\n },\n {\n title: \"Select\",\n href: \"/components-page/select\",\n icon: <ChevronsUpDown className=\"w-4 h-4\" />,\n },\n {\n title: \"Checkbox\",\n href: \"/components-page/checkbox\",\n icon: <ListChecks className=\"w-4 h-4\" />,\n },\n ],\n },\n];\n"
507
544
  },
508
545
  {
509
- "path": "src/components/ui/layout-demo2/_layout/Header.tsx",
510
- "content": "import { Button, Code, Separator, SidebarTrigger } from '@/components/ui'\r\nimport { Autocomplete } from '@/components/ui/autocomplete/Autocomplete'\r\nimport { Bell, Home, Search, Settings } from 'lucide-react'\r\nimport React from 'react'\r\nimport { Link } from 'react-router-dom'\r\n\r\nconst Header = () => {\r\n return (\r\n <header className=\"h-[60px] sticky top-0 flex items-center gap-3 border-b border-border bg-background/80 backdrop-blur-md px-4 py-2 shrink-0\">\r\n <SidebarTrigger />\r\n <Separator orientation=\"vertical\" className=\"h-4\" />\r\n\r\n {/* Breadcrumb */}\r\n <nav className=\"flex items-center w-full gap-1.5 text-sm min-w-0\">\r\n <Link\r\n to=\"/\"\r\n className=\"text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2 shrink-0\"\r\n >\r\n <Home className=\"h-3.5 w-3.5\" /><span className=\"hidden sm:inline\">Home</span>\r\n </Link>\r\n \r\n\r\n {/* Search Autocomplete */}\r\n <Autocomplete\r\n options={[]}\r\n placeholder=\"Tìm kiếm component...\"\r\n leftIcon={<Search className=\"h-4 w-4\" />}\r\n clearOnSelect\r\n // onValueChange={(href) => router.push(href)}\r\n emptyText=\"Không tìm thấy component\"\r\n className=\"ml-auto hidden md:flex md:w-[200px] lg:w-[240px]\"\r\n />\r\n\r\n {/* Action Buttons */}\r\n <div className=\"flex items-center gap-1.5 sm:gap-2 ml-auto md:ml-3 shrink-0\">\r\n <Link to=\"https://github.com\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"hidden sm:block\">\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\r\n <Code className=\"h-4 w-4\" />\r\n </Button>\r\n </Link>\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\r\n <Bell className=\"h-4 w-4\" />\r\n </Button>\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg \">\r\n <Settings className=\"h-4 w-4\" />\r\n </Button>\r\n </div>\r\n </nav>\r\n </header>\r\n )\r\n}\r\n\r\nexport default Header"
546
+ "path": "src/components/ui/layout-demo2/layout.tsx",
547
+ "content": "'use client'\n\nimport { useCallback, useEffect, useRef } from 'react'\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\nimport {\n SidebarProvider,\n SidebarInset,\n useSidebar,\n} from '@/components/ui/sidebar/Sidebar'\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\nimport SideBar from './_layout/SideBar'\nimport Header from './_layout/Header'\n\ninterface Props {\n children: React.ReactNode\n}\n\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\n const { open, setOpen, isMobile } = useSidebar();\n const panelRef = useRef<PanelImperativeHandle>(null);\n // Prevent onResize from echoing back the programmatic change\n const syncingRef = useRef(false);\n\n // Keyboard toggle (Ctrl+B) → collapse / expand panel\n useEffect(() => {\n const panel = panelRef.current;\n if (!panel || isMobile) return;\n syncingRef.current = true;\n if (open) panel.expand();\n else panel.collapse();\n requestAnimationFrame(() => { syncingRef.current = false; });\n }, [open, isMobile]);\n\n // Drag-to-collapse/expand update sidebar context\n const handleSidebarResize = useCallback(\n (size: { asPercentage: number; inPixels: number }) => {\n if (syncingRef.current) return;\n // collapsedSize = \"64px\"; add a small threshold to avoid floating-point edge cases\n setOpen(size.inPixels > 72);\n },\n [setOpen],\n );\n\n const content = (\n <SidebarInset className=\"h-full\">\n <Header />\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"p-6 h-full\">\n <ThemeProvider defaultTheme=\"\">\n {children}\n </ThemeProvider>\n </div>\n </main>\n </SidebarInset>\n );\n\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\n if (isMobile) {\n return (\n <div className=\"flex h-screen w-full\">\n <SideBar />\n {content}\n </div>\n );\n }\n\n // ── Desktop: sidebar + handle + content as resizable panels ───\n return (\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\n <ResizablePanel\n id=\"app-sidebar\"\n defaultSize=\"256px\"\n minSize=\"160px\"\n maxSize=\"480px\"\n collapsible\n collapsedSize=\"64px\"\n groupResizeBehavior=\"preserve-pixel-size\"\n panelRef={panelRef}\n onResize={handleSidebarResize}\n >\n <SideBar />\n </ResizablePanel>\n <ResizableHandle variant=\"bar\" />\n\n\n <ResizablePanel>\n {content}\n </ResizablePanel>\n \n </ResizablePanelGroup>\n );\n}\n\nexport default function Layout({ children }: Props) {\n return (\n <div className=\"h-screen w-full overflow-hidden\">\n <SidebarProvider className=\"h-full \">\n <LayoutBody>{children}</LayoutBody>\n </SidebarProvider>\n </div>\n )\n}\n"
511
548
  },
512
549
  {
513
- "path": "src/components/ui/layout-demo2/_layout/SideBar.tsx",
514
- "content": "import React from \"react\";\r\nimport { Link, useLocation } from \"react-router-dom\";\r\nimport { cn } from \"@/lib/utils/cn\";\r\nimport {\r\n Sidebar,\r\n SidebarHeader,\r\n SidebarContent,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSubItem,\r\n useSidebar,\r\n} from \"@/components/ui/sidebar/Sidebar\";\r\nimport { routesConfig } from \"../constants/route.constants\";\r\n\r\nconst SideBar = () => {\r\n const { pathname } = useLocation();\r\n const { state } = useSidebar();\r\n const isCollapsed = state === \"collapsed\";\r\n return (\r\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\" data-lenis-prevent>\r\n <SidebarHeader>\r\n <Link to=\"/blocks\">\r\n <div\r\n className={cn(\r\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\r\n isCollapsed && \"justify-center\",\r\n )}\r\n >\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\r\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\r\n UI\r\n </span>\r\n </div>\r\n {!isCollapsed && (\r\n <div className=\"overflow-hidden min-w-0\">\r\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\r\n UI Library\r\n </p>\r\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\r\n Component Showcase\r\n </p>\r\n </div>\r\n )}\r\n </div>\r\n </Link>\r\n </SidebarHeader>\r\n\r\n <SidebarContent>\r\n {routesConfig.map((route, idx) => {\r\n const isChildActive = route.items.some(\r\n (item) => pathname === item.href,\r\n );\r\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\r\n\r\n return (\r\n <SidebarGroup key={idx}>\r\n {route.collapsible ? (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <SidebarMenuCollapsible\r\n id={route.title}\r\n icon={\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {route.icon}\r\n </span>\r\n }\r\n label={route.title}\r\n defaultOpen={shouldDefaultOpen}\r\n isChildActive={isChildActive}\r\n >\r\n {route.items.map((item, i) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuSubItem key={i}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuSubItem>\r\n );\r\n })}\r\n </SidebarMenuCollapsible>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n ) : (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {route.items.map((item, i) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuItem key={i}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuItem>\r\n );\r\n })}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n )}\r\n </SidebarGroup>\r\n );\r\n })}\r\n </SidebarContent>\r\n </Sidebar>\r\n );\r\n};\r\n\r\nexport default SideBar;\r\n"
550
+ "path": "src/components/ui/layout-demo2/main.tsx",
551
+ "content": "import React from 'react'\nimport Layout from './layout'\n\nconst Main = ({ children }: { children: React.ReactNode }) => {\n return (\n <Layout>\n {children}\n </Layout>\n )\n}\n\nexport default Main"
515
552
  }
516
553
  ]
517
554
  },
@@ -519,41 +556,41 @@
519
556
  "name": "layout-demo3",
520
557
  "dependencies": [
521
558
  "lucide-react",
522
- "react-resizable-panels",
523
- "react-router-dom"
559
+ "react-router-dom",
560
+ "react-resizable-panels"
524
561
  ],
525
562
  "internalDependencies": [
526
- "sidebar",
527
- "resizable",
528
563
  "skeleton",
529
564
  "main",
530
565
  "autocomplete",
531
- "constants"
566
+ "sidebar",
567
+ "constants",
568
+ "resizable"
532
569
  ],
533
570
  "files": [
534
571
  {
535
- "path": "src/components/ui/layout-demo3/constants/route.constants.tsx",
536
- "content": "import React from \"react\";\r\nimport {\r\n BookOpen,\r\n ChevronsUpDown,\r\n FormInput,\r\n Info,\r\n LayoutGrid,\r\n ListChecks,\r\n Minus,\r\n ScrollText,\r\n Settings,\r\n Square,\r\n Tag,\r\n TextCursorInput,\r\n UserCircle,\r\n} from \"lucide-react\";\r\n\r\ninterface SidebarItem {\r\n title: string;\r\n href: string;\r\n icon?: React.ReactNode;\r\n badge?: string;\r\n}\r\n\r\ninterface SidebarSection {\r\n title: string;\r\n icon?: React.ReactNode;\r\n collapsible: boolean;\r\n defaultOpen?: boolean;\r\n items: SidebarItem[];\r\n}\r\n\r\nexport const routesConfig: SidebarSection[] = [\r\n {\r\n title: \"Tổng quan\",\r\n icon: <BookOpen className=\"w-4 h-4\" />,\r\n collapsible: false,\r\n items: [\r\n {\r\n title: \"Giới thiệu\",\r\n href: \"/blocks\",\r\n icon: <Info className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Guideline & Cài đặt CLI\",\r\n href: \"/#\",\r\n icon: <Settings className=\"w-4 h-4\" />,\r\n badge: \"CLI\",\r\n },\r\n ],\r\n },\r\n {\r\n title: \"General\",\r\n icon: <LayoutGrid className=\"w-4 h-4\" />,\r\n collapsible: true,\r\n defaultOpen: true,\r\n items: [\r\n {\r\n title: \"Button\",\r\n href: \"/#\",\r\n icon: <Square className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Badge\",\r\n href: \"/#\",\r\n icon: <Tag className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Avatar\",\r\n href: \"/#\",\r\n icon: <UserCircle className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Separator\",\r\n href: \"/#\",\r\n icon: <Minus className=\"w-4 h-4\" />,\r\n },\r\n ],\r\n },\r\n {\r\n title: \"Forms\",\r\n icon: <FormInput className=\"w-4 h-4\" />,\r\n collapsible: true,\r\n defaultOpen: true,\r\n items: [\r\n {\r\n title: \"Input\",\r\n href: \"/components-page/input\",\r\n icon: <TextCursorInput className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Textarea\",\r\n href: \"/components-page/textarea\",\r\n icon: <ScrollText className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Select\",\r\n href: \"/components-page/select\",\r\n icon: <ChevronsUpDown className=\"w-4 h-4\" />,\r\n },\r\n {\r\n title: \"Checkbox\",\r\n href: \"/components-page/checkbox\",\r\n icon: <ListChecks className=\"w-4 h-4\" />,\r\n },\r\n ],\r\n },\r\n];\r\n"
572
+ "path": "src/components/ui/layout-demo3/_components/Test3.tsx",
573
+ "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\nimport Main from \"../main\";\n\nconst ContentSkeleton = () => (\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\n {/* Header bar */}\n <div className=\"flex items-center justify-between\">\n <Skeleton className=\"h-6 w-40\" />\n <div className=\"flex gap-2\">\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\n </div>\n </div>\n\n {/* Stats cards */}\n <div className=\"grid grid-cols-3 gap-4\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div\n key={i}\n className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\"\n >\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\n </div>\n ))}\n </div>\n\n {/* Table */}\n <div className=\"flex flex-col gap-2\">\n {/* Table header */}\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\n {Array.from({ length: 4 }).map((_, i) => (\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\n ))}\n </div>\n {/* Table rows */}\n {Array.from({ length: 12 }).map((_, i) => (\n <div\n key={i}\n className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\"\n >\n <div className=\"flex items-center gap-2\">\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\n <Skeleton className=\"h-3 flex-1\" />\n </div>\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\n </div>\n ))}\n </div>\n </div>\n);\n\nconst Test = () => {\n return (\n <Main>\n <div data-lenis-prevent>\n <ContentSkeleton />\n </div>\n </Main>\n );\n};\n\nexport default Test;\n"
537
574
  },
538
575
  {
539
- "path": "src/components/ui/layout-demo3/layout.tsx",
540
- "content": "'use client'\r\n\r\nimport { useCallback, useEffect, useRef } from 'react'\r\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\r\nimport {\r\n SidebarProvider,\r\n SidebarInset,\r\n useSidebar,\r\n} from '@/components/ui/sidebar/Sidebar'\r\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\r\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\r\nimport SideBar from './_layout/SideBar'\r\nimport Header from './_layout/Header'\r\n\r\ninterface Props {\r\n children: React.ReactNode\r\n}\r\n\r\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\r\n const { open, setOpen, isMobile } = useSidebar();\r\n const panelRef = useRef<PanelImperativeHandle>(null);\r\n // Prevent onResize from echoing back the programmatic change\r\n const syncingRef = useRef(false);\r\n\r\n // Keyboard toggle (Ctrl+B) → collapse / expand panel\r\n useEffect(() => {\r\n const panel = panelRef.current;\r\n if (!panel || isMobile) return;\r\n syncingRef.current = true;\r\n if (open) panel.expand();\r\n else panel.collapse();\r\n requestAnimationFrame(() => { syncingRef.current = false; });\r\n }, [open, isMobile]);\r\n\r\n // Drag-to-collapse/expand update sidebar context\r\n const handleSidebarResize = useCallback(\r\n (size: { asPercentage: number; inPixels: number }) => {\r\n if (syncingRef.current) return;\r\n setOpen(size.inPixels > 8);\r\n },\r\n [setOpen],\r\n );\r\n\r\n const content = (\r\n <SidebarInset className=\"h-full\">\r\n <Header />\r\n <main className=\"flex-1 overflow-y-auto\">\r\n <div className=\"p-6 h-full\">\r\n <ThemeProvider defaultTheme=\"\">\r\n {children}\r\n </ThemeProvider>\r\n </div>\r\n </main>\r\n </SidebarInset>\r\n );\r\n\r\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\r\n if (isMobile) {\r\n return (\r\n <div className=\"flex h-screen w-full\">\r\n <SideBar />\r\n {content}\r\n </div>\r\n );\r\n }\r\n\r\n // ── Desktop: sidebar + handle + content as resizable panels ───\r\n return (\r\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\r\n <ResizablePanel\r\n id=\"app-sidebar\"\r\n defaultSize=\"256px\"\r\n minSize=\"160px\"\r\n maxSize=\"480px\"\r\n collapsible\r\n collapsedSize=\"0px\"\r\n groupResizeBehavior=\"preserve-pixel-size\"\r\n panelRef={panelRef}\r\n onResize={handleSidebarResize}\r\n >\r\n <SideBar />\r\n </ResizablePanel>\r\n {open && <ResizableHandle variant=\"bar\" />}\r\n\r\n\r\n <ResizablePanel>\r\n {content}\r\n </ResizablePanel>\r\n \r\n </ResizablePanelGroup>\r\n );\r\n}\r\n\r\nexport default function Layout({ children }: Props) {\r\n return (\r\n <div className=\"h-screen w-full overflow-hidden\">\r\n <SidebarProvider className=\"h-full \">\r\n <LayoutBody>{children}</LayoutBody>\r\n </SidebarProvider>\r\n </div>\r\n )\r\n}\r\n"
576
+ "path": "src/components/ui/layout-demo3/_layout/Header.tsx",
577
+ "content": "import { Button, Code, Separator, SidebarTrigger } from '@/components/ui'\nimport { Autocomplete } from '@/components/ui/autocomplete/Autocomplete'\nimport { Bell, Home, Search, Settings } from 'lucide-react'\nimport React from 'react'\nimport { Link } from 'react-router-dom'\n\nconst Header = () => {\n return (\n <header className=\"h-[60px] sticky top-0 flex items-center gap-3 border-b border-border bg-background/80 backdrop-blur-md px-4 py-2 shrink-0\">\n <SidebarTrigger />\n <Separator orientation=\"vertical\" className=\"h-4\" />\n\n <nav className=\"flex items-center w-full gap-1.5 text-sm min-w-0\">\n <Link\n to=\"/\"\n className=\"text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2 shrink-0\"\n >\n <Home className=\"h-3.5 w-3.5\" /><span className=\"hidden sm:inline\">Home</span>\n </Link>\n\n <Autocomplete\n options={[]}\n placeholder=\"Tìm kiếm component...\"\n leftIcon={<Search className=\"h-4 w-4\" />}\n clearOnSelect\n emptyText=\"Không tìm thấy component\"\n className=\"ml-auto hidden md:flex md:w-[200px] lg:w-[240px]\"\n />\n\n <div className=\"flex items-center gap-1.5 sm:gap-2 ml-auto md:ml-3 shrink-0\">\n <Link to=\"https://github.com\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"hidden sm:block\">\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\n <Code className=\"h-4 w-4\" />\n </Button>\n </Link>\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\n <Bell className=\"h-4 w-4\" />\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\n <Settings className=\"h-4 w-4\" />\n </Button>\n </div>\n </nav>\n </header>\n )\n}\n\nexport default Header\n"
541
578
  },
542
579
  {
543
- "path": "src/components/ui/layout-demo3/main.tsx",
544
- "content": "import React from 'react'\r\nimport Layout from './layout'\r\n\r\nconst Main = ({ children }: { children: React.ReactNode }) => {\r\n return (\r\n <Layout>\r\n {children}\r\n </Layout>\r\n )\r\n}\r\n\r\nexport default Main"
580
+ "path": "src/components/ui/layout-demo3/_layout/SideBar.tsx",
581
+ "content": "import React from \"react\";\nimport { Link, useLocation } from \"react-router-dom\";\nimport { cn } from \"@/lib/utils/cn\";\nimport {\n Sidebar,\n SidebarHeader,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n SidebarMenuCollapsible,\n SidebarMenuSubItem,\n useSidebar,\n} from \"@/components/ui/sidebar/Sidebar\";\nimport { routesConfig } from \"../constants/route.constants\";\n\nconst SideBar = () => {\n const { pathname } = useLocation();\n const { state } = useSidebar();\n const isCollapsed = state === \"collapsed\";\n return (\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\" data-lenis-prevent>\n <SidebarHeader>\n <Link to=\"/blocks\">\n <div\n className={cn(\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\n isCollapsed && \"justify-center\",\n )}\n >\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\n UI\n </span>\n </div>\n {!isCollapsed && (\n <div className=\"overflow-hidden min-w-0\">\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\n UI Library\n </p>\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\n Component Showcase\n </p>\n </div>\n )}\n </div>\n </Link>\n </SidebarHeader>\n\n <SidebarContent>\n {routesConfig.map((route, idx) => {\n const isChildActive = route.items.some(\n (item) => pathname === item.href,\n );\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\n\n return (\n <SidebarGroup key={idx}>\n {route.collapsible ? (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n <SidebarMenuItem>\n <SidebarMenuCollapsible\n id={route.title}\n icon={\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {route.icon}\n </span>\n }\n label={route.title}\n defaultOpen={shouldDefaultOpen}\n isChildActive={isChildActive}\n >\n {route.items.map((item, i) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuSubItem key={i}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuSubItem>\n );\n })}\n </SidebarMenuCollapsible>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n ) : (\n <>\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\n <SidebarGroupContent>\n <SidebarMenu>\n {route.items.map((item, i) => {\n const isActive = pathname === item.href;\n return (\n <SidebarMenuItem key={i}>\n <Link to={item.href}>\n <SidebarMenuButton\n isActive={isActive}\n tooltip={item.title}\n size=\"sm\"\n className={cn(\n isActive &&\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\n )}\n >\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n {item.icon}\n </span>\n <span className=\"truncate\">{item.title}</span>\n </SidebarMenuButton>\n </Link>\n </SidebarMenuItem>\n );\n })}\n </SidebarMenu>\n </SidebarGroupContent>\n </>\n )}\n </SidebarGroup>\n );\n })}\n </SidebarContent>\n </Sidebar>\n );\n};\n\nexport default SideBar;\n"
545
582
  },
546
583
  {
547
- "path": "src/components/ui/layout-demo3/_components/Test3.tsx",
548
- "content": "import { Skeleton } from \"@/components/ui/skeleton/Skeleton\";\r\nimport Main from \"../main\";\r\n\r\nconst ContentSkeleton = () => (\r\n <div className=\"flex flex-col gap-6 p-6 flex-1\">\r\n {/* Header bar */}\r\n <div className=\"flex items-center justify-between\">\r\n <Skeleton className=\"h-6 w-40\" />\r\n <div className=\"flex gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n <Skeleton rounded=\"full\" className=\"h-8 w-8\" />\r\n </div>\r\n </div>\r\n\r\n {/* Stats cards */}\r\n <div className=\"grid grid-cols-3 gap-4\">\r\n {Array.from({ length: 3 }).map((_, i) => (\r\n <div\r\n key={i}\r\n className=\"flex flex-col gap-2 p-4 border border-border rounded-lg\"\r\n >\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n <Skeleton className=\"h-7 w-14 md:w-20 lg:w-24\" />\r\n <Skeleton className=\"h-3 w-12 md:w-16 lg:w-20\" variant=\"muted\" />\r\n </div>\r\n ))}\r\n </div>\r\n\r\n {/* Table */}\r\n <div className=\"flex flex-col gap-2\">\r\n {/* Table header */}\r\n <div className=\"grid grid-cols-4 gap-4 px-3 py-2\">\r\n {Array.from({ length: 4 }).map((_, i) => (\r\n <Skeleton key={i} className=\"h-3 w-full\" variant=\"muted\" />\r\n ))}\r\n </div>\r\n {/* Table rows */}\r\n {Array.from({ length: 12 }).map((_, i) => (\r\n <div\r\n key={i}\r\n className=\"grid grid-cols-4 gap-4 px-3 py-3 border border-border rounded-md\"\r\n >\r\n <div className=\"flex items-center gap-2\">\r\n <Skeleton rounded=\"full\" className=\"h-6 w-6 shrink-0\" />\r\n <Skeleton className=\"h-3 flex-1\" />\r\n </div>\r\n <Skeleton className=\"h-3 w-full\" variant=\"muted\" />\r\n <Skeleton className=\"h-3 w-3/4\" variant=\"muted\" />\r\n <Skeleton rounded=\"full\" className=\"h-5 w-16\" />\r\n </div>\r\n ))}\r\n </div>\r\n </div>\r\n);\r\n\r\nconst Test = () => {\r\n return (\r\n <Main>\r\n <div data-lenis-prevent>\r\n <ContentSkeleton />\r\n </div>\r\n </Main>\r\n );\r\n};\r\n\r\nexport default Test;\r\n"
584
+ "path": "src/components/ui/layout-demo3/constants/route.constants.tsx",
585
+ "content": "import React from \"react\";\nimport {\n BookOpen,\n ChevronsUpDown,\n FormInput,\n Info,\n LayoutGrid,\n ListChecks,\n Minus,\n ScrollText,\n Settings,\n Square,\n Tag,\n TextCursorInput,\n UserCircle,\n} from \"lucide-react\";\n\ninterface SidebarItem {\n title: string;\n href: string;\n icon?: React.ReactNode;\n badge?: string;\n}\n\ninterface SidebarSection {\n title: string;\n icon?: React.ReactNode;\n collapsible: boolean;\n defaultOpen?: boolean;\n items: SidebarItem[];\n}\n\nexport const routesConfig: SidebarSection[] = [\n {\n title: \"Tổng quan\",\n icon: <BookOpen className=\"w-4 h-4\" />,\n collapsible: false,\n items: [\n {\n title: \"Giới thiệu\",\n href: \"/blocks\",\n icon: <Info className=\"w-4 h-4\" />,\n },\n {\n title: \"Guideline & Cài đặt CLI\",\n href: \"/#\",\n icon: <Settings className=\"w-4 h-4\" />,\n badge: \"CLI\",\n },\n ],\n },\n {\n title: \"General\",\n icon: <LayoutGrid className=\"w-4 h-4\" />,\n collapsible: true,\n defaultOpen: true,\n items: [\n {\n title: \"Button\",\n href: \"/#\",\n icon: <Square className=\"w-4 h-4\" />,\n },\n {\n title: \"Badge\",\n href: \"/#\",\n icon: <Tag className=\"w-4 h-4\" />,\n },\n {\n title: \"Avatar\",\n href: \"/#\",\n icon: <UserCircle className=\"w-4 h-4\" />,\n },\n {\n title: \"Separator\",\n href: \"/#\",\n icon: <Minus className=\"w-4 h-4\" />,\n },\n ],\n },\n {\n title: \"Forms\",\n icon: <FormInput className=\"w-4 h-4\" />,\n collapsible: true,\n defaultOpen: true,\n items: [\n {\n title: \"Input\",\n href: \"/components-page/input\",\n icon: <TextCursorInput className=\"w-4 h-4\" />,\n },\n {\n title: \"Textarea\",\n href: \"/components-page/textarea\",\n icon: <ScrollText className=\"w-4 h-4\" />,\n },\n {\n title: \"Select\",\n href: \"/components-page/select\",\n icon: <ChevronsUpDown className=\"w-4 h-4\" />,\n },\n {\n title: \"Checkbox\",\n href: \"/components-page/checkbox\",\n icon: <ListChecks className=\"w-4 h-4\" />,\n },\n ],\n },\n];\n"
549
586
  },
550
587
  {
551
- "path": "src/components/ui/layout-demo3/_layout/Header.tsx",
552
- "content": "import { Button, Code, Separator, SidebarTrigger } from '@/components/ui'\r\nimport { Autocomplete } from '@/components/ui/autocomplete/Autocomplete'\r\nimport { Bell, Home, Search, Settings } from 'lucide-react'\r\nimport React from 'react'\r\nimport { Link } from 'react-router-dom'\r\n\r\nconst Header = () => {\r\n return (\r\n <header className=\"h-[60px] sticky top-0 flex items-center gap-3 border-b border-border bg-background/80 backdrop-blur-md px-4 py-2 shrink-0\">\r\n <SidebarTrigger />\r\n <Separator orientation=\"vertical\" className=\"h-4\" />\r\n\r\n <nav className=\"flex items-center w-full gap-1.5 text-sm min-w-0\">\r\n <Link\r\n to=\"/\"\r\n className=\"text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2 shrink-0\"\r\n >\r\n <Home className=\"h-3.5 w-3.5\" /><span className=\"hidden sm:inline\">Home</span>\r\n </Link>\r\n\r\n <Autocomplete\r\n options={[]}\r\n placeholder=\"Tìm kiếm component...\"\r\n leftIcon={<Search className=\"h-4 w-4\" />}\r\n clearOnSelect\r\n emptyText=\"Không tìm thấy component\"\r\n className=\"ml-auto hidden md:flex md:w-[200px] lg:w-[240px]\"\r\n />\r\n\r\n <div className=\"flex items-center gap-1.5 sm:gap-2 ml-auto md:ml-3 shrink-0\">\r\n <Link to=\"https://github.com\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"hidden sm:block\">\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\r\n <Code className=\"h-4 w-4\" />\r\n </Button>\r\n </Link>\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\r\n <Bell className=\"h-4 w-4\" />\r\n </Button>\r\n <Button variant=\"outline\" size=\"icon-sm\" className=\"rounded-lg\">\r\n <Settings className=\"h-4 w-4\" />\r\n </Button>\r\n </div>\r\n </nav>\r\n </header>\r\n )\r\n}\r\n\r\nexport default Header\r\n"
588
+ "path": "src/components/ui/layout-demo3/layout.tsx",
589
+ "content": "'use client'\n\nimport { useCallback, useEffect, useRef } from 'react'\nimport type { PanelImperativeHandle } from 'react-resizable-panels'\nimport {\n SidebarProvider,\n SidebarInset,\n useSidebar,\n} from '@/components/ui/sidebar/Sidebar'\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable/Resizable'\nimport { ThemeProvider } from '@/lib/theme/ThemeProvider'\nimport SideBar from './_layout/SideBar'\nimport Header from './_layout/Header'\n\ninterface Props {\n children: React.ReactNode\n}\n\nfunction LayoutBody({ children }: { children?: React.ReactNode }) {\n const { open, setOpen, isMobile } = useSidebar();\n const panelRef = useRef<PanelImperativeHandle>(null);\n // Prevent onResize from echoing back the programmatic change\n const syncingRef = useRef(false);\n\n // Keyboard toggle (Ctrl+B) → collapse / expand panel\n useEffect(() => {\n const panel = panelRef.current;\n if (!panel || isMobile) return;\n syncingRef.current = true;\n if (open) panel.expand();\n else panel.collapse();\n requestAnimationFrame(() => { syncingRef.current = false; });\n }, [open, isMobile]);\n\n // Drag-to-collapse/expand update sidebar context\n const handleSidebarResize = useCallback(\n (size: { asPercentage: number; inPixels: number }) => {\n if (syncingRef.current) return;\n setOpen(size.inPixels > 8);\n },\n [setOpen],\n );\n\n const content = (\n <SidebarInset className=\"h-full\">\n <Header />\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"p-6 h-full\">\n <ThemeProvider defaultTheme=\"\">\n {children}\n </ThemeProvider>\n </div>\n </main>\n </SidebarInset>\n );\n\n // ── Mobile: sidebar renders as fixed overlay, no panel needed ──\n if (isMobile) {\n return (\n <div className=\"flex h-screen w-full\">\n <SideBar />\n {content}\n </div>\n );\n }\n\n // ── Desktop: sidebar + handle + content as resizable panels ───\n return (\n <ResizablePanelGroup direction=\"horizontal\" className=\"h-full w-full\">\n <ResizablePanel\n id=\"app-sidebar\"\n defaultSize=\"256px\"\n minSize=\"160px\"\n maxSize=\"480px\"\n collapsible\n collapsedSize=\"0px\"\n groupResizeBehavior=\"preserve-pixel-size\"\n panelRef={panelRef}\n onResize={handleSidebarResize}\n >\n <SideBar />\n </ResizablePanel>\n {open && <ResizableHandle variant=\"bar\" />}\n\n\n <ResizablePanel>\n {content}\n </ResizablePanel>\n \n </ResizablePanelGroup>\n );\n}\n\nexport default function Layout({ children }: Props) {\n return (\n <div className=\"h-screen w-full overflow-hidden\">\n <SidebarProvider className=\"h-full \">\n <LayoutBody>{children}</LayoutBody>\n </SidebarProvider>\n </div>\n )\n}\n"
553
590
  },
554
591
  {
555
- "path": "src/components/ui/layout-demo3/_layout/SideBar.tsx",
556
- "content": "import React from \"react\";\r\nimport { Link, useLocation } from \"react-router-dom\";\r\nimport { cn } from \"@/lib/utils/cn\";\r\nimport {\r\n Sidebar,\r\n SidebarHeader,\r\n SidebarContent,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSubItem,\r\n useSidebar,\r\n} from \"@/components/ui/sidebar/Sidebar\";\r\nimport { routesConfig } from \"../constants/route.constants\";\r\n\r\nconst SideBar = () => {\r\n const { pathname } = useLocation();\r\n const { state } = useSidebar();\r\n const isCollapsed = state === \"collapsed\";\r\n return (\r\n <Sidebar collapsible=\"icon\" variant=\"sidebar\" side=\"left\" data-lenis-prevent>\r\n <SidebarHeader>\r\n <Link to=\"/blocks\">\r\n <div\r\n className={cn(\r\n \"flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200\",\r\n isCollapsed && \"justify-center\",\r\n )}\r\n >\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm flex-none\">\r\n <span className=\"text-primary-foreground font-bold text-sm select-none\">\r\n UI\r\n </span>\r\n </div>\r\n {!isCollapsed && (\r\n <div className=\"overflow-hidden min-w-0\">\r\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">\r\n UI Library\r\n </p>\r\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">\r\n Component Showcase\r\n </p>\r\n </div>\r\n )}\r\n </div>\r\n </Link>\r\n </SidebarHeader>\r\n\r\n <SidebarContent>\r\n {routesConfig.map((route, idx) => {\r\n const isChildActive = route.items.some(\r\n (item) => pathname === item.href,\r\n );\r\n const shouldDefaultOpen = isChildActive || route.defaultOpen === true;\r\n\r\n return (\r\n <SidebarGroup key={idx}>\r\n {route.collapsible ? (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <SidebarMenuCollapsible\r\n id={route.title}\r\n icon={\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {route.icon}\r\n </span>\r\n }\r\n label={route.title}\r\n defaultOpen={shouldDefaultOpen}\r\n isChildActive={isChildActive}\r\n >\r\n {route.items.map((item, i) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuSubItem key={i}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuSubItem>\r\n );\r\n })}\r\n </SidebarMenuCollapsible>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n ) : (\r\n <>\r\n <SidebarGroupLabel>{route.title}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {route.items.map((item, i) => {\r\n const isActive = pathname === item.href;\r\n return (\r\n <SidebarMenuItem key={i}>\r\n <Link to={item.href}>\r\n <SidebarMenuButton\r\n isActive={isActive}\r\n tooltip={item.title}\r\n size=\"sm\"\r\n className={cn(\r\n isActive &&\r\n \"bg-sidebar-accent text-sidebar-accent-foreground font-semibold\",\r\n )}\r\n >\r\n <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\r\n {item.icon}\r\n </span>\r\n <span className=\"truncate\">{item.title}</span>\r\n </SidebarMenuButton>\r\n </Link>\r\n </SidebarMenuItem>\r\n );\r\n })}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </>\r\n )}\r\n </SidebarGroup>\r\n );\r\n })}\r\n </SidebarContent>\r\n </Sidebar>\r\n );\r\n};\r\n\r\nexport default SideBar;\r\n"
592
+ "path": "src/components/ui/layout-demo3/main.tsx",
593
+ "content": "import React from 'react'\nimport Layout from './layout'\n\nconst Main = ({ children }: { children: React.ReactNode }) => {\n return (\n <Layout>\n {children}\n </Layout>\n )\n}\n\nexport default Main"
557
594
  }
558
595
  ]
559
596
  },
@@ -576,7 +613,7 @@
576
613
  "files": [
577
614
  {
578
615
  "path": "src/components/ui/login-classic/LoginClassic.tsx",
579
- "content": "import * as React from 'react';\r\nimport { useForm } from 'react-hook-form';\r\nimport { zodResolver } from '@hookform/resolvers/zod';\r\nimport * as z from 'zod';\r\nimport { Mail, Lock } from 'lucide-react';\r\nimport { Form, FormField } from '@/components/ui/form/Form';\r\nimport { Input } from '@/components/ui/input/Input';\r\nimport { Button } from '@/components/ui/button/Button';\r\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\r\nimport {\r\n Card,\r\n CardContent,\r\n CardHeader,\r\n CardTitle,\r\n CardDescription,\r\n} from '@/components/ui/card/Card';\r\nimport { toast } from 'sonner';\r\n\r\nconst loginSchema = z.object({\r\n email: z.string().email('Email không hợp lệ'),\r\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\r\n rememberMe: z.boolean().optional(),\r\n});\r\n\r\ntype LoginValues = z.infer<typeof loginSchema>;\r\n\r\nexport const LoginClassic = () => {\r\n const [isLoading, setIsLoading] = React.useState(false);\r\n\r\n const form = useForm<LoginValues>({\r\n resolver: zodResolver(loginSchema),\r\n defaultValues: { email: '', password: '', rememberMe: false },\r\n });\r\n\r\n const onSubmit = async (_data: LoginValues) => {\r\n setIsLoading(true);\r\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\r\n setIsLoading(false);\r\n toast.success('Đăng nhập thành công!');\r\n };\r\n\r\n return (\r\n <Card className=\"w-full max-w-sm animate-in fade-in zoom-in-95 duration-500\">\r\n <CardHeader className=\"text-center pb-4 pt-8\">\r\n <div className=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10\">\r\n <Lock className=\"h-7 w-7 text-primary\" />\r\n </div>\r\n <CardTitle className=\"text-2xl\">Chào mừng trở lại</CardTitle>\r\n <CardDescription>Đăng nhập vào tài khoản của bạn</CardDescription>\r\n </CardHeader>\r\n\r\n <CardContent className=\"px-6 pb-8\">\r\n <Form {...form}>\r\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\r\n <FormField\r\n control={form.control}\r\n name=\"email\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Email\"\r\n type=\"email\"\r\n placeholder=\"email@example.com\"\r\n icon={<Mail className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n <FormField\r\n control={form.control}\r\n name=\"password\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Mật khẩu\"\r\n type=\"password\"\r\n placeholder=\"••••••••\"\r\n icon={<Lock className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n\r\n <div className=\"flex items-center justify-between\">\r\n <FormField\r\n control={form.control}\r\n name=\"rememberMe\"\r\n render={({ field }) => (\r\n <Checkbox\r\n label=\"Ghi nhớ đăng nhập\"\r\n checked={field.value}\r\n onCheckedChange={(checked) => field.onChange(checked === true)}\r\n />\r\n )}\r\n />\r\n <Button variant=\"link\" type=\"button\">\r\n Quên mật khẩu?\r\n </Button>\r\n </div>\r\n\r\n <Button\r\n type=\"submit\"\r\n className=\"w-full\"\r\n isLoading={isLoading}\r\n disabled={isLoading}\r\n >\r\n Đăng nhập\r\n </Button>\r\n\r\n <div className=\"relative my-2\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <span className=\"w-full border-t border-border\" />\r\n </div>\r\n <div className=\"relative flex justify-center text-xs uppercase\">\r\n <span className=\"bg-card px-2 text-muted-foreground\">Hoặc tiếp tục với</span>\r\n </div>\r\n </div>\r\n\r\n <div className=\"grid grid-cols-2 gap-3\">\r\n <Button variant=\"outline\" type=\"button\">Google</Button>\r\n <Button variant=\"outline\" type=\"button\">GitHub</Button>\r\n </div>\r\n </form>\r\n </Form>\r\n\r\n <p className=\"mt-4 text-center text-sm text-muted-foreground\">\r\n Chưa có tài khoản?{' '}\r\n <Button variant=\"link\" type=\"button\" className=\"px-0\">\r\n Đăng ký ngay\r\n </Button>\r\n </p>\r\n </CardContent>\r\n </Card>\r\n );\r\n};\r\n"
616
+ "content": "import * as React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { Mail, Lock } from 'lucide-react';\nimport { Form, FormField } from '@/components/ui/form/Form';\nimport { Input } from '@/components/ui/input/Input';\nimport { Button } from '@/components/ui/button/Button';\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\nimport {\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n CardDescription,\n} from '@/components/ui/card/Card';\nimport { toast } from 'sonner';\n\nconst loginSchema = z.object({\n email: z.string().email('Email không hợp lệ'),\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\n rememberMe: z.boolean().optional(),\n});\n\ntype LoginValues = z.infer<typeof loginSchema>;\n\nexport const LoginClassic = () => {\n const [isLoading, setIsLoading] = React.useState(false);\n\n const form = useForm<LoginValues>({\n resolver: zodResolver(loginSchema),\n defaultValues: { email: '', password: '', rememberMe: false },\n });\n\n const onSubmit = async (_data: LoginValues) => {\n setIsLoading(true);\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\n setIsLoading(false);\n toast.success('Đăng nhập thành công!');\n };\n\n return (\n <Card className=\"w-full max-w-sm animate-in fade-in zoom-in-95 duration-500\">\n <CardHeader className=\"text-center pb-4 pt-8\">\n <div className=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10\">\n <Lock className=\"h-7 w-7 text-primary\" />\n </div>\n <CardTitle className=\"text-2xl\">Chào mừng trở lại</CardTitle>\n <CardDescription>Đăng nhập vào tài khoản của bạn</CardDescription>\n </CardHeader>\n\n <CardContent className=\"px-6 pb-8\">\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n <FormField\n control={form.control}\n name=\"email\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Email\"\n type=\"email\"\n placeholder=\"email@example.com\"\n icon={<Mail className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n <FormField\n control={form.control}\n name=\"password\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Mật khẩu\"\n type=\"password\"\n placeholder=\"••••••••\"\n icon={<Lock className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n\n <div className=\"flex items-center justify-between\">\n <FormField\n control={form.control}\n name=\"rememberMe\"\n render={({ field }) => (\n <Checkbox\n label=\"Ghi nhớ đăng nhập\"\n checked={field.value}\n onCheckedChange={(checked) => field.onChange(checked === true)}\n />\n )}\n />\n <Button variant=\"link\" type=\"button\">\n Quên mật khẩu?\n </Button>\n </div>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n isLoading={isLoading}\n disabled={isLoading}\n >\n Đăng nhập\n </Button>\n\n <div className=\"relative my-2\">\n <div className=\"absolute inset-0 flex items-center\">\n <span className=\"w-full border-t border-border\" />\n </div>\n <div className=\"relative flex justify-center text-xs uppercase\">\n <span className=\"bg-card px-2 text-muted-foreground\">Hoặc tiếp tục với</span>\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-3\">\n <Button variant=\"outline\" type=\"button\">Google</Button>\n <Button variant=\"outline\" type=\"button\">GitHub</Button>\n </div>\n </form>\n </Form>\n\n <p className=\"mt-4 text-center text-sm text-muted-foreground\">\n Chưa có tài khoản?{' '}\n <Button variant=\"link\" type=\"button\" className=\"px-0\">\n Đăng ký ngay\n </Button>\n </p>\n </CardContent>\n </Card>\n );\n};\n"
580
617
  }
581
618
  ]
582
619
  },
@@ -598,7 +635,7 @@
598
635
  "files": [
599
636
  {
600
637
  "path": "src/components/ui/login-fullscreen/LoginFullScreen.tsx",
601
- "content": "\r\nimport * as React from 'react';\r\nimport { useForm } from 'react-hook-form';\r\nimport { zodResolver } from '@hookform/resolvers/zod';\r\nimport * as z from 'zod';\r\nimport { Mail, Lock, Quote } from 'lucide-react';\r\nimport { Form, FormField } from '@/components/ui/form/Form';\r\nimport { Input } from '@/components/ui/input/Input';\r\nimport { Button } from '@/components/ui/button/Button';\r\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\r\nimport { toast } from 'sonner';\r\n\r\nconst loginSchema = z.object({\r\n email: z.string().email('Email không hợp lệ'),\r\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\r\n rememberMe: z.boolean().optional(),\r\n});\r\n\r\ntype LoginValues = z.infer<typeof loginSchema>;\r\n\r\nexport const LoginFullScreen = () => {\r\n const [isLoading, setIsLoading] = React.useState(false);\r\n\r\n const form = useForm<LoginValues>({\r\n resolver: zodResolver(loginSchema),\r\n defaultValues: { email: '', password: '', rememberMe: false },\r\n });\r\n\r\n const onSubmit = async (_data: LoginValues) => {\r\n setIsLoading(true);\r\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\r\n setIsLoading(false);\r\n toast.success('Đăng nhập thành công!');\r\n };\r\n\r\n return (\r\n <div className=\"h-screen w-full flex\">\r\n\r\n {/* ── Left: Color panel ─────────────────────────────────── */}\r\n <div className=\"hidden md:flex w-[60%] flex-col justify-between p-12 bg-gradient-to-br from-primary via-primary/90 to-violet-700 relative overflow-hidden\">\r\n {/* Decorative circles */}\r\n <div className=\"absolute -top-24 -left-24 w-96 h-96 bg-white/5 rounded-full\" />\r\n <div className=\"absolute top-1/2 -right-32 w-72 h-72 bg-white/5 rounded-full\" />\r\n <div className=\"absolute -bottom-16 left-1/4 w-56 h-56 bg-white/5 rounded-full\" />\r\n\r\n {/* Logo */}\r\n <div className=\"relative flex items-center gap-3\">\r\n <div className=\"w-9 h-9 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center\">\r\n <span className=\"text-white font-bold text-sm select-none\">UI</span>\r\n </div>\r\n <span className=\"text-white font-semibold text-lg tracking-tight\">BasuiCN</span>\r\n </div>\r\n\r\n {/* Quote */}\r\n <div className=\"relative space-y-5\">\r\n <Quote className=\"w-10 h-10 text-white/30\" />\r\n <p className=\"text-white text-2xl font-medium leading-relaxed max-w-xs\">\r\n Công cụ tốt nhất là thứ giúp bạn làm việc nhanh hơn, không phải phức tạp hơn.\r\n </p>\r\n <div className=\"flex items-center gap-3 pt-2\">\r\n <div className=\"w-10 h-10 rounded-full bg-white/20 flex items-center justify-center text-white text-sm font-bold\">\r\n NT\r\n </div>\r\n <div>\r\n <p className=\"text-white text-sm font-semibold\">Nguyễn Trung</p>\r\n <p className=\"text-white/50 text-xs\">Product Lead · BasuiCN</p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n <p className=\"relative text-white/30 text-xs\">© 2026 BasuiCN. All rights reserved.</p>\r\n </div>\r\n\r\n {/* ── Right: Form panel ─────────────────────────────────── */}\r\n <div className=\"flex-1 flex flex-col bg-background overflow-y-auto\">\r\n {/* Top bar */}\r\n <div className=\"flex items-center justify-between p-8\">\r\n {/* Mobile logo */}\r\n <div className=\"flex items-center gap-2 md:invisible\">\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center\">\r\n <span className=\"text-primary-foreground font-bold text-xs select-none\">UI</span>\r\n </div>\r\n <span className=\"font-semibold text-foreground text-sm\">BasuiCN</span>\r\n </div>\r\n <Button variant=\"ghost\" size=\"sm\" className=\"text-sm text-muted-foreground ml-auto\">\r\n Chưa có tài khoản?&nbsp;\r\n <span className=\"text-primary font-semibold\">Đăng ký</span>\r\n </Button>\r\n </div>\r\n\r\n {/* Centered form */}\r\n <div className=\"flex-1 flex items-center justify-center px-8 pb-12\">\r\n <div className=\"w-full max-w-sm space-y-8\">\r\n <div className=\"space-y-1\">\r\n <h1 className=\"text-3xl font-bold text-foreground tracking-tight\">Chào mừng trở lại</h1>\r\n <p className=\"text-muted-foreground text-sm\">Đăng nhập để tiếp tục sử dụng dịch vụ</p>\r\n </div>\r\n\r\n <Form {...form}>\r\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-5\">\r\n <FormField\r\n control={form.control}\r\n name=\"email\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Email\"\r\n type=\"email\"\r\n placeholder=\"email@example.com\"\r\n icon={<Mail className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n <FormField\r\n control={form.control}\r\n name=\"password\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Mật khẩu\"\r\n type=\"password\"\r\n placeholder=\"••••••••\"\r\n icon={<Lock className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n\r\n <div className=\"flex items-center justify-between\">\r\n <FormField\r\n control={form.control}\r\n name=\"rememberMe\"\r\n render={({ field }) => (\r\n <Checkbox\r\n label=\"Ghi nhớ đăng nhập\"\r\n checked={field.value}\r\n onCheckedChange={(checked) => field.onChange(checked === true)}\r\n />\r\n )}\r\n />\r\n <Button variant=\"link\" type=\"button\" className=\"px-0 text-sm\">\r\n Quên mật khẩu?\r\n </Button>\r\n </div>\r\n\r\n <Button type=\"submit\" className=\"w-full\" size=\"lg\" isLoading={isLoading} disabled={isLoading}>\r\n Đăng nhập\r\n </Button>\r\n\r\n <div className=\"relative\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <span className=\"w-full border-t border-border\" />\r\n </div>\r\n <div className=\"relative flex justify-center text-xs uppercase\">\r\n <span className=\"bg-background px-2 text-muted-foreground\">Hoặc tiếp tục với</span>\r\n </div>\r\n </div>\r\n\r\n <div className=\"grid grid-cols-2 gap-3\">\r\n <Button variant=\"outline\" type=\"button\" className=\"w-full\">Google</Button>\r\n <Button variant=\"outline\" type=\"button\" className=\"w-full\">GitHub</Button>\r\n </div>\r\n </form>\r\n </Form>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n </div>\r\n );\r\n};\r\n\r\nexport default LoginFullScreen;\r\n"
638
+ "content": "\nimport * as React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { Mail, Lock, Quote } from 'lucide-react';\nimport { Form, FormField } from '@/components/ui/form/Form';\nimport { Input } from '@/components/ui/input/Input';\nimport { Button } from '@/components/ui/button/Button';\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\nimport { toast } from 'sonner';\n\nconst loginSchema = z.object({\n email: z.string().email('Email không hợp lệ'),\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\n rememberMe: z.boolean().optional(),\n});\n\ntype LoginValues = z.infer<typeof loginSchema>;\n\nexport const LoginFullScreen = () => {\n const [isLoading, setIsLoading] = React.useState(false);\n\n const form = useForm<LoginValues>({\n resolver: zodResolver(loginSchema),\n defaultValues: { email: '', password: '', rememberMe: false },\n });\n\n const onSubmit = async (_data: LoginValues) => {\n setIsLoading(true);\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\n setIsLoading(false);\n toast.success('Đăng nhập thành công!');\n };\n\n return (\n <div className=\"h-screen w-full flex\">\n\n {/* ── Left: Color panel ─────────────────────────────────── */}\n <div className=\"hidden md:flex w-[60%] flex-col justify-between p-12 bg-gradient-to-br from-primary via-primary/90 to-violet-700 relative overflow-hidden\">\n {/* Decorative circles */}\n <div className=\"absolute -top-24 -left-24 w-96 h-96 bg-white/5 rounded-full\" />\n <div className=\"absolute top-1/2 -right-32 w-72 h-72 bg-white/5 rounded-full\" />\n <div className=\"absolute -bottom-16 left-1/4 w-56 h-56 bg-white/5 rounded-full\" />\n\n {/* Logo */}\n <div className=\"relative flex items-center gap-3\">\n <div className=\"w-9 h-9 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center\">\n <span className=\"text-white font-bold text-sm select-none\">UI</span>\n </div>\n <span className=\"text-white font-semibold text-lg tracking-tight\">BasuiCN</span>\n </div>\n\n {/* Quote */}\n <div className=\"relative space-y-5\">\n <Quote className=\"w-10 h-10 text-white/30\" />\n <p className=\"text-white text-2xl font-medium leading-relaxed max-w-xs\">\n Công cụ tốt nhất là thứ giúp bạn làm việc nhanh hơn, không phải phức tạp hơn.\n </p>\n <div className=\"flex items-center gap-3 pt-2\">\n <div className=\"w-10 h-10 rounded-full bg-white/20 flex items-center justify-center text-white text-sm font-bold\">\n NT\n </div>\n <div>\n <p className=\"text-white text-sm font-semibold\">Nguyễn Trung</p>\n <p className=\"text-white/50 text-xs\">Product Lead · BasuiCN</p>\n </div>\n </div>\n </div>\n\n <p className=\"relative text-white/30 text-xs\">© 2026 BasuiCN. All rights reserved.</p>\n </div>\n\n {/* ── Right: Form panel ─────────────────────────────────── */}\n <div className=\"flex-1 flex flex-col bg-background overflow-y-auto\">\n {/* Top bar */}\n <div className=\"flex items-center justify-between p-8\">\n {/* Mobile logo */}\n <div className=\"flex items-center gap-2 md:invisible\">\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center\">\n <span className=\"text-primary-foreground font-bold text-xs select-none\">UI</span>\n </div>\n <span className=\"font-semibold text-foreground text-sm\">BasuiCN</span>\n </div>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-sm text-muted-foreground ml-auto\">\n Chưa có tài khoản?&nbsp;\n <span className=\"text-primary font-semibold\">Đăng ký</span>\n </Button>\n </div>\n\n {/* Centered form */}\n <div className=\"flex-1 flex items-center justify-center px-8 pb-12\">\n <div className=\"w-full max-w-sm space-y-8\">\n <div className=\"space-y-1\">\n <h1 className=\"text-3xl font-bold text-foreground tracking-tight\">Chào mừng trở lại</h1>\n <p className=\"text-muted-foreground text-sm\">Đăng nhập để tiếp tục sử dụng dịch vụ</p>\n </div>\n\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-5\">\n <FormField\n control={form.control}\n name=\"email\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Email\"\n type=\"email\"\n placeholder=\"email@example.com\"\n icon={<Mail className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n <FormField\n control={form.control}\n name=\"password\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Mật khẩu\"\n type=\"password\"\n placeholder=\"••••••••\"\n icon={<Lock className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n\n <div className=\"flex items-center justify-between\">\n <FormField\n control={form.control}\n name=\"rememberMe\"\n render={({ field }) => (\n <Checkbox\n label=\"Ghi nhớ đăng nhập\"\n checked={field.value}\n onCheckedChange={(checked) => field.onChange(checked === true)}\n />\n )}\n />\n <Button variant=\"link\" type=\"button\" className=\"px-0 text-sm\">\n Quên mật khẩu?\n </Button>\n </div>\n\n <Button type=\"submit\" className=\"w-full\" size=\"lg\" isLoading={isLoading} disabled={isLoading}>\n Đăng nhập\n </Button>\n\n <div className=\"relative\">\n <div className=\"absolute inset-0 flex items-center\">\n <span className=\"w-full border-t border-border\" />\n </div>\n <div className=\"relative flex justify-center text-xs uppercase\">\n <span className=\"bg-background px-2 text-muted-foreground\">Hoặc tiếp tục với</span>\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-3\">\n <Button variant=\"outline\" type=\"button\" className=\"w-full\">Google</Button>\n <Button variant=\"outline\" type=\"button\" className=\"w-full\">GitHub</Button>\n </div>\n </form>\n </Form>\n </div>\n </div>\n </div>\n\n </div>\n );\n};\n\nexport default LoginFullScreen;\n"
602
639
  }
603
640
  ]
604
641
  },
@@ -620,7 +657,7 @@
620
657
  "files": [
621
658
  {
622
659
  "path": "src/components/ui/login-glass/LoginGlass.tsx",
623
- "content": "import * as React from 'react';\r\nimport { useForm } from 'react-hook-form';\r\nimport { zodResolver } from '@hookform/resolvers/zod';\r\nimport * as z from 'zod';\r\nimport { Mail, Lock, Sparkles } from 'lucide-react';\r\nimport { Form, FormField } from '@/components/ui/form/Form';\r\nimport { Input } from '@/components/ui/input/Input';\r\nimport { Button } from '@/components/ui/button/Button';\r\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\r\nimport { toast } from 'sonner';\r\n\r\nconst loginSchema = z.object({\r\n email: z.string().email('Email không hợp lệ'),\r\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\r\n rememberMe: z.boolean().optional(),\r\n});\r\n\r\ntype LoginValues = z.infer<typeof loginSchema>;\r\n\r\nconst glassInputClass =\r\n 'bg-white/10 border-white/20 text-white placeholder:text-white/40 focus:border-violet-400';\r\n\r\nexport const LoginGlass = () => {\r\n const [isLoading, setIsLoading] = React.useState(false);\r\n\r\n const form = useForm<LoginValues>({\r\n resolver: zodResolver(loginSchema),\r\n defaultValues: { email: '', password: '', rememberMe: false },\r\n });\r\n\r\n const onSubmit = async (_data: LoginValues) => {\r\n setIsLoading(true);\r\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\r\n setIsLoading(false);\r\n toast.success('Đăng nhập thành công!');\r\n };\r\n\r\n return (\r\n <div className=\"relative w-full max-w-sm overflow-hidden rounded-2xl p-px bg-gradient-to-br from-violet-500/30 via-indigo-500/20 to-transparent\">\r\n {/* Gradient backdrop */}\r\n <div className=\"absolute inset-0 -z-10 rounded-2xl bg-gradient-to-br from-violet-900 via-indigo-900 to-slate-900\" />\r\n <div className=\"absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top_right,_rgba(139,92,246,0.3),transparent_60%)]\" />\r\n\r\n <div className=\"relative rounded-2xl bg-white/5 backdrop-blur-xl border border-white/10 p-8 space-y-6 animate-in fade-in zoom-in-95 duration-500\">\r\n {/* Header */}\r\n <div className=\"text-center\">\r\n <div className=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 border border-white/20\">\r\n <Sparkles className=\"h-7 w-7 text-violet-300\" />\r\n </div>\r\n <h2 className=\"text-2xl font-bold text-white\">Đăng nhập</h2>\r\n <p className=\"text-white/50 mt-1 text-sm\">Truy cập vào tài khoản của bạn</p>\r\n </div>\r\n\r\n {/* Override label & icon colours for dark background */}\r\n <div className=\"[&_label]:!text-white/70 [&_.text-muted-foreground]:!text-white/40\">\r\n <Form {...form}>\r\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\r\n <FormField\r\n control={form.control}\r\n name=\"email\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Email\"\r\n type=\"email\"\r\n placeholder=\"email@example.com\"\r\n icon={<Mail className=\"w-4 h-4 text-white/50\" />}\r\n error={fieldState.error?.message}\r\n className={glassInputClass}\r\n {...field}\r\n />\r\n )}\r\n />\r\n <FormField\r\n control={form.control}\r\n name=\"password\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Mật khẩu\"\r\n type=\"password\"\r\n placeholder=\"••••••••\"\r\n icon={<Lock className=\"w-4 h-4 text-white/50\" />}\r\n error={fieldState.error?.message}\r\n className={glassInputClass}\r\n {...field}\r\n />\r\n )}\r\n />\r\n\r\n <div className=\"flex items-center justify-between\">\r\n <FormField\r\n control={form.control}\r\n name=\"rememberMe\"\r\n render={({ field }) => (\r\n <div className=\"[&_label]:!text-white/60\">\r\n <Checkbox\r\n label=\"Ghi nhớ đăng nhập\"\r\n checked={field.value}\r\n onCheckedChange={(checked) => field.onChange(checked === true)}\r\n />\r\n </div>\r\n )}\r\n />\r\n <Button\r\n variant=\"link\"\r\n type=\"button\"\r\n className=\"text-violet-300 hover:text-violet-100 px-0\"\r\n >\r\n Quên mật khẩu?\r\n </Button>\r\n </div>\r\n\r\n <Button\r\n type=\"submit\"\r\n variant=\"glass\"\r\n className=\"w-full\"\r\n isLoading={isLoading}\r\n disabled={isLoading}\r\n >\r\n Đăng nhập\r\n </Button>\r\n </form>\r\n </Form>\r\n </div>\r\n\r\n <p className=\"text-center text-sm text-white/40\">\r\n Chưa có tài khoản?{' '}\r\n <Button\r\n variant=\"link\"\r\n type=\"button\"\r\n className=\"text-violet-300 px-0 hover:text-violet-100\"\r\n >\r\n Đăng ký ngay\r\n </Button>\r\n </p>\r\n </div>\r\n </div>\r\n );\r\n};\r\n"
660
+ "content": "import * as React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { Mail, Lock, Sparkles } from 'lucide-react';\nimport { Form, FormField } from '@/components/ui/form/Form';\nimport { Input } from '@/components/ui/input/Input';\nimport { Button } from '@/components/ui/button/Button';\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\nimport { toast } from 'sonner';\n\nconst loginSchema = z.object({\n email: z.string().email('Email không hợp lệ'),\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\n rememberMe: z.boolean().optional(),\n});\n\ntype LoginValues = z.infer<typeof loginSchema>;\n\nconst glassInputClass =\n 'bg-white/10 border-white/20 text-white placeholder:text-white/40 focus:border-violet-400';\n\nexport const LoginGlass = () => {\n const [isLoading, setIsLoading] = React.useState(false);\n\n const form = useForm<LoginValues>({\n resolver: zodResolver(loginSchema),\n defaultValues: { email: '', password: '', rememberMe: false },\n });\n\n const onSubmit = async (_data: LoginValues) => {\n setIsLoading(true);\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\n setIsLoading(false);\n toast.success('Đăng nhập thành công!');\n };\n\n return (\n <div className=\"relative w-full max-w-sm overflow-hidden rounded-2xl p-px bg-gradient-to-br from-violet-500/30 via-indigo-500/20 to-transparent\">\n {/* Gradient backdrop */}\n <div className=\"absolute inset-0 -z-10 rounded-2xl bg-gradient-to-br from-violet-900 via-indigo-900 to-slate-900\" />\n <div className=\"absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top_right,_rgba(139,92,246,0.3),transparent_60%)]\" />\n\n <div className=\"relative rounded-2xl bg-white/5 backdrop-blur-xl border border-white/10 p-8 space-y-6 animate-in fade-in zoom-in-95 duration-500\">\n {/* Header */}\n <div className=\"text-center\">\n <div className=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 border border-white/20\">\n <Sparkles className=\"h-7 w-7 text-violet-300\" />\n </div>\n <h2 className=\"text-2xl font-bold text-white\">Đăng nhập</h2>\n <p className=\"text-white/50 mt-1 text-sm\">Truy cập vào tài khoản của bạn</p>\n </div>\n\n {/* Override label & icon colours for dark background */}\n <div className=\"[&_label]:!text-white/70 [&_.text-muted-foreground]:!text-white/40\">\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n <FormField\n control={form.control}\n name=\"email\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Email\"\n type=\"email\"\n placeholder=\"email@example.com\"\n icon={<Mail className=\"w-4 h-4 text-white/50\" />}\n error={fieldState.error?.message}\n className={glassInputClass}\n {...field}\n />\n )}\n />\n <FormField\n control={form.control}\n name=\"password\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Mật khẩu\"\n type=\"password\"\n placeholder=\"••••••••\"\n icon={<Lock className=\"w-4 h-4 text-white/50\" />}\n error={fieldState.error?.message}\n className={glassInputClass}\n {...field}\n />\n )}\n />\n\n <div className=\"flex items-center justify-between\">\n <FormField\n control={form.control}\n name=\"rememberMe\"\n render={({ field }) => (\n <div className=\"[&_label]:!text-white/60\">\n <Checkbox\n label=\"Ghi nhớ đăng nhập\"\n checked={field.value}\n onCheckedChange={(checked) => field.onChange(checked === true)}\n />\n </div>\n )}\n />\n <Button\n variant=\"link\"\n type=\"button\"\n className=\"text-violet-300 hover:text-violet-100 px-0\"\n >\n Quên mật khẩu?\n </Button>\n </div>\n\n <Button\n type=\"submit\"\n variant=\"glass\"\n className=\"w-full\"\n isLoading={isLoading}\n disabled={isLoading}\n >\n Đăng nhập\n </Button>\n </form>\n </Form>\n </div>\n\n <p className=\"text-center text-sm text-white/40\">\n Chưa có tài khoản?{' '}\n <Button\n variant=\"link\"\n type=\"button\"\n className=\"text-violet-300 px-0 hover:text-violet-100\"\n >\n Đăng ký ngay\n </Button>\n </p>\n </div>\n </div>\n );\n};\n"
624
661
  }
625
662
  ]
626
663
  },
@@ -641,7 +678,7 @@
641
678
  "files": [
642
679
  {
643
680
  "path": "src/components/ui/login-minimal/LoginMinimal.tsx",
644
- "content": "import * as React from 'react';\r\nimport { useForm } from 'react-hook-form';\r\nimport { zodResolver } from '@hookform/resolvers/zod';\r\nimport * as z from 'zod';\r\nimport { Mail, Lock, ArrowRight } from 'lucide-react';\r\nimport { Form, FormField } from '@/components/ui/form/Form';\r\nimport { Input } from '@/components/ui/input/Input';\r\nimport { Button } from '@/components/ui/button/Button';\r\nimport { toast } from 'sonner';\r\n\r\nconst loginSchema = z.object({\r\n email: z.string().email('Email không hợp lệ'),\r\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\r\n});\r\n\r\ntype LoginValues = z.infer<typeof loginSchema>;\r\n\r\nexport const LoginMinimal = () => {\r\n const [isLoading, setIsLoading] = React.useState(false);\r\n\r\n const form = useForm<LoginValues>({\r\n resolver: zodResolver(loginSchema),\r\n defaultValues: { email: '', password: '' },\r\n });\r\n\r\n const onSubmit = async (_data: LoginValues) => {\r\n setIsLoading(true);\r\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\r\n setIsLoading(false);\r\n toast.success('Đăng nhập thành công!');\r\n };\r\n\r\n return (\r\n <div className=\"w-full max-w-sm space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\r\n <div>\r\n <p className=\"text-sm font-medium text-primary uppercase tracking-widest mb-1\">\r\n Chào mừng\r\n </p>\r\n <h2 className=\"text-3xl font-bold tracking-tight text-foreground\">\r\n Đăng nhập\r\n </h2>\r\n <p className=\"mt-1 text-muted-foreground text-sm\">\r\n Nhập thông tin để tiếp tục hành trình\r\n </p>\r\n </div>\r\n\r\n <Form {...form}>\r\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-6\">\r\n <FormField\r\n control={form.control}\r\n name=\"email\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Email\"\r\n type=\"email\"\r\n placeholder=\"email@example.com\"\r\n variant=\"flushed\"\r\n icon={<Mail className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n <FormField\r\n control={form.control}\r\n name=\"password\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Mật khẩu\"\r\n type=\"password\"\r\n placeholder=\"••••••••\"\r\n variant=\"flushed\"\r\n icon={<Lock className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n\r\n <div className=\"flex justify-end -mt-2\">\r\n <Button variant=\"link\" type=\"button\" className=\"text-xs px-0\">\r\n Quên mật khẩu?\r\n </Button>\r\n </div>\r\n\r\n <Button\r\n type=\"submit\"\r\n className=\"w-full\"\r\n isLoading={isLoading}\r\n disabled={isLoading}\r\n rightIcon={!isLoading ? <ArrowRight className=\"w-4 h-4\" /> : undefined}\r\n >\r\n Tiếp tục\r\n </Button>\r\n </form>\r\n </Form>\r\n\r\n <div className=\"flex items-center gap-3 text-sm text-muted-foreground\">\r\n <span className=\"h-px flex-1 bg-border\" />\r\n <span>hoặc</span>\r\n <span className=\"h-px flex-1 bg-border\" />\r\n </div>\r\n\r\n <p className=\"text-center text-sm text-muted-foreground\">\r\n Chưa có tài khoản?{' '}\r\n <Button variant=\"link\" type=\"button\" className=\"px-0 font-semibold\">\r\n Đăng ký ngay\r\n </Button>\r\n </p>\r\n </div>\r\n );\r\n};\r\n"
681
+ "content": "import * as React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { Mail, Lock, ArrowRight } from 'lucide-react';\nimport { Form, FormField } from '@/components/ui/form/Form';\nimport { Input } from '@/components/ui/input/Input';\nimport { Button } from '@/components/ui/button/Button';\nimport { toast } from 'sonner';\n\nconst loginSchema = z.object({\n email: z.string().email('Email không hợp lệ'),\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\n});\n\ntype LoginValues = z.infer<typeof loginSchema>;\n\nexport const LoginMinimal = () => {\n const [isLoading, setIsLoading] = React.useState(false);\n\n const form = useForm<LoginValues>({\n resolver: zodResolver(loginSchema),\n defaultValues: { email: '', password: '' },\n });\n\n const onSubmit = async (_data: LoginValues) => {\n setIsLoading(true);\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\n setIsLoading(false);\n toast.success('Đăng nhập thành công!');\n };\n\n return (\n <div className=\"w-full max-w-sm space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500\">\n <div>\n <p className=\"text-sm font-medium text-primary uppercase tracking-widest mb-1\">\n Chào mừng\n </p>\n <h2 className=\"text-3xl font-bold tracking-tight text-foreground\">\n Đăng nhập\n </h2>\n <p className=\"mt-1 text-muted-foreground text-sm\">\n Nhập thông tin để tiếp tục hành trình\n </p>\n </div>\n\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-6\">\n <FormField\n control={form.control}\n name=\"email\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Email\"\n type=\"email\"\n placeholder=\"email@example.com\"\n variant=\"flushed\"\n icon={<Mail className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n <FormField\n control={form.control}\n name=\"password\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Mật khẩu\"\n type=\"password\"\n placeholder=\"••••••••\"\n variant=\"flushed\"\n icon={<Lock className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n\n <div className=\"flex justify-end -mt-2\">\n <Button variant=\"link\" type=\"button\" className=\"text-xs px-0\">\n Quên mật khẩu?\n </Button>\n </div>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n isLoading={isLoading}\n disabled={isLoading}\n rightIcon={!isLoading ? <ArrowRight className=\"w-4 h-4\" /> : undefined}\n >\n Tiếp tục\n </Button>\n </form>\n </Form>\n\n <div className=\"flex items-center gap-3 text-sm text-muted-foreground\">\n <span className=\"h-px flex-1 bg-border\" />\n <span>hoặc</span>\n <span className=\"h-px flex-1 bg-border\" />\n </div>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n Chưa có tài khoản?{' '}\n <Button variant=\"link\" type=\"button\" className=\"px-0 font-semibold\">\n Đăng ký ngay\n </Button>\n </p>\n </div>\n );\n};\n"
645
682
  }
646
683
  ]
647
684
  },
@@ -663,7 +700,7 @@
663
700
  "files": [
664
701
  {
665
702
  "path": "src/components/ui/login-split/LoginSplit.tsx",
666
- "content": "import * as React from 'react';\r\nimport { useForm } from 'react-hook-form';\r\nimport { zodResolver } from '@hookform/resolvers/zod';\r\nimport * as z from 'zod';\r\nimport { Mail, Lock, BadgeCheck } from 'lucide-react';\r\nimport { Form, FormField } from '@/components/ui/form/Form';\r\nimport { Input } from '@/components/ui/input/Input';\r\nimport { Button } from '@/components/ui/button/Button';\r\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\r\nimport { toast } from 'sonner';\r\n\r\nconst loginSchema = z.object({\r\n email: z.string().email('Email không hợp lệ'),\r\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\r\n rememberMe: z.boolean().optional(),\r\n});\r\n\r\ntype LoginValues = z.infer<typeof loginSchema>;\r\n\r\nconst features = [\r\n 'Bảo mật 2 lớp (2FA)',\r\n 'Đồng bộ đa thiết bị',\r\n 'Dashboard phân tích thời gian thực',\r\n 'Hỗ trợ 24/7',\r\n];\r\n\r\nexport const LoginSplit = () => {\r\n const [isLoading, setIsLoading] = React.useState(false);\r\n\r\n const form = useForm<LoginValues>({\r\n resolver: zodResolver(loginSchema),\r\n defaultValues: { email: '', password: '', rememberMe: false },\r\n });\r\n\r\n const onSubmit = async (_data: LoginValues) => {\r\n setIsLoading(true);\r\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\r\n setIsLoading(false);\r\n toast.success('Đăng nhập thành công!');\r\n };\r\n\r\n return (\r\n <div className=\"w-full overflow-hidden rounded-2xl border border-border shadow-lg animate-in fade-in zoom-in-95 duration-500\">\r\n <div className=\"grid grid-cols-1 md:grid-cols-2 min-h-[480px]\">\r\n {/* Left: Branding */}\r\n <div className=\"relative hidden md:flex flex-col justify-between bg-gradient-to-br from-primary via-primary/90 to-primary/70 p-10 text-primary-foreground overflow-hidden\">\r\n <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(255,255,255,0.1),transparent_60%)]\" />\r\n\r\n <div className=\"relative flex items-center gap-2 font-bold text-xl\">\r\n <BadgeCheck className=\"h-6 w-6\" />\r\n BasuiCN\r\n </div>\r\n\r\n <div className=\"relative space-y-6\">\r\n <h2 className=\"text-3xl font-bold leading-snug\">\r\n Nền tảng quản lý<br />thế hệ mới\r\n </h2>\r\n <ul className=\"space-y-3\">\r\n {features.map((feature) => (\r\n <li key={feature} className=\"flex items-center gap-2 text-primary-foreground/80 text-sm\">\r\n <BadgeCheck className=\"h-4 w-4 shrink-0 text-primary-foreground/60\" />\r\n {feature}\r\n </li>\r\n ))}\r\n </ul>\r\n </div>\r\n\r\n <p className=\"relative text-primary-foreground/40 text-xs\">\r\n © 2026 BasuiCN. All rights reserved.\r\n </p>\r\n </div>\r\n\r\n {/* Right: Form */}\r\n <div className=\"bg-card flex flex-col justify-center p-10\">\r\n <div className=\"mb-8\">\r\n <h2 className=\"text-2xl font-bold text-foreground\">Chào mừng trở lại</h2>\r\n <p className=\"text-muted-foreground mt-1 text-sm\">\r\n Đăng nhập để tiếp tục sử dụng dịch vụ\r\n </p>\r\n </div>\r\n\r\n <Form {...form}>\r\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\r\n <FormField\r\n control={form.control}\r\n name=\"email\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Email\"\r\n type=\"email\"\r\n placeholder=\"email@example.com\"\r\n variant=\"filled\"\r\n icon={<Mail className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n <FormField\r\n control={form.control}\r\n name=\"password\"\r\n render={({ field, fieldState }) => (\r\n <Input\r\n label=\"Mật khẩu\"\r\n type=\"password\"\r\n placeholder=\"••••••••\"\r\n variant=\"filled\"\r\n icon={<Lock className=\"w-4 h-4\" />}\r\n error={fieldState.error?.message}\r\n {...field}\r\n />\r\n )}\r\n />\r\n\r\n <div className=\"flex items-center justify-between\">\r\n <FormField\r\n control={form.control}\r\n name=\"rememberMe\"\r\n render={({ field }) => (\r\n <Checkbox\r\n label=\"Ghi nhớ đăng nhập\"\r\n checked={field.value}\r\n onCheckedChange={(checked) => field.onChange(checked === true)}\r\n />\r\n )}\r\n />\r\n <Button variant=\"link\" type=\"button\">\r\n Quên mật khẩu?\r\n </Button>\r\n </div>\r\n\r\n <Button\r\n type=\"submit\"\r\n className=\"w-full\"\r\n isLoading={isLoading}\r\n disabled={isLoading}\r\n >\r\n Đăng nhập\r\n </Button>\r\n </form>\r\n </Form>\r\n\r\n <p className=\"mt-6 text-center text-sm text-muted-foreground\">\r\n Chưa có tài khoản?{' '}\r\n <Button variant=\"link\" type=\"button\" className=\"px-0 font-semibold\">\r\n Đăng ký ngay\r\n </Button>\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n );\r\n};\r\n"
703
+ "content": "import * as React from 'react';\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport * as z from 'zod';\nimport { Mail, Lock, BadgeCheck } from 'lucide-react';\nimport { Form, FormField } from '@/components/ui/form/Form';\nimport { Input } from '@/components/ui/input/Input';\nimport { Button } from '@/components/ui/button/Button';\nimport { Checkbox } from '@/components/ui/checkbox/Checkbox';\nimport { toast } from 'sonner';\n\nconst loginSchema = z.object({\n email: z.string().email('Email không hợp lệ'),\n password: z.string().min(6, 'Mật khẩu ít nhất 6 ký tự'),\n rememberMe: z.boolean().optional(),\n});\n\ntype LoginValues = z.infer<typeof loginSchema>;\n\nconst features = [\n 'Bảo mật 2 lớp (2FA)',\n 'Đồng bộ đa thiết bị',\n 'Dashboard phân tích thời gian thực',\n 'Hỗ trợ 24/7',\n];\n\nexport const LoginSplit = () => {\n const [isLoading, setIsLoading] = React.useState(false);\n\n const form = useForm<LoginValues>({\n resolver: zodResolver(loginSchema),\n defaultValues: { email: '', password: '', rememberMe: false },\n });\n\n const onSubmit = async (_data: LoginValues) => {\n setIsLoading(true);\n await new Promise<void>((resolve) => setTimeout(resolve, 1500));\n setIsLoading(false);\n toast.success('Đăng nhập thành công!');\n };\n\n return (\n <div className=\"w-full overflow-hidden rounded-2xl border border-border shadow-lg animate-in fade-in zoom-in-95 duration-500\">\n <div className=\"grid grid-cols-1 md:grid-cols-2 min-h-[480px]\">\n {/* Left: Branding */}\n <div className=\"relative hidden md:flex flex-col justify-between bg-gradient-to-br from-primary via-primary/90 to-primary/70 p-10 text-primary-foreground overflow-hidden\">\n <div className=\"absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(255,255,255,0.1),transparent_60%)]\" />\n\n <div className=\"relative flex items-center gap-2 font-bold text-xl\">\n <BadgeCheck className=\"h-6 w-6\" />\n BasuiCN\n </div>\n\n <div className=\"relative space-y-6\">\n <h2 className=\"text-3xl font-bold leading-snug\">\n Nền tảng quản lý<br />thế hệ mới\n </h2>\n <ul className=\"space-y-3\">\n {features.map((feature) => (\n <li key={feature} className=\"flex items-center gap-2 text-primary-foreground/80 text-sm\">\n <BadgeCheck className=\"h-4 w-4 shrink-0 text-primary-foreground/60\" />\n {feature}\n </li>\n ))}\n </ul>\n </div>\n\n <p className=\"relative text-primary-foreground/40 text-xs\">\n © 2026 BasuiCN. All rights reserved.\n </p>\n </div>\n\n {/* Right: Form */}\n <div className=\"bg-card flex flex-col justify-center p-10\">\n <div className=\"mb-8\">\n <h2 className=\"text-2xl font-bold text-foreground\">Chào mừng trở lại</h2>\n <p className=\"text-muted-foreground mt-1 text-sm\">\n Đăng nhập để tiếp tục sử dụng dịch vụ\n </p>\n </div>\n\n <Form {...form}>\n <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-4\">\n <FormField\n control={form.control}\n name=\"email\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Email\"\n type=\"email\"\n placeholder=\"email@example.com\"\n variant=\"filled\"\n icon={<Mail className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n <FormField\n control={form.control}\n name=\"password\"\n render={({ field, fieldState }) => (\n <Input\n label=\"Mật khẩu\"\n type=\"password\"\n placeholder=\"••••••••\"\n variant=\"filled\"\n icon={<Lock className=\"w-4 h-4\" />}\n error={fieldState.error?.message}\n {...field}\n />\n )}\n />\n\n <div className=\"flex items-center justify-between\">\n <FormField\n control={form.control}\n name=\"rememberMe\"\n render={({ field }) => (\n <Checkbox\n label=\"Ghi nhớ đăng nhập\"\n checked={field.value}\n onCheckedChange={(checked) => field.onChange(checked === true)}\n />\n )}\n />\n <Button variant=\"link\" type=\"button\">\n Quên mật khẩu?\n </Button>\n </div>\n\n <Button\n type=\"submit\"\n className=\"w-full\"\n isLoading={isLoading}\n disabled={isLoading}\n >\n Đăng nhập\n </Button>\n </form>\n </Form>\n\n <p className=\"mt-6 text-center text-sm text-muted-foreground\">\n Chưa có tài khoản?{' '}\n <Button variant=\"link\" type=\"button\" className=\"px-0 font-semibold\">\n Đăng ký ngay\n </Button>\n </p>\n </div>\n </div>\n </div>\n );\n};\n"
667
704
  }
668
705
  ]
669
706
  },
@@ -679,7 +716,7 @@
679
716
  "files": [
680
717
  {
681
718
  "path": "src/components/ui/menu-bar/MenuBar.tsx",
682
- "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { useNavigate, useMatch } from 'react-router-dom';\r\nimport { tv } from 'tailwind-variants';\r\nimport { ChevronRight, ExternalLink } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n/* ─── Types ─────────────────────────────────────────────────────────────── */\r\n\r\n/** How the menu item behaves when clicked */\r\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\r\n\r\n/** Config for a single item inside a menu */\r\nexport interface MenuBarItemConfig {\r\n id: string;\r\n label: React.ReactNode;\r\n icon?: React.ReactNode;\r\n /** @default 'button' */\r\n type?: MenuBarItemType;\r\n /** Route path for type='link', full URL for type='external' */\r\n href?: string;\r\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\r\n onClick?: () => void;\r\n shortcut?: string;\r\n disabled?: boolean;\r\n /** Renders a separator line before this item */\r\n separator?: boolean;\r\n /** Nested items — renders as a flyout submenu (unlimited depth) */\r\n children?: MenuBarItemConfig[];\r\n}\r\n\r\n/** Config for one top-level menu entry.\r\n *\r\n * - Có `items` → dropdown menu bình thường\r\n * - Không có `items` → click thẳng vào entry (dùng `type` + `href` / `onClick`)\r\n */\r\nexport interface MenuBarMenuConfig {\r\n id: string;\r\n label: React.ReactNode;\r\n icon?: React.ReactNode;\r\n /** Nếu có items → render dropdown. Nếu bỏ qua → render trực tiếp như button/link */\r\n items?: MenuBarItemConfig[];\r\n disabled?: boolean;\r\n /** Chỉ dùng khi không có items. @default 'button' */\r\n type?: MenuBarItemType;\r\n /** Route path (type='link') hoặc URL (type='external') */\r\n href?: string;\r\n /** Callback khi click (type='button' | 'modal') */\r\n onClick?: () => void;\r\n}\r\n\r\n/* ─── Variants ──────────────────────────────────────────────────────────── */\r\n\r\nconst menuBarVariants = tv({\r\n slots: {\r\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\r\n trigger:\r\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\r\n content:\r\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 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',\r\n item:\r\n 'relative flex w-full 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n itemActive: 'bg-accent/50 font-medium',\r\n subTrigger:\r\n 'relative flex w-full 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 data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n },\r\n});\r\n\r\nconst styles = menuBarVariants();\r\n\r\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\r\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\r\n));\r\nMenuBar.displayName = 'MenuBar';\r\n\r\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarMenu = BaseMenu.Root;\r\n\r\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\r\n ({ className, ...props }, ref) => (\r\n <BaseMenu.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n className={styles.trigger({ className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nMenuBarTrigger.displayName = 'MenuBarTrigger';\r\n\r\n/* ─── MenuBarButton (top-level direct item, no dropdown) ───────────────── */\r\n\r\nexport interface MenuBarButtonProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Highlights the button (e.g. active route) */\r\n active?: boolean;\r\n}\r\n\r\nconst MenuBarButton = React.forwardRef<HTMLButtonElement, MenuBarButtonProps>(\r\n ({ className, active, ...props }, ref) => (\r\n <button\r\n ref={ref}\r\n className={styles.trigger({ className: cn(active && styles.itemActive(), className) })}\r\n {...props}\r\n />\r\n )\r\n);\r\nMenuBarButton.displayName = 'MenuBarButton';\r\n\r\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n align?: 'start' | 'center' | 'end';\r\n sideOffset?: number;\r\n}\r\n\r\nconst MenuBarContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n MenuBarContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nMenuBarContent.displayName = 'MenuBarContent';\r\n\r\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n className?: string;\r\n /** Applies active/highlighted styling (e.g. current route) */\r\n active?: boolean;\r\n}\r\n\r\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\r\n ({ className, active, children, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\r\n {...props}\r\n >\r\n {children}\r\n </BaseMenu.Item>\r\n )\r\n);\r\nMenuBarItem.displayName = 'MenuBarItem';\r\n\r\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nMenuBarSeparator.displayName = 'MenuBarSeparator';\r\n\r\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className })} {...props} />\r\n )\r\n);\r\nMenuBarLabel.displayName = 'MenuBarLabel';\r\n\r\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\r\n\r\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nMenuBarShortcut.displayName = 'MenuBarShortcut';\r\n\r\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarSub = BaseMenu.SubmenuRoot;\r\n\r\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n MenuBarSubTriggerProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\r\n\r\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n MenuBarSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nMenuBarSubContent.displayName = 'MenuBarSubContent';\r\n\r\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarGroup = BaseMenu.Group;\r\n\r\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\r\n\r\n/**\r\n * Internal recursive renderer for MenuBarItemConfig.\r\n * Handles all 4 item types, separators, and unlimited submenu depth.\r\n */\r\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\r\n const navigate = useNavigate();\r\n const isLinkType = item.type === 'link' && !!item.href;\r\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\r\n const isActive = isLinkType && !!match;\r\n\r\n const handleClick = React.useCallback(() => {\r\n if (item.type === 'link' && item.href) {\r\n navigate(item.href);\r\n } else if (item.type === 'external' && item.href) {\r\n window.open(item.href, '_blank', 'noopener,noreferrer');\r\n } else {\r\n item.onClick?.();\r\n }\r\n }, [item, navigate]);\r\n\r\n if (item.children && item.children.length > 0) {\r\n return (\r\n <>\r\n {item.separator && <MenuBarSeparator />}\r\n <MenuBarSub>\r\n <MenuBarSubTrigger disabled={item.disabled}>\r\n {item.icon}\r\n {item.label}\r\n </MenuBarSubTrigger>\r\n <MenuBarSubContent>\r\n {item.children.map((child) => (\r\n <MenuBarItemRenderer key={child.id} item={child} />\r\n ))}\r\n </MenuBarSubContent>\r\n </MenuBarSub>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <>\r\n {item.separator && <MenuBarSeparator />}\r\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\r\n {item.icon}\r\n {item.label}\r\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\r\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\r\n </MenuBarItem>\r\n </>\r\n );\r\n};\r\n\r\n/** Props for the config-driven MenuBarNav component */\r\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\r\n /** Array of top-level menus, each with nested items supporting unlimited depth */\r\n menus: MenuBarMenuConfig[];\r\n}\r\n\r\n/**\r\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\r\n * triggers, dropdowns, submenus, separators, active link states.\r\n *\r\n * @example\r\n * ```tsx\r\n * <MenuBarNav menus={[\r\n * {\r\n * id: 'file', label: 'File',\r\n * items: [\r\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\r\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\r\n * { id: 'sep', label: '---', separator: true, ... },\r\n * ],\r\n * },\r\n * ]} />\r\n * ```\r\n */\r\n/** Internal: renders a direct (no-dropdown) top-level entry */\r\nconst MenuBarDirectRenderer = ({ menu }: { menu: MenuBarMenuConfig }) => {\r\n const navigate = useNavigate();\r\n const isLink = menu.type === 'link' && !!menu.href;\r\n const match = useMatch(isLink ? menu.href! : '__NO_MATCH__');\r\n\r\n const handleClick = React.useCallback(() => {\r\n if (menu.type === 'link' && menu.href) navigate(menu.href);\r\n else if (menu.type === 'external' && menu.href) window.open(menu.href, '_blank', 'noopener,noreferrer');\r\n else menu.onClick?.();\r\n }, [menu, navigate]);\r\n\r\n return (\r\n <MenuBarButton active={isLink && !!match} disabled={menu.disabled} onClick={handleClick}>\r\n {menu.icon}\r\n {menu.label}\r\n {menu.type === 'external' && <ExternalLink className=\"!size-3 opacity-50\" />}\r\n </MenuBarButton>\r\n );\r\n};\r\n\r\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\r\n ({ menus, className, ...props }, ref) => (\r\n <MenuBar ref={ref} className={className} {...props}>\r\n {menus.map((menu) =>\r\n !menu.items || menu.items.length === 0 ? (\r\n // Direct item — không có dropdown\r\n <MenuBarDirectRenderer key={menu.id} menu={menu} />\r\n ) : (\r\n // Dropdown menu bình thường\r\n <MenuBarMenu key={menu.id}>\r\n <MenuBarTrigger disabled={menu.disabled}>\r\n {menu.icon}\r\n {menu.label}\r\n </MenuBarTrigger>\r\n <MenuBarContent>\r\n {menu.items.map((item) => (\r\n <MenuBarItemRenderer key={item.id} item={item} />\r\n ))}\r\n </MenuBarContent>\r\n </MenuBarMenu>\r\n )\r\n )}\r\n </MenuBar>\r\n )\r\n);\r\nMenuBarNav.displayName = 'MenuBarNav';\r\n\r\n/* ─── Exports ───────────────────────────────────────────────────────────── */\r\n\r\nexport {\r\n menuBarVariants,\r\n // Primitive API\r\n MenuBar,\r\n MenuBarMenu,\r\n MenuBarTrigger,\r\n MenuBarButton,\r\n MenuBarContent,\r\n MenuBarItem,\r\n MenuBarSeparator,\r\n MenuBarLabel,\r\n MenuBarShortcut,\r\n MenuBarSub,\r\n MenuBarSubTrigger,\r\n MenuBarSubContent,\r\n MenuBarGroup,\r\n // Config-driven API\r\n MenuBarNav,\r\n};\r\n"
719
+ "content": "import * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { useNavigate, useMatch } from 'react-router-dom';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, ExternalLink } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n/* ─── Types ─────────────────────────────────────────────────────────────── */\n\n/** How the menu item behaves when clicked */\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\n\n/** Config for a single item inside a menu */\nexport interface MenuBarItemConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** @default 'button' */\n type?: MenuBarItemType;\n /** Route path for type='link', full URL for type='external' */\n href?: string;\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\n onClick?: () => void;\n shortcut?: string;\n disabled?: boolean;\n /** Renders a separator line before this item */\n separator?: boolean;\n /** Nested items — renders as a flyout submenu (unlimited depth) */\n children?: MenuBarItemConfig[];\n}\n\n/** Config for one top-level menu entry.\n *\n * - Có `items` → dropdown menu bình thường\n * - Không có `items` → click thẳng vào entry (dùng `type` + `href` / `onClick`)\n */\nexport interface MenuBarMenuConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** Nếu có items → render dropdown. Nếu bỏ qua → render trực tiếp như button/link */\n items?: MenuBarItemConfig[];\n disabled?: boolean;\n /** Chỉ dùng khi không có items. @default 'button' */\n type?: MenuBarItemType;\n /** Route path (type='link') hoặc URL (type='external') */\n href?: string;\n /** Callback khi click (type='button' | 'modal') */\n onClick?: () => void;\n}\n\n/* ─── Variants ──────────────────────────────────────────────────────────── */\n\nconst menuBarVariants = tv({\n slots: {\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\n trigger:\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\n content:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 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',\n item:\n 'relative flex w-full 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n itemActive: 'bg-accent/50 font-medium',\n subTrigger:\n 'relative flex w-full 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 data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n separator: '-mx-1 my-1 h-px bg-border',\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n },\n});\n\nconst styles = menuBarVariants();\n\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\n\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\n));\nMenuBar.displayName = 'MenuBar';\n\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\n\nconst MenuBarMenu = BaseMenu.Root;\n\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\n\nexport interface MenuBarTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\n ({ className, ...props }, ref) => (\n <BaseMenu.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n className={styles.trigger({ className })}\n {...props}\n />\n )\n);\nMenuBarTrigger.displayName = 'MenuBarTrigger';\n\n/* ─── MenuBarButton (top-level direct item, no dropdown) ───────────────── */\n\nexport interface MenuBarButtonProps extends React.ComponentPropsWithoutRef<'button'> {\n /** Highlights the button (e.g. active route) */\n active?: boolean;\n}\n\nconst MenuBarButton = React.forwardRef<HTMLButtonElement, MenuBarButtonProps>(\n ({ className, active, ...props }, ref) => (\n <button\n ref={ref}\n className={styles.trigger({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n />\n )\n);\nMenuBarButton.displayName = 'MenuBarButton';\n\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\n\nexport interface MenuBarContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n side?: 'top' | 'right' | 'bottom' | 'left';\n align?: 'start' | 'center' | 'end';\n sideOffset?: number;\n}\n\nconst MenuBarContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarContent.displayName = 'MenuBarContent';\n\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\n\nexport interface MenuBarItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n className?: string;\n /** Applies active/highlighted styling (e.g. current route) */\n active?: boolean;\n}\n\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\n ({ className, active, children, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n >\n {children}\n </BaseMenu.Item>\n )\n);\nMenuBarItem.displayName = 'MenuBarItem';\n\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\n\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nMenuBarSeparator.displayName = 'MenuBarSeparator';\n\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\n\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nMenuBarLabel.displayName = 'MenuBarLabel';\n\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\n\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nMenuBarShortcut.displayName = 'MenuBarShortcut';\n\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\n\nconst MenuBarSub = BaseMenu.SubmenuRoot;\n\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n MenuBarSubTriggerProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\n\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarSubContent.displayName = 'MenuBarSubContent';\n\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\n\nconst MenuBarGroup = BaseMenu.Group;\n\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\n\n/**\n * Internal recursive renderer for MenuBarItemConfig.\n * Handles all 4 item types, separators, and unlimited submenu depth.\n */\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\n const navigate = useNavigate();\n const isLinkType = item.type === 'link' && !!item.href;\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\n const isActive = isLinkType && !!match;\n\n const handleClick = React.useCallback(() => {\n if (item.type === 'link' && item.href) {\n navigate(item.href);\n } else if (item.type === 'external' && item.href) {\n window.open(item.href, '_blank', 'noopener,noreferrer');\n } else {\n item.onClick?.();\n }\n }, [item, navigate]);\n\n if (item.children && item.children.length > 0) {\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarSub>\n <MenuBarSubTrigger disabled={item.disabled}>\n {item.icon}\n {item.label}\n </MenuBarSubTrigger>\n <MenuBarSubContent>\n {item.children.map((child) => (\n <MenuBarItemRenderer key={child.id} item={child} />\n ))}\n </MenuBarSubContent>\n </MenuBarSub>\n </>\n );\n }\n\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\n {item.icon}\n {item.label}\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\n </MenuBarItem>\n </>\n );\n};\n\n/** Props for the config-driven MenuBarNav component */\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\n /** Array of top-level menus, each with nested items supporting unlimited depth */\n menus: MenuBarMenuConfig[];\n}\n\n/**\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\n * triggers, dropdowns, submenus, separators, active link states.\n *\n * @example\n * ```tsx\n * <MenuBarNav menus={[\n * {\n * id: 'file', label: 'File',\n * items: [\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\n * { id: 'sep', label: '---', separator: true, ... },\n * ],\n * },\n * ]} />\n * ```\n */\n/** Internal: renders a direct (no-dropdown) top-level entry */\nconst MenuBarDirectRenderer = ({ menu }: { menu: MenuBarMenuConfig }) => {\n const navigate = useNavigate();\n const isLink = menu.type === 'link' && !!menu.href;\n const match = useMatch(isLink ? menu.href! : '__NO_MATCH__');\n\n const handleClick = React.useCallback(() => {\n if (menu.type === 'link' && menu.href) navigate(menu.href);\n else if (menu.type === 'external' && menu.href) window.open(menu.href, '_blank', 'noopener,noreferrer');\n else menu.onClick?.();\n }, [menu, navigate]);\n\n return (\n <MenuBarButton active={isLink && !!match} disabled={menu.disabled} onClick={handleClick}>\n {menu.icon}\n {menu.label}\n {menu.type === 'external' && <ExternalLink className=\"!size-3 opacity-50\" />}\n </MenuBarButton>\n );\n};\n\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\n ({ menus, className, ...props }, ref) => (\n <MenuBar ref={ref} className={className} {...props}>\n {menus.map((menu) =>\n !menu.items || menu.items.length === 0 ? (\n // Direct item — không có dropdown\n <MenuBarDirectRenderer key={menu.id} menu={menu} />\n ) : (\n // Dropdown menu bình thường\n <MenuBarMenu key={menu.id}>\n <MenuBarTrigger disabled={menu.disabled}>\n {menu.icon}\n {menu.label}\n </MenuBarTrigger>\n <MenuBarContent>\n {menu.items.map((item) => (\n <MenuBarItemRenderer key={item.id} item={item} />\n ))}\n </MenuBarContent>\n </MenuBarMenu>\n )\n )}\n </MenuBar>\n )\n);\nMenuBarNav.displayName = 'MenuBarNav';\n\n/* ─── Exports ───────────────────────────────────────────────────────────── */\n\nexport {\n menuBarVariants,\n // Primitive API\n MenuBar,\n MenuBarMenu,\n MenuBarTrigger,\n MenuBarButton,\n MenuBarContent,\n MenuBarItem,\n MenuBarSeparator,\n MenuBarLabel,\n MenuBarShortcut,\n MenuBarSub,\n MenuBarSubTrigger,\n MenuBarSubContent,\n MenuBarGroup,\n // Config-driven API\n MenuBarNav,\n};\n"
683
720
  }
684
721
  ]
685
722
  },
@@ -694,7 +731,7 @@
694
731
  "files": [
695
732
  {
696
733
  "path": "src/components/ui/number-input/NumberInput.tsx",
697
- "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 self-start 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 };\r\n"
734
+ "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 self-start 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 };\n"
698
735
  }
699
736
  ]
700
737
  },
@@ -708,7 +745,7 @@
708
745
  "files": [
709
746
  {
710
747
  "path": "src/components/ui/pagination/Pagination.tsx",
711
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';\r\n\r\nconst paginationVariants = tv({\r\n slots: {\r\n nav: 'mx-auto flex w-full justify-center',\r\n list: 'flex flex-row items-center gap-1',\r\n item: '',\r\n link: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer hover:bg-accent hover:text-accent-foreground h-10 w-10',\r\n ellipsis: 'flex h-10 w-10 items-center justify-center',\r\n },\r\n variants: {\r\n active: {\r\n true: { link: 'border border-border bg-background shadow-sm' },\r\n false: {},\r\n },\r\n size: {\r\n sm: { link: 'h-8 w-8 text-xs' },\r\n md: { link: 'h-10 w-10 text-sm' },\r\n lg: { link: 'h-12 w-12 text-base' },\r\n },\r\n },\r\n defaultVariants: {\r\n active: false,\r\n size: 'md',\r\n },\r\n});\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationProps extends React.ComponentPropsWithoutRef<'nav'> {}\r\n\r\nconst Pagination = React.forwardRef<HTMLElement, PaginationProps>(\r\n ({ className, ...props }, ref) => {\r\n const { nav } = paginationVariants();\r\n return <nav ref={ref} role=\"navigation\" aria-label=\"pagination\" className={nav({ className })} {...props} />;\r\n }\r\n);\r\nPagination.displayName = 'Pagination';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationContentProps extends React.ComponentPropsWithoutRef<'ul'> {}\r\n\r\nconst PaginationContent = React.forwardRef<HTMLUListElement, PaginationContentProps>(\r\n ({ className, ...props }, ref) => {\r\n const { list } = paginationVariants();\r\n return <ul ref={ref} className={list({ className })} {...props} />;\r\n }\r\n);\r\nPaginationContent.displayName = 'PaginationContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationItemProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst PaginationItem = React.forwardRef<HTMLLIElement, PaginationItemProps>(\r\n ({ className, ...props }, ref) => {\r\n const { item } = paginationVariants();\r\n return <li ref={ref} className={item({ className })} {...props} />;\r\n }\r\n);\r\nPaginationItem.displayName = 'PaginationItem';\r\n\r\n/* ─── Link ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationLink component */\r\nexport interface PaginationLinkProps\r\n extends React.ComponentPropsWithoutRef<'button'>,\r\n Pick<VariantProps<typeof paginationVariants>, 'size'> {\r\n /** Whether this link represents the current page */\r\n isActive?: boolean;\r\n}\r\n\r\nconst PaginationLink = React.forwardRef<HTMLButtonElement, PaginationLinkProps>(\r\n ({ className, isActive, size, ...props }, ref) => {\r\n const { link } = paginationVariants({ active: isActive, size });\r\n return (\r\n <button\r\n ref={ref}\r\n aria-current={isActive ? 'page' : undefined}\r\n className={link({ className })}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nPaginationLink.displayName = 'PaginationLink';\r\n\r\n/* ─── Previous ──────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationPrevious component */\r\nexport interface PaginationPreviousProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Optional text label displayed next to the chevron icon */\r\n label?: string;\r\n}\r\n\r\nconst PaginationPrevious = React.forwardRef<HTMLButtonElement, PaginationPreviousProps>(\r\n ({ className, label, ...props }, ref) => {\r\n const { link } = paginationVariants();\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Go to previous page\"\r\n className={link({ className: `gap-1 ${label ? 'pl-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n <ChevronLeft className=\"h-4 w-4\" />\r\n {label && <span>{label}</span>}\r\n </button>\r\n );\r\n }\r\n);\r\nPaginationPrevious.displayName = 'PaginationPrevious';\r\n\r\n/* ─── Next ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationNext component */\r\nexport interface PaginationNextProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Optional text label displayed next to the chevron icon */\r\n label?: string;\r\n}\r\n\r\nconst PaginationNext = React.forwardRef<HTMLButtonElement, PaginationNextProps>(\r\n ({ className, label, ...props }, ref) => {\r\n const { link } = paginationVariants();\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Go to next page\"\r\n className={link({ className: `gap-1 ${label ? 'pr-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {label && <span>{label}</span>}\r\n <ChevronRight className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nPaginationNext.displayName = 'PaginationNext';\r\n\r\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst PaginationEllipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>(\r\n ({ className, ...props }, ref) => {\r\n const { ellipsis } = paginationVariants();\r\n return (\r\n <span ref={ref} aria-hidden className={ellipsis({ className })} {...props}>\r\n <MoreHorizontal className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">More pages</span>\r\n </span>\r\n );\r\n }\r\n);\r\nPaginationEllipsis.displayName = 'PaginationEllipsis';\r\n\r\nexport {\r\n Pagination,\r\n PaginationContent,\r\n PaginationItem,\r\n PaginationLink,\r\n PaginationPrevious,\r\n PaginationNext,\r\n PaginationEllipsis,\r\n paginationVariants,\r\n};\r\n"
748
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';\n\nconst paginationVariants = tv({\n slots: {\n nav: 'mx-auto flex w-full justify-center',\n list: 'flex flex-row items-center gap-1',\n item: '',\n link: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer hover:bg-accent hover:text-accent-foreground h-10 w-10',\n ellipsis: 'flex h-10 w-10 items-center justify-center',\n },\n variants: {\n active: {\n true: { link: 'border border-border bg-background shadow-sm' },\n false: {},\n },\n size: {\n sm: { link: 'h-8 w-8 text-xs' },\n md: { link: 'h-10 w-10 text-sm' },\n lg: { link: 'h-12 w-12 text-base' },\n },\n },\n defaultVariants: {\n active: false,\n size: 'md',\n },\n});\n\n/* ─── Root ──────────────────────────────────────────────────────────── */\n\nexport interface PaginationProps extends React.ComponentPropsWithoutRef<'nav'> {}\n\nconst Pagination = React.forwardRef<HTMLElement, PaginationProps>(\n ({ className, ...props }, ref) => {\n const { nav } = paginationVariants();\n return <nav ref={ref} role=\"navigation\" aria-label=\"pagination\" className={nav({ className })} {...props} />;\n }\n);\nPagination.displayName = 'Pagination';\n\n/* ─── Content ───────────────────────────────────────────────────────── */\n\nexport interface PaginationContentProps extends React.ComponentPropsWithoutRef<'ul'> {}\n\nconst PaginationContent = React.forwardRef<HTMLUListElement, PaginationContentProps>(\n ({ className, ...props }, ref) => {\n const { list } = paginationVariants();\n return <ul ref={ref} className={list({ className })} {...props} />;\n }\n);\nPaginationContent.displayName = 'PaginationContent';\n\n/* ─── Item ──────────────────────────────────────────────────────────── */\n\nexport interface PaginationItemProps extends React.ComponentPropsWithoutRef<'li'> {}\n\nconst PaginationItem = React.forwardRef<HTMLLIElement, PaginationItemProps>(\n ({ className, ...props }, ref) => {\n const { item } = paginationVariants();\n return <li ref={ref} className={item({ className })} {...props} />;\n }\n);\nPaginationItem.displayName = 'PaginationItem';\n\n/* ─── Link ──────────────────────────────────────────────────────────── */\n\n/** Props for the PaginationLink component */\nexport interface PaginationLinkProps\n extends React.ComponentPropsWithoutRef<'button'>,\n Pick<VariantProps<typeof paginationVariants>, 'size'> {\n /** Whether this link represents the current page */\n isActive?: boolean;\n}\n\nconst PaginationLink = React.forwardRef<HTMLButtonElement, PaginationLinkProps>(\n ({ className, isActive, size, ...props }, ref) => {\n const { link } = paginationVariants({ active: isActive, size });\n return (\n <button\n ref={ref}\n aria-current={isActive ? 'page' : undefined}\n className={link({ className })}\n {...props}\n />\n );\n }\n);\nPaginationLink.displayName = 'PaginationLink';\n\n/* ─── Previous ──────────────────────────────────────────────────────── */\n\n/** Props for the PaginationPrevious component */\nexport interface PaginationPreviousProps extends React.ComponentPropsWithoutRef<'button'> {\n /** Optional text label displayed next to the chevron icon */\n label?: string;\n}\n\nconst PaginationPrevious = React.forwardRef<HTMLButtonElement, PaginationPreviousProps>(\n ({ className, label, ...props }, ref) => {\n const { link } = paginationVariants();\n return (\n <button\n ref={ref}\n aria-label=\"Go to previous page\"\n className={link({ className: `gap-1 ${label ? 'pl-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\n {...props}\n >\n <ChevronLeft className=\"h-4 w-4\" />\n {label && <span>{label}</span>}\n </button>\n );\n }\n);\nPaginationPrevious.displayName = 'PaginationPrevious';\n\n/* ─── Next ──────────────────────────────────────────────────────────── */\n\n/** Props for the PaginationNext component */\nexport interface PaginationNextProps extends React.ComponentPropsWithoutRef<'button'> {\n /** Optional text label displayed next to the chevron icon */\n label?: string;\n}\n\nconst PaginationNext = React.forwardRef<HTMLButtonElement, PaginationNextProps>(\n ({ className, label, ...props }, ref) => {\n const { link } = paginationVariants();\n return (\n <button\n ref={ref}\n aria-label=\"Go to next page\"\n className={link({ className: `gap-1 ${label ? 'pr-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\n {...props}\n >\n {label && <span>{label}</span>}\n <ChevronRight className=\"h-4 w-4\" />\n </button>\n );\n }\n);\nPaginationNext.displayName = 'PaginationNext';\n\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\n\nexport interface PaginationEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\n\nconst PaginationEllipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>(\n ({ className, ...props }, ref) => {\n const { ellipsis } = paginationVariants();\n return (\n <span ref={ref} aria-hidden className={ellipsis({ className })} {...props}>\n <MoreHorizontal className=\"h-4 w-4\" />\n <span className=\"sr-only\">More pages</span>\n </span>\n );\n }\n);\nPaginationEllipsis.displayName = 'PaginationEllipsis';\n\nexport {\n Pagination,\n PaginationContent,\n PaginationItem,\n PaginationLink,\n PaginationPrevious,\n PaginationNext,\n PaginationEllipsis,\n paginationVariants,\n};\n"
712
749
  }
713
750
  ]
714
751
  },
@@ -755,7 +792,7 @@
755
792
  "files": [
756
793
  {
757
794
  "path": "src/components/ui/preview-card/PreviewCard.tsx",
758
- "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Button } from '../button/Button';\r\n\r\nconst previewCardVariants = tv({\r\n slots: {\r\n popup: [\r\n 'z-50 w-72 rounded-xl border border-border bg-background shadow-xl outline-none',\r\n 'data-starting:animate-in data-ending:animate-out',\r\n 'data-ending:fade-out-0 data-starting:fade-in-0',\r\n 'data-ending:zoom-out-95 data-starting:zoom-in-95',\r\n 'data-side-bottom:slide-in-from-top-2',\r\n 'data-side-left:slide-in-from-right-2',\r\n 'data-side-right:slide-in-from-left-2',\r\n 'data-side-top:slide-in-from-bottom-2',\r\n ],\r\n cover: 'w-full overflow-hidden rounded-t-xl',\r\n body: 'p-4 space-y-2',\r\n title: 'font-semibold text-sm text-foreground leading-tight',\r\n description: 'text-xs text-muted-foreground leading-relaxed',\r\n footer: 'px-4 pb-4 pt-0 border-t border-border/50 mt-2 pt-3',\r\n },\r\n});\r\n\r\nexport type PreviewCardSide = 'top' | 'right' | 'bottom' | 'left';\r\nexport type PreviewCardAlign = 'start' | 'center' | 'end';\r\n\r\n/** Props for the PreviewCard component */\r\nexport interface PreviewCardProps {\r\n /** Element that triggers the preview card */\r\n trigger: React.ReactNode;\r\n /** Title text displayed in the card body */\r\n title?: string;\r\n /** Description text displayed below the title */\r\n description?: string;\r\n /** URL for the cover image at the top of the card */\r\n coverImage?: string;\r\n /** Alt text for the cover image */\r\n coverAlt?: string;\r\n /** Height in px of the cover image area */\r\n coverHeight?: number;\r\n children?: React.ReactNode;\r\n /** Content rendered in the card footer section */\r\n footerContent?: React.ReactNode;\r\n /** Which side of the trigger to render the card */\r\n side?: PreviewCardSide;\r\n /** Alignment relative to the trigger */\r\n align?: PreviewCardAlign;\r\n /** Distance in px between the trigger and the card */\r\n sideOffset?: number;\r\n /** Open the card on hover instead of click */\r\n openOnHover?: boolean;\r\n /** Width of the card in px */\r\n width?: number;\r\n className?: string;\r\n}\r\n\r\nconst PreviewCard = React.forwardRef<HTMLSpanElement, PreviewCardProps>(({\r\n trigger,\r\n title,\r\n description,\r\n coverImage,\r\n coverAlt = '',\r\n coverHeight = 120,\r\n children,\r\n footerContent,\r\n side = 'bottom',\r\n align = 'start',\r\n sideOffset = 8,\r\n openOnHover = false,\r\n width = 288,\r\n className,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const slots = previewCardVariants();\r\n\r\n const triggerProps = openOnHover\r\n ? {\r\n onMouseEnter: () => setOpen(true),\r\n onMouseLeave: () => setOpen(false),\r\n }\r\n : {};\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n <BasePopover.Trigger\r\n nativeButton={false}\r\n render={\r\n <span\r\n ref={ref}\r\n className=\"inline-block cursor-pointer\"\r\n {...triggerProps}\r\n >\r\n {trigger}\r\n </span>\r\n }\r\n />\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BasePopover.Popup\r\n className={slots.popup({ className })}\r\n style={{ width }}\r\n >\r\n {coverImage && (\r\n <div className={slots.cover()} style={{ height: coverHeight }}>\r\n <img\r\n src={coverImage}\r\n alt={coverAlt}\r\n className=\"w-full h-full object-cover\"\r\n />\r\n </div>\r\n )}\r\n {(title || description || children) && (\r\n <div className={slots.body()}>\r\n {title && <p className={slots.title()}>{title}</p>}\r\n {description && <p className={slots.description()}>{description}</p>}\r\n {children}\r\n </div>\r\n )}\r\n {footerContent && (\r\n <div className={slots.footer()}>{footerContent}</div>\r\n )}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n});\r\n\r\nPreviewCard.displayName = 'PreviewCard';\r\n\r\nexport { PreviewCard };\r\n"
795
+ "content": "import * as React from 'react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Button } from '../button/Button';\n\nconst previewCardVariants = tv({\n slots: {\n popup: [\n 'z-50 w-72 rounded-xl border border-border bg-background shadow-xl outline-none',\n 'data-starting:animate-in data-ending:animate-out',\n 'data-ending:fade-out-0 data-starting:fade-in-0',\n 'data-ending:zoom-out-95 data-starting:zoom-in-95',\n 'data-side-bottom:slide-in-from-top-2',\n 'data-side-left:slide-in-from-right-2',\n 'data-side-right:slide-in-from-left-2',\n 'data-side-top:slide-in-from-bottom-2',\n ],\n cover: 'w-full overflow-hidden rounded-t-xl',\n body: 'p-4 space-y-2',\n title: 'font-semibold text-sm text-foreground leading-tight',\n description: 'text-xs text-muted-foreground leading-relaxed',\n footer: 'px-4 pb-4 pt-0 border-t border-border/50 mt-2 pt-3',\n },\n});\n\nexport type PreviewCardSide = 'top' | 'right' | 'bottom' | 'left';\nexport type PreviewCardAlign = 'start' | 'center' | 'end';\n\n/** Props for the PreviewCard component */\nexport interface PreviewCardProps {\n /** Element that triggers the preview card */\n trigger: React.ReactNode;\n /** Title text displayed in the card body */\n title?: string;\n /** Description text displayed below the title */\n description?: string;\n /** URL for the cover image at the top of the card */\n coverImage?: string;\n /** Alt text for the cover image */\n coverAlt?: string;\n /** Height in px of the cover image area */\n coverHeight?: number;\n children?: React.ReactNode;\n /** Content rendered in the card footer section */\n footerContent?: React.ReactNode;\n /** Which side of the trigger to render the card */\n side?: PreviewCardSide;\n /** Alignment relative to the trigger */\n align?: PreviewCardAlign;\n /** Distance in px between the trigger and the card */\n sideOffset?: number;\n /** Open the card on hover instead of click */\n openOnHover?: boolean;\n /** Width of the card in px */\n width?: number;\n className?: string;\n}\n\nconst PreviewCard = React.forwardRef<HTMLSpanElement, PreviewCardProps>(({\n trigger,\n title,\n description,\n coverImage,\n coverAlt = '',\n coverHeight = 120,\n children,\n footerContent,\n side = 'bottom',\n align = 'start',\n sideOffset = 8,\n openOnHover = false,\n width = 288,\n className,\n}, ref) => {\n const [open, setOpen] = React.useState(false);\n const slots = previewCardVariants();\n\n const triggerProps = openOnHover\n ? {\n onMouseEnter: () => setOpen(true),\n onMouseLeave: () => setOpen(false),\n }\n : {};\n\n return (\n <BasePopover.Root open={open} onOpenChange={setOpen}>\n <BasePopover.Trigger\n nativeButton={false}\n render={\n <span\n ref={ref}\n className=\"inline-block cursor-pointer\"\n {...triggerProps}\n >\n {trigger}\n </span>\n }\n />\n <BasePopover.Portal>\n <BasePopover.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BasePopover.Popup\n className={slots.popup({ className })}\n style={{ width }}\n >\n {coverImage && (\n <div className={slots.cover()} style={{ height: coverHeight }}>\n <img\n src={coverImage}\n alt={coverAlt}\n className=\"w-full h-full object-cover\"\n />\n </div>\n )}\n {(title || description || children) && (\n <div className={slots.body()}>\n {title && <p className={slots.title()}>{title}</p>}\n {description && <p className={slots.description()}>{description}</p>}\n {children}\n </div>\n )}\n {footerContent && (\n <div className={slots.footer()}>{footerContent}</div>\n )}\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n </BasePopover.Root>\n );\n});\n\nPreviewCard.displayName = 'PreviewCard';\n\nexport { PreviewCard };\n"
759
796
  }
760
797
  ]
761
798
  },
@@ -769,7 +806,7 @@
769
806
  "files": [
770
807
  {
771
808
  "path": "src/components/ui/progress/Progress.tsx",
772
- "content": "import * as React from 'react';\r\nimport { Progress as BaseProgress } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst progressVariants = tv({\r\n slots: {\r\n base: 'flex flex-col gap-1.5 w-full',\r\n labelContainer: 'flex justify-between items-center text-sm font-medium',\r\n root: 'relative w-full overflow-hidden rounded-full bg-secondary',\r\n indicator: 'rounded-full h-full w-full flex-1 transition-all duration-500 ease-in-out relative overflow-hidden flex items-center justify-end',\r\n innerLabel: 'text-[10px] font-bold text-white drop-shadow-md pr-2',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-3', innerLabel: 'text-[10px] pr-1' },\r\n md: { root: 'h-4', innerLabel: 'text-[10px] pr-1' },\r\n lg: { root: 'h-6', innerLabel: 'text-xs pr-3' },\r\n },\r\n variant: {\r\n default: { indicator: 'bg-primary' },\r\n success: { indicator: 'bg-success' },\r\n warning: { indicator: 'bg-warning' },\r\n danger: { indicator: 'bg-danger' },\r\n gradient: { indicator: 'bg-gradient-to-r from-primary to-indigo-400' },\r\n },\r\n striped: {\r\n true: { \r\n indicator: 'bg-[linear-gradient(45deg,rgba(255,255,255,0.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.15)_50%,rgba(255,255,255,0.15)_75%,transparent_75%,transparent)] bg-[length:1rem_1rem]' \r\n }\r\n },\r\n animated: {\r\n true: {\r\n indicator: 'animate-progress-stripes'\r\n }\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'default',\r\n }\r\n});\r\n\r\n/** Props for the Progress component */\r\nexport interface ProgressProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseProgress.Root>, 'value'>,\r\n VariantProps<typeof progressVariants> {\r\n className?: string;\r\n /** Progress value from 0 to 100 */\r\n value?: number | null;\r\n /** @deprecated Use `labelPosition` instead */\r\n showLabel?: boolean;\r\n /** Where to display the percentage label */\r\n labelPosition?: 'inside' | 'outside' | 'none';\r\n /** Descriptive text label shown above the progress bar */\r\n label?: string;\r\n}\r\n\r\nconst Progress = React.forwardRef<React.ElementRef<typeof BaseProgress.Root>, ProgressProps>(\r\n ({ className, value, size, variant, striped, animated, showLabel = false, labelPosition = 'none', label, ...props }, ref) => {\r\n const { base, root, indicator, labelContainer, innerLabel } = progressVariants({ size, variant, striped, animated });\r\n \r\n // Auto-enable striped if string animated is true, unless explicitly turned off\r\n const isStriped = striped !== undefined ? striped : animated;\r\n const { indicator: finalIndicator } = progressVariants({ size, variant, striped: isStriped, animated });\r\n\r\n const displayValue = value ?? 0;\r\n\r\n return (\r\n <div className={base({ className })}>\r\n {(labelPosition === 'outside' || label) && (\r\n <div className={labelContainer()}>\r\n {label && <span>{label}</span>}\r\n {labelPosition === 'outside' && <span>{Math.round(displayValue)}%</span>}\r\n </div>\r\n )}\r\n <BaseProgress.Root\r\n ref={ref}\r\n className={root()}\r\n value={value ?? null}\r\n aria-valuemin={0}\r\n aria-valuemax={100}\r\n aria-valuenow={displayValue}\r\n aria-label={label ?? 'Progress'}\r\n {...props}\r\n >\r\n <BaseProgress.Indicator \r\n className={finalIndicator()} \r\n style={{ transform: `translateX(-${100 - displayValue}%)` }} \r\n >\r\n {labelPosition === 'inside' && displayValue > 5 && (\r\n <span className={innerLabel()}>{Math.round(displayValue)}%</span>\r\n )}\r\n </BaseProgress.Indicator>\r\n </BaseProgress.Root>\r\n </div>\r\n )\r\n }\r\n)\r\nProgress.displayName = 'Progress';\r\n\r\nexport { Progress };\r\n"
809
+ "content": "import * as React from 'react';\nimport { Progress as BaseProgress } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst progressVariants = tv({\n slots: {\n base: 'flex flex-col gap-1.5 w-full',\n labelContainer: 'flex justify-between items-center text-sm font-medium',\n root: 'relative w-full overflow-hidden rounded-full bg-secondary',\n indicator: 'rounded-full h-full w-full flex-1 transition-all duration-500 ease-in-out relative overflow-hidden flex items-center justify-end',\n innerLabel: 'text-[10px] font-bold text-white drop-shadow-md pr-2',\n },\n variants: {\n size: {\n sm: { root: 'h-3', innerLabel: 'text-[10px] pr-1' },\n md: { root: 'h-4', innerLabel: 'text-[10px] pr-1' },\n lg: { root: 'h-6', innerLabel: 'text-xs pr-3' },\n },\n variant: {\n default: { indicator: 'bg-primary' },\n success: { indicator: 'bg-success' },\n warning: { indicator: 'bg-warning' },\n danger: { indicator: 'bg-danger' },\n gradient: { indicator: 'bg-gradient-to-r from-primary to-indigo-400' },\n },\n striped: {\n true: { \n indicator: 'bg-[linear-gradient(45deg,rgba(255,255,255,0.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.15)_50%,rgba(255,255,255,0.15)_75%,transparent_75%,transparent)] bg-[length:1rem_1rem]' \n }\n },\n animated: {\n true: {\n indicator: 'animate-progress-stripes'\n }\n }\n },\n defaultVariants: {\n size: 'md',\n variant: 'default',\n }\n});\n\n/** Props for the Progress component */\nexport interface ProgressProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseProgress.Root>, 'value'>,\n VariantProps<typeof progressVariants> {\n className?: string;\n /** Progress value from 0 to 100 */\n value?: number | null;\n /** @deprecated Use `labelPosition` instead */\n showLabel?: boolean;\n /** Where to display the percentage label */\n labelPosition?: 'inside' | 'outside' | 'none';\n /** Descriptive text label shown above the progress bar */\n label?: string;\n}\n\nconst Progress = React.forwardRef<React.ElementRef<typeof BaseProgress.Root>, ProgressProps>(\n ({ className, value, size, variant, striped, animated, showLabel = false, labelPosition = 'none', label, ...props }, ref) => {\n const { base, root, indicator, labelContainer, innerLabel } = progressVariants({ size, variant, striped, animated });\n \n // Auto-enable striped if string animated is true, unless explicitly turned off\n const isStriped = striped !== undefined ? striped : animated;\n const { indicator: finalIndicator } = progressVariants({ size, variant, striped: isStriped, animated });\n\n const displayValue = value ?? 0;\n\n return (\n <div className={base({ className })}>\n {(labelPosition === 'outside' || label) && (\n <div className={labelContainer()}>\n {label && <span>{label}</span>}\n {labelPosition === 'outside' && <span>{Math.round(displayValue)}%</span>}\n </div>\n )}\n <BaseProgress.Root\n ref={ref}\n className={root()}\n value={value ?? null}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-valuenow={displayValue}\n aria-label={label ?? 'Progress'}\n {...props}\n >\n <BaseProgress.Indicator \n className={finalIndicator()} \n style={{ transform: `translateX(-${100 - displayValue}%)` }} \n >\n {labelPosition === 'inside' && displayValue > 5 && (\n <span className={innerLabel()}>{Math.round(displayValue)}%</span>\n )}\n </BaseProgress.Indicator>\n </BaseProgress.Root>\n </div>\n )\n }\n)\nProgress.displayName = 'Progress';\n\nexport { Progress };\n"
773
810
  }
774
811
  ]
775
812
  },
@@ -783,13 +820,13 @@
783
820
  "button"
784
821
  ],
785
822
  "files": [
786
- {
787
- "path": "src/components/ui/qrcode/index.ts",
788
- "content": "export { QRCode } from './QRCode';\nexport type { QRCodeProps, QRCodeLevel, QRCodeRenderer, QRCodeImageSettings } from './QRCode';\n"
789
- },
790
823
  {
791
824
  "path": "src/components/ui/qrcode/QRCode.tsx",
792
825
  "content": "import * as React from 'react';\nimport { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';\nimport { Download } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { Button } from '../button/Button';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\nexport type QRCodeLevel = 'L' | 'M' | 'Q' | 'H';\nexport type QRCodeRenderer = 'svg' | 'canvas';\n\nexport interface QRCodeImageSettings {\n src: string;\n width: number;\n height: number;\n excavate?: boolean;\n x?: number;\n y?: number;\n opacity?: number;\n crossOrigin?: 'anonymous' | 'use-credentials' | '';\n}\n\nconst PIXEL_SIZE = { sm: 96, md: 128, lg: 192, xl: 256 } as const;\n\n// ─── Props ────────────────────────────────────────────────────────────────────\nexport interface QRCodeProps {\n /** Value to encode — URL, text, etc. */\n value: string;\n /** Preset size: sm=96 md=128 lg=192 xl=256 */\n size?: keyof typeof PIXEL_SIZE;\n /** Override pixel dimension, takes precedence over size */\n pixelSize?: number;\n /** Error correction level — higher = more redundancy but denser pattern */\n level?: QRCodeLevel;\n /** Background color (hex/rgb string) */\n bgColor?: string;\n /** Foreground / module color (hex/rgb string) */\n fgColor?: string;\n /** Quiet zone modules around the QR code */\n marginSize?: number;\n /** Embed an image or logo in the center (use level H for best results) */\n imageSettings?: QRCodeImageSettings;\n /** SVG (crisp at any scale) or Canvas (downloadable as PNG) */\n renderer?: QRCodeRenderer;\n /** Accessible title for screen readers */\n title?: string;\n /** Label displayed above the QR code */\n label?: string;\n /** Caption displayed below the QR code */\n description?: string;\n /** Show a download button */\n downloadable?: boolean;\n /** Filename without extension used when downloading */\n downloadFilename?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\nexport const QRCode = React.forwardRef<HTMLDivElement, QRCodeProps>(({\n value,\n size = 'md',\n pixelSize,\n level = 'L',\n bgColor = '#ffffff',\n fgColor = '#000000',\n marginSize = 2,\n imageSettings,\n renderer = 'svg',\n title,\n label,\n description,\n downloadable = false,\n downloadFilename = 'qrcode',\n className,\n style,\n}, ref) => {\n const qrWrapperRef = React.useRef<HTMLDivElement>(null);\n const resolvedSize = pixelSize ?? PIXEL_SIZE[size];\n\n const handleDownload = React.useCallback(() => {\n const el = qrWrapperRef.current;\n if (!el) return;\n\n if (renderer === 'canvas') {\n const canvas = el.querySelector('canvas');\n if (!canvas) return;\n const link = document.createElement('a');\n link.download = `${downloadFilename}.png`;\n link.href = canvas.toDataURL('image/png');\n link.click();\n return;\n }\n\n const svg = el.querySelector('svg');\n if (!svg) return;\n const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });\n const url = URL.createObjectURL(blob);\n const link = document.createElement('a');\n link.download = `${downloadFilename}.svg`;\n link.href = url;\n link.click();\n URL.revokeObjectURL(url);\n }, [renderer, downloadFilename]);\n\n const qrProps = {\n value,\n size: resolvedSize,\n level,\n bgColor,\n fgColor,\n marginSize,\n imageSettings: imageSettings\n ? { excavate: false, ...imageSettings }\n : undefined,\n title: title ?? `QR Code: ${value}`,\n };\n\n return (\n <div ref={ref} className={cn('inline-flex flex-col items-center gap-2', className)} style={style}>\n {label && (\n <p className=\"text-sm font-medium text-foreground\">{label}</p>\n )}\n <div ref={qrWrapperRef} className=\"rounded-xl overflow-hidden border border-border shadow-sm\">\n {renderer === 'svg' ? <QRCodeSVG {...qrProps} /> : <QRCodeCanvas {...qrProps} />}\n </div>\n {description && (\n <p className=\"text-xs text-muted-foreground text-center max-w-[200px]\">{description}</p>\n )}\n {downloadable && (\n <Button\n size=\"sm\"\n variant=\"outline\"\n leftIcon={<Download className=\"w-3.5 h-3.5\" />}\n onClick={handleDownload}\n >\n Download\n </Button>\n )}\n </div>\n );\n});\n\nQRCode.displayName = 'QRCode';\n"
826
+ },
827
+ {
828
+ "path": "src/components/ui/qrcode/index.ts",
829
+ "content": "export { QRCode } from './QRCode';\nexport type { QRCodeProps, QRCodeLevel, QRCodeRenderer, QRCodeImageSettings } from './QRCode';\n"
793
830
  }
794
831
  ]
795
832
  },
@@ -803,7 +840,7 @@
803
840
  "files": [
804
841
  {
805
842
  "path": "src/components/ui/radio/Radio.tsx",
806
- "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 flex items-center justify-center',\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-2.5 w-2.5' },\r\n md: { root: 'h-5 w-5', cardCircle: 'h-5 w-5', dot: 'h-3 w-3' },\r\n lg: { root: 'h-6 w-6', cardCircle: 'h-6 w-6', dot: 'h-3.5 w-3.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"
843
+ "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 flex items-center justify-center',\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-2.5 w-2.5' },\n md: { root: 'h-5 w-5', cardCircle: 'h-5 w-5', dot: 'h-3 w-3' },\n lg: { root: 'h-6 w-6', cardCircle: 'h-6 w-6', dot: 'h-3.5 w-3.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"
807
844
  }
808
845
  ]
809
846
  },
@@ -817,7 +854,7 @@
817
854
  "files": [
818
855
  {
819
856
  "path": "src/components/ui/radio-group/RadioGroup.tsx",
820
- "content": "import * as React from 'react';\r\nimport { RadioGroup as BaseRadioGroup } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst radioGroupVariants = tv({\r\n base: 'grid gap-2',\r\n variants: {\r\n orientation: {\r\n vertical: 'grid-flow-row',\r\n horizontal: 'grid-flow-col auto-cols-auto',\r\n },\r\n },\r\n defaultVariants: {\r\n orientation: 'vertical',\r\n },\r\n});\r\n\r\n/** Props for the RadioGroup component */\r\nexport interface RadioGroupProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseRadioGroup>,\r\n VariantProps<typeof radioGroupVariants> {\r\n className?: string;\r\n}\r\n\r\nconst RadioGroup = React.forwardRef<React.ElementRef<typeof BaseRadioGroup>, RadioGroupProps>(\r\n ({ className, orientation, ...props }, ref) => {\r\n return (\r\n <BaseRadioGroup\r\n ref={ref}\r\n className={radioGroupVariants({ orientation, className })}\r\n aria-orientation={orientation ?? 'vertical'}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\n\r\nRadioGroup.displayName = 'RadioGroup';\r\n\r\nexport { RadioGroup };\r\n"
857
+ "content": "import * as React from 'react';\nimport { RadioGroup as BaseRadioGroup } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst radioGroupVariants = tv({\n base: 'grid gap-2',\n variants: {\n orientation: {\n vertical: 'grid-flow-row',\n horizontal: 'grid-flow-col auto-cols-auto',\n },\n },\n defaultVariants: {\n orientation: 'vertical',\n },\n});\n\n/** Props for the RadioGroup component */\nexport interface RadioGroupProps\n extends React.ComponentPropsWithoutRef<typeof BaseRadioGroup>,\n VariantProps<typeof radioGroupVariants> {\n className?: string;\n}\n\nconst RadioGroup = React.forwardRef<React.ElementRef<typeof BaseRadioGroup>, RadioGroupProps>(\n ({ className, orientation, ...props }, ref) => {\n return (\n <BaseRadioGroup\n ref={ref}\n className={radioGroupVariants({ orientation, className })}\n aria-orientation={orientation ?? 'vertical'}\n {...props}\n />\n );\n }\n);\n\nRadioGroup.displayName = 'RadioGroup';\n\nexport { RadioGroup };\n"
821
858
  }
822
859
  ]
823
860
  },
@@ -845,7 +882,7 @@
845
882
  "files": [
846
883
  {
847
884
  "path": "src/components/ui/resizable/Resizable.tsx",
848
- "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"
885
+ "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"
849
886
  }
850
887
  ]
851
888
  },
@@ -873,11 +910,11 @@
873
910
  "files": [
874
911
  {
875
912
  "path": "src/components/ui/select/MultiSelect.tsx",
876
- "content": "import * as React from 'react';\r\nimport { Select as BaseSelect } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronDown, Check, X } from 'lucide-react';\r\n\r\nconst multiSelectVariants = tv({\r\n slots: {\r\n trigger: 'flex min-h-10 w-full items-start justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer gap-2',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n viewport: 'p-1',\r\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\r\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\r\n }\r\n});\r\n\r\nconst { trigger, content, viewport, item, icon } = multiSelectVariants();\r\n\r\n/** Props for the MultiSelect component */\r\nexport interface MultiSelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\r\n /** Label text displayed above the select trigger */\r\n label?: string;\r\n /** Helper text displayed below the select (hidden when error is present) */\r\n description?: string;\r\n /** Error message displayed below the select; also applies danger styling */\r\n error?: string;\r\n /** Placeholder shown when no option is selected */\r\n placeholder?: string;\r\n /** Array of selectable options */\r\n options: { label: string; value: string }[];\r\n id?: string;\r\n className?: string;\r\n /** Controlled selected values */\r\n value?: string[];\r\n /** Initial selected values for uncontrolled usage */\r\n defaultValue?: string[];\r\n /** Whether a clear button is shown when value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected values change */\r\n onChange?: (values: string[]) => void;\r\n /** Text shown when the options array is empty */\r\n emptyText?: string;\r\n /** Accessible label for the clear button */\r\n clearLabel?: string;\r\n /** Max tags displayed before showing \"+N more\" */\r\n maxTags?: number;\r\n}\r\n\r\nconst MultiSelect = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, MultiSelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', maxTags = 2, ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValues, setSelectedValues] = React.useState<string[]>(value ?? defaultValue ?? []);\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValues(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n const newValues = selectedValues.includes(val)\r\n ? selectedValues.filter((v) => v !== val)\r\n : [...selectedValues, val];\r\n setSelectedValues(newValues);\r\n onChange?.(newValues);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValues([]);\r\n onChange?.([]);\r\n };\r\n\r\n const selectedLabels = selectedValues\r\n .map((v) => options.find((o) => o.value === v)?.label)\r\n .filter(Boolean) as string[];\r\n\r\n const displayLabels = selectedLabels.slice(0, maxTags);\r\n const moreCount = selectedLabels.length - maxTags;\r\n\r\n return (\r\n <div className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <div className=\"relative w-full\">\r\n <BaseSelect.Root\r\n value={undefined}\r\n onValueChange={handleValueChange as (value: unknown) => void}\r\n {...props}\r\n >\r\n <BaseSelect.Trigger\r\n ref={(node) => {\r\n triggerRef.current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.RefObject <HTMLButtonElement | null>).current = node;\r\n }}\r\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\r\n id={id}\r\n >\r\n <div className=\"flex flex-wrap gap-1.5 items-center\">\r\n {selectedLabels.length === 0 ? (\r\n <span className=\"text-muted-foreground\">{placeholder}</span>\r\n ) : (\r\n <>\r\n {displayLabels.map((label) => (\r\n <span\r\n key={label}\r\n className=\"inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground\"\r\n >\r\n {label}\r\n </span>\r\n ))}\r\n {moreCount > 0 && (\r\n <span className=\"text-xs text-muted-foreground\">\r\n +{moreCount} more\r\n </span>\r\n )}\r\n </>\r\n )}\r\n </div>\r\n <BaseSelect.Icon>\r\n { selectedValues.length <= 0 &&<ChevronDown className={icon()} />}\r\n </BaseSelect.Icon>\r\n </BaseSelect.Trigger>\r\n <BaseSelect.Portal>\r\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\r\n <BaseSelect.Popup className={content()}>\r\n <div className={viewport()}>\r\n {options.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\r\n {emptyText}\r\n </div>\r\n ) : (\r\n options.map((option) => (\r\n <button\r\n key={option.value}\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(option.value);\r\n }}\r\n className={cn(\r\n item(),\r\n 'text-left',\r\n selectedValues.includes(option.value) && 'bg-muted text-foreground'\r\n )}\r\n >\r\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\r\n {selectedValues.includes(option.value) && <Check className=\"h-4 w-4\" />}\r\n </span>\r\n <span>{option.label}</span>\r\n </button>\r\n ))\r\n )}\r\n </div>\r\n </BaseSelect.Popup>\r\n </BaseSelect.Positioner>\r\n </BaseSelect.Portal>\r\n </BaseSelect.Root>\r\n\r\n {clearable && selectedValues.length > 0 && (\r\n <button\r\n type=\"button\"\r\n aria-label={clearLabel}\r\n onMouseDown={handleClear}\r\n className=\"cursor-pointer absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors z-10\"\r\n >\r\n <X className=\"h-3 w-3\" />\r\n </button>\r\n )}\r\n </div>\r\n\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nMultiSelect.displayName = 'MultiSelect';\r\n\r\nexport { MultiSelect };\r\n"
913
+ "content": "import * as React from 'react';\nimport { Select as BaseSelect } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { ChevronDown, Check, X } from 'lucide-react';\n\nconst multiSelectVariants = tv({\n slots: {\n trigger: 'flex min-h-10 w-full items-start justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer gap-2',\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\n viewport: 'p-1',\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\n }\n});\n\nconst { trigger, content, viewport, item, icon } = multiSelectVariants();\n\n/** Props for the MultiSelect component */\nexport interface MultiSelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\n /** Label text displayed above the select trigger */\n label?: string;\n /** Helper text displayed below the select (hidden when error is present) */\n description?: string;\n /** Error message displayed below the select; also applies danger styling */\n error?: string;\n /** Placeholder shown when no option is selected */\n placeholder?: string;\n /** Array of selectable options */\n options: { label: string; value: string }[];\n id?: string;\n className?: string;\n /** Controlled selected values */\n value?: string[];\n /** Initial selected values for uncontrolled usage */\n defaultValue?: string[];\n /** Whether a clear button is shown when value is selected */\n clearable?: boolean;\n /** Callback fired when the selected values change */\n onChange?: (values: string[]) => void;\n /** Text shown when the options array is empty */\n emptyText?: string;\n /** Accessible label for the clear button */\n clearLabel?: string;\n /** Max tags displayed before showing \"+N more\" */\n maxTags?: number;\n}\n\nconst MultiSelect = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, MultiSelectProps>(\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', maxTags = 2, ...props }, ref) => {\n const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n const [selectedValues, setSelectedValues] = React.useState<string[]>(value ?? defaultValue ?? []);\n\n React.useEffect(() => {\n if (value !== undefined) setSelectedValues(value);\n }, [value]);\n\n const handleValueChange = (val: string) => {\n const newValues = selectedValues.includes(val)\n ? selectedValues.filter((v) => v !== val)\n : [...selectedValues, val];\n setSelectedValues(newValues);\n onChange?.(newValues);\n };\n\n const handleClear = (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setSelectedValues([]);\n onChange?.([]);\n };\n\n const selectedLabels = selectedValues\n .map((v) => options.find((o) => o.value === v)?.label)\n .filter(Boolean) as string[];\n\n const displayLabels = selectedLabels.slice(0, maxTags);\n const moreCount = selectedLabels.length - maxTags;\n\n return (\n <div className=\"flex flex-col gap-1.5 w-full\">\n {label && (\n <label className=\"text-sm font-medium text-foreground\">\n {label}\n </label>\n )}\n\n <div className=\"relative w-full\">\n <BaseSelect.Root\n value={undefined}\n onValueChange={handleValueChange as (value: unknown) => void}\n {...props}\n >\n <BaseSelect.Trigger\n ref={(node) => {\n triggerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.RefObject <HTMLButtonElement | null>).current = node;\n }}\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\n id={id}\n >\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {selectedLabels.length === 0 ? (\n <span className=\"text-muted-foreground\">{placeholder}</span>\n ) : (\n <>\n {displayLabels.map((label) => (\n <span\n key={label}\n className=\"inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground\"\n >\n {label}\n </span>\n ))}\n {moreCount > 0 && (\n <span className=\"text-xs text-muted-foreground\">\n +{moreCount} more\n </span>\n )}\n </>\n )}\n </div>\n <BaseSelect.Icon>\n { selectedValues.length <= 0 &&<ChevronDown className={icon()} />}\n </BaseSelect.Icon>\n </BaseSelect.Trigger>\n <BaseSelect.Portal>\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\n <BaseSelect.Popup className={content()}>\n <div className={viewport()}>\n {options.length === 0 ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\n {emptyText}\n </div>\n ) : (\n options.map((option) => (\n <button\n key={option.value}\n type=\"button\"\n onClick={(e) => {\n e.preventDefault();\n handleValueChange(option.value);\n }}\n className={cn(\n item(),\n 'text-left',\n selectedValues.includes(option.value) && 'bg-muted text-foreground'\n )}\n >\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n {selectedValues.includes(option.value) && <Check className=\"h-4 w-4\" />}\n </span>\n <span>{option.label}</span>\n </button>\n ))\n )}\n </div>\n </BaseSelect.Popup>\n </BaseSelect.Positioner>\n </BaseSelect.Portal>\n </BaseSelect.Root>\n\n {clearable && selectedValues.length > 0 && (\n <button\n type=\"button\"\n aria-label={clearLabel}\n onMouseDown={handleClear}\n className=\"cursor-pointer absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors z-10\"\n >\n <X className=\"h-3 w-3\" />\n </button>\n )}\n </div>\n\n {description && !error && (\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n </div>\n );\n }\n);\n\nMultiSelect.displayName = 'MultiSelect';\n\nexport { MultiSelect };\n"
877
914
  },
878
915
  {
879
916
  "path": "src/components/ui/select/Select.tsx",
880
- "content": "import * as React from 'react';\r\nimport { Select as BaseSelect } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronDown, Check, X } from 'lucide-react';\r\n\r\nconst selectVariants = tv({\r\n slots: {\r\n trigger: 'flex h-10 w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n viewport: 'p-1',\r\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\r\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\r\n }\r\n});\r\n\r\nconst { trigger, content, viewport, item, icon } = selectVariants();\r\n\r\n/** Props for the Select component */\r\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\r\n /** Label text displayed above the select trigger */\r\n label?: string;\r\n /** Helper text displayed below the select (hidden when error is present) */\r\n description?: string;\r\n /** Error message displayed below the select; also applies danger styling */\r\n error?: string;\r\n /** Placeholder shown when no option is selected */\r\n placeholder?: string;\r\n /** Array of selectable options */\r\n options: { label: string; value: string }[];\r\n id?: string;\r\n className?: string;\r\n /** Controlled selected value */\r\n value?: string;\r\n /** Initial selected value for uncontrolled usage */\r\n defaultValue?: string;\r\n /** Whether a clear button is shown when a value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected value changes */\r\n onChange?: (value: string) => void;\r\n /** Text shown when the options array is empty */\r\n emptyText?: string;\r\n /** Accessible label for the clear button */\r\n clearLabel?: string;\r\n}\r\n\r\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValue(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n setSelectedValue(val);\r\n onChange?.(val);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValue('');\r\n onChange?.('');\r\n };\r\n\r\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\r\n\r\n return (\r\n <div className=\"flex flex-col gap-1.5\">\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n {/*\r\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\r\n * → click X không bao giờ bubble lên Trigger → popup không mở\r\n */}\r\n <BaseSelect.Root\r\n value={value}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange as (value: unknown) => void}\r\n {...props}\r\n >\r\n <BaseSelect.Trigger\r\n ref={(node) => {\r\n triggerRef.current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\r\n }}\r\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\r\n id={id}\r\n >\r\n <span className={cn('truncate', selectedLabel ? 'text-foreground' : 'text-muted-foreground')}>\r\n {selectedLabel ?? placeholder}\r\n </span>\r\n \r\n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\r\n {clearable && selectedValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label={clearLabel}\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors z-10 pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseSelect.Icon>\r\n <ChevronDown className={icon()} />\r\n </BaseSelect.Icon>\r\n )}\r\n </div>\r\n </BaseSelect.Trigger>\r\n <BaseSelect.Portal>\r\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\r\n <BaseSelect.Popup className={content()}>\r\n <div className={viewport()}>\r\n {options.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\r\n {emptyText}\r\n </div>\r\n ) : (\r\n options.map((option) => (\r\n <BaseSelect.Item key={option.value} value={option.value} className={item()}>\r\n <BaseSelect.ItemIndicator className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\r\n {option.value === selectedValue && <Check className=\"h-4 w-4\" />}\r\n </BaseSelect.ItemIndicator>\r\n <BaseSelect.ItemText>{option.label}</BaseSelect.ItemText>\r\n </BaseSelect.Item>\r\n ))\r\n )}\r\n </div>\r\n </BaseSelect.Popup>\r\n </BaseSelect.Positioner>\r\n </BaseSelect.Portal>\r\n </BaseSelect.Root>\r\n\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSelect.displayName = 'Select';\r\n\r\nexport { Select };\r\n"
917
+ "content": "import * as React from 'react';\nimport { Select as BaseSelect } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { ChevronDown, Check, X } from 'lucide-react';\n\nconst selectVariants = tv({\n slots: {\n trigger: 'flex h-10 w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\n viewport: 'p-1',\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\n }\n});\n\nconst { trigger, content, viewport, item, icon } = selectVariants();\n\n/** Props for the Select component */\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\n /** Label text displayed above the select trigger */\n label?: string;\n /** Helper text displayed below the select (hidden when error is present) */\n description?: string;\n /** Error message displayed below the select; also applies danger styling */\n error?: string;\n /** Placeholder shown when no option is selected */\n placeholder?: string;\n /** Array of selectable options */\n options: { label: string; value: string }[];\n id?: string;\n className?: string;\n /** Controlled selected value */\n value?: string;\n /** Initial selected value for uncontrolled usage */\n defaultValue?: string;\n /** Whether a clear button is shown when a value is selected */\n clearable?: boolean;\n /** Callback fired when the selected value changes */\n onChange?: (value: string) => void;\n /** Text shown when the options array is empty */\n emptyText?: string;\n /** Accessible label for the clear button */\n clearLabel?: string;\n}\n\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', ...props }, ref) => {\n const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\n\n React.useEffect(() => {\n if (value !== undefined) setSelectedValue(value);\n }, [value]);\n\n const handleValueChange = (val: string) => {\n setSelectedValue(val);\n onChange?.(val);\n };\n\n const handleClear = (e: React.SyntheticEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setSelectedValue('');\n onChange?.('');\n };\n\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n {label && (\n <label className=\"text-sm font-medium text-foreground\">\n {label}\n </label>\n )}\n\n {/*\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\n * → click X không bao giờ bubble lên Trigger → popup không mở\n */}\n <BaseSelect.Root\n value={value}\n defaultValue={defaultValue}\n onValueChange={handleValueChange as (value: unknown) => void}\n {...props}\n >\n <BaseSelect.Trigger\n ref={(node) => {\n triggerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\n }}\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\n id={id}\n >\n <span className={cn('truncate', selectedLabel ? 'text-foreground' : 'text-muted-foreground')}>\n {selectedLabel ?? placeholder}\n </span>\n \n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\n {clearable && selectedValue ? (\n <span\n role=\"button\"\n aria-label={clearLabel}\n onPointerDown={(e) => {\n e.stopPropagation();\n handleClear(e);\n }}\n onClick={(e) => e.stopPropagation()}\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors z-10 pointer-events-auto\"\n >\n <X className=\"h-3.5 w-3.5\" />\n </span>\n ) : (\n <BaseSelect.Icon>\n <ChevronDown className={icon()} />\n </BaseSelect.Icon>\n )}\n </div>\n </BaseSelect.Trigger>\n <BaseSelect.Portal>\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\n <BaseSelect.Popup className={content()}>\n <div className={viewport()}>\n {options.length === 0 ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\n {emptyText}\n </div>\n ) : (\n options.map((option) => (\n <BaseSelect.Item key={option.value} value={option.value} className={item()}>\n <BaseSelect.ItemIndicator className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n {option.value === selectedValue && <Check className=\"h-4 w-4\" />}\n </BaseSelect.ItemIndicator>\n <BaseSelect.ItemText>{option.label}</BaseSelect.ItemText>\n </BaseSelect.Item>\n ))\n )}\n </div>\n </BaseSelect.Popup>\n </BaseSelect.Positioner>\n </BaseSelect.Portal>\n </BaseSelect.Root>\n\n {description && !error && (\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n </div>\n );\n }\n);\n\nSelect.displayName = 'Select';\n\nexport { Select };\n"
881
918
  }
882
919
  ]
883
920
  },
@@ -890,7 +927,7 @@
890
927
  "files": [
891
928
  {
892
929
  "path": "src/components/ui/separator/Separator.tsx",
893
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst separatorVariants = tv({\r\n base: 'shrink-0 bg-border',\r\n variants: {\r\n orientation: {\r\n horizontal: 'h-[1px] w-full',\r\n vertical: 'h-full w-[1px]',\r\n },\r\n variant: {\r\n default: 'bg-border',\r\n muted: 'bg-muted',\r\n primary: 'bg-primary/20',\r\n dashed: 'bg-transparent border-0 border-border border-dashed',\r\n },\r\n },\r\n compoundVariants: [\r\n { orientation: 'horizontal', variant: 'dashed', className: 'border-t h-0' },\r\n { orientation: 'vertical', variant: 'dashed', className: 'border-l w-0' },\r\n ],\r\n defaultVariants: {\r\n orientation: 'horizontal',\r\n variant: 'default',\r\n },\r\n});\r\n\r\n/** Props for the Separator component */\r\nexport interface SeparatorProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof separatorVariants> {\r\n /** When true, the separator is purely visual and hidden from assistive technology */\r\n decorative?: boolean;\r\n}\r\n\r\nconst Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(\r\n ({ className, orientation = 'horizontal', variant, decorative = true, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role={decorative ? 'none' : 'separator'}\r\n aria-orientation={decorative ? undefined : orientation}\r\n className={separatorVariants({ orientation, variant, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nSeparator.displayName = 'Separator';\r\n\r\nexport { Separator, separatorVariants };\r\n"
930
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst separatorVariants = tv({\n base: 'shrink-0 bg-border',\n variants: {\n orientation: {\n horizontal: 'h-[1px] w-full',\n vertical: 'h-full w-[1px]',\n },\n variant: {\n default: 'bg-border',\n muted: 'bg-muted',\n primary: 'bg-primary/20',\n dashed: 'bg-transparent border-0 border-border border-dashed',\n },\n },\n compoundVariants: [\n { orientation: 'horizontal', variant: 'dashed', className: 'border-t h-0' },\n { orientation: 'vertical', variant: 'dashed', className: 'border-l w-0' },\n ],\n defaultVariants: {\n orientation: 'horizontal',\n variant: 'default',\n },\n});\n\n/** Props for the Separator component */\nexport interface SeparatorProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof separatorVariants> {\n /** When true, the separator is purely visual and hidden from assistive technology */\n decorative?: boolean;\n}\n\nconst Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(\n ({ className, orientation = 'horizontal', variant, decorative = true, ...props }, ref) => (\n <div\n ref={ref}\n role={decorative ? 'none' : 'separator'}\n aria-orientation={decorative ? undefined : orientation}\n className={separatorVariants({ orientation, variant, className })}\n {...props}\n />\n )\n);\nSeparator.displayName = 'Separator';\n\nexport { Separator, separatorVariants };\n"
894
931
  }
895
932
  ]
896
933
  },
@@ -903,7 +940,7 @@
903
940
  "files": [
904
941
  {
905
942
  "path": "src/components/ui/sheet/Sheet.tsx",
906
- "content": "import * as React from 'react';\r\nimport {\r\n Drawer as SheetRoot,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n type DrawerContentProps,\r\n} from '../drawer/Drawer';\r\n\r\n/** Sheet is a Drawer that defaults to direction=\"right\" and size=\"lg\". */\r\n\r\nconst Sheet = SheetRoot;\r\nconst SheetTrigger = DrawerTrigger;\r\nconst SheetClose = DrawerClose;\r\n\r\nconst SheetContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ direction = 'right', size = 'lg', backdropBlur = false, ...props }, ref) => (\r\n <DrawerContent ref={ref} direction={direction} size={size} backdropBlur={backdropBlur} {...props} />\r\n ),\r\n);\r\nSheetContent.displayName = 'SheetContent';\r\n\r\nconst SheetHeader = DrawerHeader;\r\nconst SheetTitle = DrawerTitle;\r\nconst SheetDescription = DrawerDescription;\r\nconst SheetBody = DrawerBody;\r\nconst SheetFooter = DrawerFooter;\r\n\r\nexport {\r\n Sheet,\r\n SheetTrigger,\r\n SheetContent,\r\n SheetHeader,\r\n SheetTitle,\r\n SheetDescription,\r\n SheetBody,\r\n SheetFooter,\r\n SheetClose,\r\n};\r\n"
943
+ "content": "import * as React from 'react';\nimport {\n Drawer as SheetRoot,\n DrawerTrigger,\n DrawerContent,\n DrawerHeader,\n DrawerTitle,\n DrawerDescription,\n DrawerBody,\n DrawerFooter,\n DrawerClose,\n type DrawerContentProps,\n} from '../drawer/Drawer';\n\n/** Sheet is a Drawer that defaults to direction=\"right\" and size=\"lg\". */\n\nconst Sheet = SheetRoot;\nconst SheetTrigger = DrawerTrigger;\nconst SheetClose = DrawerClose;\n\nconst SheetContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\n ({ direction = 'right', size = 'lg', backdropBlur = false, ...props }, ref) => (\n <DrawerContent ref={ref} direction={direction} size={size} backdropBlur={backdropBlur} {...props} />\n ),\n);\nSheetContent.displayName = 'SheetContent';\n\nconst SheetHeader = DrawerHeader;\nconst SheetTitle = DrawerTitle;\nconst SheetDescription = DrawerDescription;\nconst SheetBody = DrawerBody;\nconst SheetFooter = DrawerFooter;\n\nexport {\n Sheet,\n SheetTrigger,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetDescription,\n SheetBody,\n SheetFooter,\n SheetClose,\n};\n"
907
944
  }
908
945
  ]
909
946
  },
@@ -929,7 +966,7 @@
929
966
  },
930
967
  {
931
968
  "path": "src/components/ui/sidebar/SidebarLayout.tsx",
932
- "content": "import * as React from 'react';\r\nimport { PanelLeft } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar, SIDEBAR_WIDTH_DEFAULT } from './SidebarContext';\r\n\r\n// ─── SidebarTrigger ───────────────────────────────────────────────────────────\r\n\r\nexport const SidebarTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ButtonHTMLAttributes<HTMLButtonElement>\r\n>(({ className, onClick, ...props }, ref) => {\r\n const { toggleSidebar } = useSidebar();\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"trigger\"\r\n onClick={(e) => {\r\n toggleSidebar();\r\n onClick?.(e);\r\n }}\r\n className={cn('inline-flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className)}\r\n title=\"Toggle Sidebar (⌘B)\"\r\n {...props}\r\n >\r\n <PanelLeft className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Toggle Sidebar</span>\r\n </button>\r\n );\r\n});\r\nSidebarTrigger.displayName = 'SidebarTrigger';\r\n\r\n// ─── Sidebar ──────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the Sidebar panel */\r\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Which side of the viewport the sidebar attaches to */\r\n side?: 'left' | 'right';\r\n /** Visual variant: default border, floating card, or inset with background */\r\n variant?: 'sidebar' | 'floating' | 'inset';\r\n /** Collapse behavior: slide offcanvas, shrink to icons, or non-collapsible */\r\n collapsible?: 'offcanvas' | 'icon' | 'none';\r\n}\r\n\r\nexport const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(\r\n ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ...props }, ref) => {\r\n const { state, isMobile, openMobile, setOpenMobile, sidebarWidth } = useSidebar();\r\n\r\n if (collapsible === 'none') {\r\n return (\r\n <aside\r\n ref={ref}\r\n className={cn('flex h-screen flex-col bg-sidebar border-r border-sidebar-border', className)}\r\n style={{ width: SIDEBAR_WIDTH_DEFAULT }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n\r\n if (isMobile) {\r\n return (\r\n <>\r\n {openMobile && (\r\n <div\r\n className=\"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm motion-safe:transition-opacity\"\r\n onClick={() => setOpenMobile(false)}\r\n />\r\n )}\r\n <aside\r\n ref={ref}\r\n className={cn(\r\n 'fixed inset-y-0 z-50 flex flex-col bg-sidebar border-r border-sidebar-border shadow-xl',\r\n 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-in-out',\r\n side === 'left' ? 'left-0' : 'right-0',\r\n openMobile\r\n ? 'translate-x-0'\r\n : side === 'left'\r\n ? '-translate-x-full'\r\n : 'translate-x-full',\r\n className\r\n )}\r\n style={{ width: sidebarWidth }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <aside\r\n ref={ref}\r\n data-state={state}\r\n data-collapsible={state === 'collapsed' ? collapsible : ''}\r\n data-variant={variant}\r\n data-side={side}\r\n className={cn(\r\n 'group relative flex h-full w-full flex-col bg-sidebar text-sidebar-foreground',\r\n 'overflow-hidden shrink-0',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n);\r\nSidebar.displayName = 'Sidebar';\r\n\r\n// ─── SidebarRail — kept for API compatibility, resize is handled by ResizableHandle ──\r\n\r\nexport const SidebarRail = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n (_props, _ref) => null,\r\n);\r\nSidebarRail.displayName = 'SidebarRail';\r\n\r\n// ─── SidebarInset ─────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('relative flex flex-1 flex-col overflow-hidden min-w-0 bg-background', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarInset.displayName = 'SidebarInset';\r\n\r\n// ─── Layout: Header / Content / Footer ───────────────────────────────────────\r\n\r\nexport const SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"header\"\r\n className={cn('flex flex-col gap-2 p-2 shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarHeader.displayName = 'SidebarHeader';\r\n\r\nexport const SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { isMobile } = useSidebar();\r\n if (isMobile) {\r\n return null;\r\n }\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"footer\"\r\n className={cn('flex flex-col gap-2 p-2 mt-auto shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n}\r\n);\r\nSidebarFooter.displayName = 'SidebarFooter';\r\n\r\nexport const SidebarContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"content\"\r\n className={cn('flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden py-2', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarContent.displayName = 'SidebarContent';\r\n\r\nexport const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"separator\"\r\n className={cn('mx-2 h-px border-t border-sidebar-border', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarSeparator.displayName = 'SidebarSeparator';\r\n"
969
+ "content": "import * as React from 'react';\nimport { PanelLeft } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { useSidebar, SIDEBAR_WIDTH_DEFAULT } from './SidebarContext';\n\n// ─── SidebarTrigger ───────────────────────────────────────────────────────────\n\nexport const SidebarTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ButtonHTMLAttributes<HTMLButtonElement>\n>(({ className, onClick, ...props }, ref) => {\n const { toggleSidebar } = useSidebar();\n return (\n <button\n ref={ref}\n type=\"button\"\n data-sidebar=\"trigger\"\n onClick={(e) => {\n toggleSidebar();\n onClick?.(e);\n }}\n className={cn('inline-flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className)}\n title=\"Toggle Sidebar (⌘B)\"\n {...props}\n >\n <PanelLeft className=\"h-4 w-4\" />\n <span className=\"sr-only\">Toggle Sidebar</span>\n </button>\n );\n});\nSidebarTrigger.displayName = 'SidebarTrigger';\n\n// ─── Sidebar ──────────────────────────────────────────────────────────────────\n\n/** Props for the Sidebar panel */\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\n /** Which side of the viewport the sidebar attaches to */\n side?: 'left' | 'right';\n /** Visual variant: default border, floating card, or inset with background */\n variant?: 'sidebar' | 'floating' | 'inset';\n /** Collapse behavior: slide offcanvas, shrink to icons, or non-collapsible */\n collapsible?: 'offcanvas' | 'icon' | 'none';\n}\n\nexport const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(\n ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ...props }, ref) => {\n const { state, isMobile, openMobile, setOpenMobile, sidebarWidth } = useSidebar();\n\n if (collapsible === 'none') {\n return (\n <aside\n ref={ref}\n className={cn('flex h-screen flex-col bg-sidebar border-r border-sidebar-border', className)}\n style={{ width: SIDEBAR_WIDTH_DEFAULT }}\n {...props}\n >\n {children}\n </aside>\n );\n }\n\n if (isMobile) {\n return (\n <>\n {openMobile && (\n <div\n className=\"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm motion-safe:transition-opacity\"\n onClick={() => setOpenMobile(false)}\n />\n )}\n <aside\n ref={ref}\n className={cn(\n 'fixed inset-y-0 z-50 flex flex-col bg-sidebar border-r border-sidebar-border shadow-xl',\n 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-in-out',\n side === 'left' ? 'left-0' : 'right-0',\n openMobile\n ? 'translate-x-0'\n : side === 'left'\n ? '-translate-x-full'\n : 'translate-x-full',\n className\n )}\n style={{ width: sidebarWidth }}\n {...props}\n >\n {children}\n </aside>\n </>\n );\n }\n\n return (\n <aside\n ref={ref}\n data-state={state}\n data-collapsible={state === 'collapsed' ? collapsible : ''}\n data-variant={variant}\n data-side={side}\n className={cn(\n 'group relative flex h-full w-full flex-col bg-sidebar text-sidebar-foreground',\n 'overflow-hidden shrink-0',\n className\n )}\n {...props}\n >\n {children}\n </aside>\n );\n }\n);\nSidebar.displayName = 'Sidebar';\n\n// ─── SidebarRail — kept for API compatibility, resize is handled by ResizableHandle ──\n\nexport const SidebarRail = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n (_props, _ref) => null,\n);\nSidebarRail.displayName = 'SidebarRail';\n\n// ─── SidebarInset ─────────────────────────────────────────────────────────────\n\nexport const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('relative flex flex-1 flex-col overflow-hidden min-w-0 bg-background', className)}\n {...props}\n />\n )\n);\nSidebarInset.displayName = 'SidebarInset';\n\n// ─── Layout: Header / Content / Footer ───────────────────────────────────────\n\nexport const SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"header\"\n className={cn('flex flex-col gap-2 p-2 shrink-0', className)}\n {...props}\n />\n )\n);\nSidebarHeader.displayName = 'SidebarHeader';\n\nexport const SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => {\n const { isMobile } = useSidebar();\n if (isMobile) {\n return null;\n }\n return (\n <div\n ref={ref}\n data-sidebar=\"footer\"\n className={cn('flex flex-col gap-2 p-2 mt-auto shrink-0', className)}\n {...props}\n />\n )\n}\n);\nSidebarFooter.displayName = 'SidebarFooter';\n\nexport const SidebarContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"content\"\n className={cn('flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden py-2', className)}\n {...props}\n />\n )\n);\nSidebarContent.displayName = 'SidebarContent';\n\nexport const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n data-sidebar=\"separator\"\n className={cn('mx-2 h-px border-t border-sidebar-border', className)}\n {...props}\n />\n )\n);\nSidebarSeparator.displayName = 'SidebarSeparator';\n"
933
970
  },
934
971
  {
935
972
  "path": "src/components/ui/sidebar/SidebarMenu.tsx",
@@ -950,7 +987,7 @@
950
987
  "files": [
951
988
  {
952
989
  "path": "src/components/ui/skeleton/Skeleton.tsx",
953
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst skeletonVariants = tv({\r\n base: 'animate-pulse rounded-md bg-secondary',\r\n variants: {\r\n variant: {\r\n default: 'bg-secondary',\r\n muted: 'bg-muted',\r\n },\r\n rounded: {\r\n none: 'rounded-none',\r\n sm: 'rounded-sm',\r\n md: 'rounded-md',\r\n lg: 'rounded-lg',\r\n full: 'rounded-full',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n rounded: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Skeleton component */\r\nexport interface SkeletonProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof skeletonVariants> {}\r\n\r\nconst Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(\r\n ({ className, variant, rounded, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={skeletonVariants({ variant, rounded, className })}\r\n aria-hidden=\"true\"\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSkeleton.displayName = 'Skeleton';\r\n\r\nexport { Skeleton, skeletonVariants };\r\n"
990
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst skeletonVariants = tv({\n base: 'animate-pulse rounded-md bg-secondary',\n variants: {\n variant: {\n default: 'bg-secondary',\n muted: 'bg-muted',\n },\n rounded: {\n none: 'rounded-none',\n sm: 'rounded-sm',\n md: 'rounded-md',\n lg: 'rounded-lg',\n full: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'default',\n rounded: 'md',\n },\n});\n\n/** Props for the Skeleton component */\nexport interface SkeletonProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof skeletonVariants> {}\n\nconst Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(\n ({ className, variant, rounded, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={skeletonVariants({ variant, rounded, className })}\n aria-hidden=\"true\"\n {...props}\n />\n );\n }\n);\nSkeleton.displayName = 'Skeleton';\n\nexport { Skeleton, skeletonVariants };\n"
954
991
  }
955
992
  ]
956
993
  },
@@ -977,7 +1014,7 @@
977
1014
  "files": [
978
1015
  {
979
1016
  "path": "src/components/ui/spinner/Spinner.tsx",
980
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst spinnerVariants = tv({\r\n base: 'animate-spin rounded-full border-2 border-current border-t-transparent',\r\n variants: {\r\n size: {\r\n xs: 'h-3 w-3 border-[1.5px]',\r\n sm: 'h-4 w-4 border-2',\r\n md: 'h-6 w-6 border-2',\r\n lg: 'h-8 w-8 border-3',\r\n xl: 'h-12 w-12 border-4',\r\n },\r\n variant: {\r\n primary: 'text-primary',\r\n secondary: 'text-secondary',\r\n white: 'text-white',\r\n muted: 'text-muted-foreground',\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'primary'\r\n }\r\n});\r\n\r\n/** Props for the Spinner component */\r\nexport interface SpinnerProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof spinnerVariants> {}\r\n\r\nconst Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(\r\n ({ className, size, variant, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={spinnerVariants({ size, variant, className })}\r\n role=\"status\"\r\n aria-label=\"Loading\"\r\n {...props}\r\n >\r\n <span className=\"sr-only\">Loading...</span>\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSpinner.displayName = 'Spinner';\r\n\r\nexport { Spinner };\r\n"
1017
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst spinnerVariants = tv({\n base: 'animate-spin rounded-full border-2 border-current border-t-transparent',\n variants: {\n size: {\n xs: 'h-3 w-3 border-[1.5px]',\n sm: 'h-4 w-4 border-2',\n md: 'h-6 w-6 border-2',\n lg: 'h-8 w-8 border-3',\n xl: 'h-12 w-12 border-4',\n },\n variant: {\n primary: 'text-primary',\n secondary: 'text-secondary',\n white: 'text-white',\n muted: 'text-muted-foreground',\n }\n },\n defaultVariants: {\n size: 'md',\n variant: 'primary'\n }\n});\n\n/** Props for the Spinner component */\nexport interface SpinnerProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof spinnerVariants> {}\n\nconst Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(\n ({ className, size, variant, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={spinnerVariants({ size, variant, className })}\n role=\"status\"\n aria-label=\"Loading\"\n {...props}\n >\n <span className=\"sr-only\">Loading...</span>\n </div>\n );\n }\n);\n\nSpinner.displayName = 'Spinner';\n\nexport { Spinner };\n"
981
1018
  }
982
1019
  ]
983
1020
  },
@@ -991,7 +1028,7 @@
991
1028
  "files": [
992
1029
  {
993
1030
  "path": "src/components/ui/switch/Switch.tsx",
994
- "content": "import * as React from 'react';\r\nimport { Switch as BaseSwitch } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst switchVariants = tv({\r\n slots: {\r\n root: 'group inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-unchecked:bg-switch-background',\r\n thumb: 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-checked:translate-x-5 data-unchecked:translate-x-0',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n root: 'h-5 w-9',\r\n thumb: 'h-4 w-4 data-checked:translate-x-4',\r\n },\r\n md: {\r\n root: 'h-6 w-11',\r\n thumb: 'h-5 w-5 data-checked:translate-x-5',\r\n }\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\n\r\n\r\n/** Props for the Switch component */\r\nexport interface SwitchProps\r\n extends Omit<BaseSwitch.Root.Props, 'className'>,\r\n VariantProps<typeof switchVariants> {\r\n /** Text label displayed next to the switch */\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Switch = React.forwardRef<React.ElementRef<typeof BaseSwitch.Root>, SwitchProps>(\r\n ({ className, size, label, id, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const switchId = id || defaultId;\r\n\r\n const { root, thumb } = switchVariants({ size });\r\n\r\n return (\r\n <div className={cn(\" flex items-center gap-2 w-fit\", props.disabled && \"opacity-40 cursor-not-allowed\")}>\r\n <BaseSwitch.Root\r\n ref={ref}\r\n id={switchId}\r\n className={root({ className: cn(!props.disabled && \"cursor-pointer\", className) })}\r\n {...props}\r\n >\r\n <BaseSwitch.Thumb className={thumb()} />\r\n </BaseSwitch.Root>\r\n {label && (\r\n <label\r\n htmlFor={switchId}\r\n className={cn(\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\", props.disabled && \"cursor-not-allowed\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSwitch.displayName = 'Switch';\r\n\r\nexport { Switch };\r\n"
1031
+ "content": "import * as React from 'react';\nimport { Switch as BaseSwitch } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst switchVariants = tv({\n slots: {\n root: 'group inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-unchecked:bg-switch-background',\n thumb: 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-checked:translate-x-5 data-unchecked:translate-x-0',\n },\n variants: {\n size: {\n sm: {\n root: 'h-5 w-9',\n thumb: 'h-4 w-4 data-checked:translate-x-4',\n },\n md: {\n root: 'h-6 w-11',\n thumb: 'h-5 w-5 data-checked:translate-x-5',\n }\n }\n },\n defaultVariants: {\n size: 'md'\n }\n});\n\n\n\n/** Props for the Switch component */\nexport interface SwitchProps\n extends Omit<BaseSwitch.Root.Props, 'className'>,\n VariantProps<typeof switchVariants> {\n /** Text label displayed next to the switch */\n label?: string;\n className?: string;\n}\n\nconst Switch = React.forwardRef<React.ElementRef<typeof BaseSwitch.Root>, SwitchProps>(\n ({ className, size, label, id, ...props }, ref) => {\n const defaultId = React.useId();\n const switchId = id || defaultId;\n\n const { root, thumb } = switchVariants({ size });\n\n return (\n <div className={cn(\" flex items-center gap-2 w-fit\", props.disabled && \"opacity-40 cursor-not-allowed\")}>\n <BaseSwitch.Root\n ref={ref}\n id={switchId}\n className={root({ className: cn(!props.disabled && \"cursor-pointer\", className) })}\n {...props}\n >\n <BaseSwitch.Thumb className={thumb()} />\n </BaseSwitch.Root>\n {label && (\n <label\n htmlFor={switchId}\n className={cn(\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\", props.disabled && \"cursor-not-allowed\")}\n >\n {label}\n </label>\n )}\n </div>\n );\n }\n);\n\nSwitch.displayName = 'Switch';\n\nexport { Switch };\n"
995
1032
  }
996
1033
  ]
997
1034
  },
@@ -1009,19 +1046,19 @@
1009
1046
  "files": [
1010
1047
  {
1011
1048
  "path": "src/components/ui/table/Table.tsx",
1012
- "content": "import React, { useEffect, useRef, useState } from 'react';\r\nimport {\r\n useReactTable,\r\n getCoreRowModel,\r\n getSortedRowModel,\r\n getPaginationRowModel,\r\n getFilteredRowModel,\r\n getExpandedRowModel,\r\n type ColumnDef,\r\n type SortingState,\r\n type PaginationState,\r\n type RowSelectionState,\r\n type RowData,\r\n type ColumnResizeMode,\r\n} from '@tanstack/react-table';\r\nimport { useVirtualizer } from '@tanstack/react-virtual';\r\n\r\ndeclare module '@tanstack/react-table' {\r\n interface ColumnMeta<TData extends RowData, TValue> {\r\n align?: 'left' | 'center' | 'right';\r\n }\r\n}\r\nimport { ChevronDown, ChevronRight } from 'lucide-react';\r\nimport { Checkbox } from '../checkbox/Checkbox';\r\nimport { Spinner } from '../spinner/Spinner';\r\nimport { TableHeader } from './TableHeader';\r\nimport { TableEmpty, TableNormalRows, TableVirtualRows } from './TableBody';\r\nimport { TablePagination } from './TablePagination';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Pagination Config ───────────────────────────────────────────────────────\r\n\r\nexport interface PaginationConfig {\r\n current?: number;\r\n pageSize?: number;\r\n total?: number;\r\n pageSizeOptions?: number[];\r\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\r\n showSizeChanger?: boolean;\r\n onChange?: (page: number, pageSize: number) => void;\r\n}\r\n\r\nexport interface TableLabels {\r\n page?: string;\r\n perPage?: string;\r\n empty?: string;\r\n}\r\n\r\nexport interface TableProps<TData, TValue = unknown> {\r\n data: TData[];\r\n columns: ColumnDef<TData, TValue>[];\r\n isLoading?: boolean;\r\n enableSorting?: boolean;\r\n enableRowSelection?: boolean;\r\n enableExpanding?: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n getRowCanExpand?: (row: TData) => boolean;\r\n onSelectionChange?: (selectedRows: TData[]) => void;\r\n className?: string;\r\n enableColumnResizing?: boolean;\r\n columnResizeMode?: ColumnResizeMode;\r\n pagination?: PaginationConfig | false;\r\n /** @deprecated Use `labels.empty` instead */\r\n emptyText?: string;\r\n labels?: TableLabels;\r\n virtualize?: boolean;\r\n virtualHeight?: number;\r\n estimatedRowHeight?: number;\r\n}\r\n\r\nexport function Table<TData, TValue = unknown>({\r\n data = [],\r\n columns,\r\n isLoading = false,\r\n enableSorting = true,\r\n enableRowSelection = false,\r\n enableExpanding = false,\r\n renderSubComponent,\r\n getRowCanExpand,\r\n onSelectionChange,\r\n className,\r\n enableColumnResizing = false,\r\n columnResizeMode = 'onChange',\r\n pagination: paginationProp = {},\r\n emptyText,\r\n labels,\r\n virtualize = false,\r\n virtualHeight = 400,\r\n estimatedRowHeight = 45,\r\n}: TableProps<TData>) {\r\n const resolvedEmptyText = emptyText ?? labels?.empty ?? 'No data';\r\n const scrollContainerRef = useRef<HTMLDivElement>(null);\r\n const paginationEnabled = paginationProp !== false;\r\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\r\n\r\n const isServerMode = paginationEnabled && cfg.total !== undefined;\r\n const [page, setPage] = useState(cfg.current ?? 1);\r\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\r\n\r\n useEffect(() => {\r\n if (cfg.current !== undefined) setPage(cfg.current);\r\n }, [cfg.current]);\r\n\r\n useEffect(() => {\r\n if (cfg.pageSize !== undefined) setPageSize(cfg.pageSize);\r\n }, [cfg.pageSize]);\r\n\r\n const pageIndex = page - 1;\r\n const totalRows = isServerMode ? cfg.total! : data.length;\r\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\r\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\r\n\r\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\r\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\r\n const newPage = next.pageIndex + 1;\r\n const newPageSize = next.pageSize;\r\n\r\n if (!isServerMode) {\r\n setPage(newPage);\r\n setPageSize(newPageSize);\r\n }\r\n cfg.onChange?.(newPage, newPageSize);\r\n };\r\n\r\n const [sorting, setSorting] = useState<SortingState>([]);\r\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\r\n\r\n const finalColumns = React.useMemo(() => {\r\n const cols = [...columns];\r\n if (enableRowSelection) {\r\n cols.unshift({\r\n id: 'select',\r\n size: 10,\r\n minSize: 5,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n header: ({ table }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={table.getIsAllRowsSelected()}\r\n indeterminate={table.getIsSomeRowsSelected()}\r\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n cell: ({ row }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={row.getIsSelected()}\r\n disabled={!row.getCanSelect()}\r\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n enableSorting: false,\r\n });\r\n }\r\n if (enableExpanding) {\r\n cols.unshift({\r\n id: 'expander',\r\n header: () => null,\r\n size: 10,\r\n minSize: 10,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n cell: ({ row }) => row.getCanExpand() ? (\r\n <div className=\"flex items-center justify-center\">\r\n <span\r\n onClick={row.getToggleExpandedHandler()}\r\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\r\n >\r\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\r\n </span>\r\n </div>\r\n ) : null,\r\n enableSorting: false,\r\n });\r\n }\r\n return cols;\r\n }, [columns, enableRowSelection, enableExpanding]);\r\n\r\n const table = useReactTable({\r\n data,\r\n columns: finalColumns,\r\n state: { sorting, rowSelection, pagination: tanstackPagination },\r\n onSortingChange: setSorting,\r\n onRowSelectionChange: setRowSelection,\r\n onPaginationChange: handlePaginationChange,\r\n getCoreRowModel: getCoreRowModel(),\r\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\r\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\r\n getFilteredRowModel: getFilteredRowModel(),\r\n getExpandedRowModel: getExpandedRowModel(),\r\n columnResizeMode,\r\n enableColumnResizing,\r\n enableRowSelection,\r\n manualPagination: isServerMode,\r\n pageCount: isServerMode ? pageCount : undefined,\r\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\r\n });\r\n\r\n useEffect(() => {\r\n if (onSelectionChange) {\r\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\r\n onSelectionChange(selected);\r\n }\r\n }, [rowSelection, onSelectionChange, table]);\r\n\r\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\r\n\r\n // Virtualizer\r\n const allRows = virtualize ? table.getCoreRowModel().rows : [];\r\n const rowVirtualizer = useVirtualizer({\r\n count: virtualize ? allRows.length : 0,\r\n getScrollElement: () => scrollContainerRef.current,\r\n estimateSize: () => estimatedRowHeight,\r\n overscan: 8,\r\n enabled: virtualize,\r\n });\r\n\r\n return (\r\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\r\n {isLoading && (\r\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\r\n <Spinner size=\"lg\" variant=\"primary\" />\r\n </div>\r\n )}\r\n\r\n <div\r\n ref={scrollContainerRef}\r\n className=\"overflow-x-auto w-full\"\r\n style={virtualize ? { overflowY: 'auto', maxHeight: virtualHeight } : undefined}\r\n >\r\n <table\r\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\r\n style={{\r\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\r\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\r\n }}\r\n >\r\n <TableHeader\r\n headerGroups={table.getHeaderGroups()}\r\n enableSorting={enableSorting}\r\n enableColumnResizing={enableColumnResizing}\r\n />\r\n <tbody>\r\n {!isLoading && data.length === 0 ? (\r\n <TableEmpty colSpan={finalColumns.length} text={resolvedEmptyText} />\r\n ) : virtualize ? (\r\n <TableVirtualRows\r\n allRows={allRows}\r\n rowVirtualizer={rowVirtualizer}\r\n enableColumnResizing={enableColumnResizing}\r\n colSpan={finalColumns.length}\r\n />\r\n ) : (\r\n <TableNormalRows\r\n rows={table.getRowModel().rows}\r\n enableColumnResizing={enableColumnResizing}\r\n renderSubComponent={renderSubComponent}\r\n />\r\n )}\r\n </tbody>\r\n </table>\r\n </div>\r\n\r\n {!virtualize && paginationEnabled && (\r\n <TablePagination\r\n table={table}\r\n totalRows={totalRows}\r\n totalPageCount={totalPageCount}\r\n isServerMode={isServerMode}\r\n cfg={cfg}\r\n labels={labels}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"
1049
+ "content": "import React, { useEffect, useRef, useState } from 'react';\nimport {\n useReactTable,\n getCoreRowModel,\n getSortedRowModel,\n getPaginationRowModel,\n getFilteredRowModel,\n getExpandedRowModel,\n type ColumnDef,\n type SortingState,\n type PaginationState,\n type RowSelectionState,\n type RowData,\n type ColumnResizeMode,\n} from '@tanstack/react-table';\nimport { useVirtualizer } from '@tanstack/react-virtual';\n\ndeclare module '@tanstack/react-table' {\n interface ColumnMeta<TData extends RowData, TValue> {\n align?: 'left' | 'center' | 'right';\n }\n}\nimport { ChevronDown, ChevronRight } from 'lucide-react';\nimport { Checkbox } from '../checkbox/Checkbox';\nimport { Spinner } from '../spinner/Spinner';\nimport { TableHeader } from './TableHeader';\nimport { TableEmpty, TableNormalRows, TableVirtualRows } from './TableBody';\nimport { TablePagination } from './TablePagination';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Pagination Config ───────────────────────────────────────────────────────\n\nexport interface PaginationConfig {\n current?: number;\n pageSize?: number;\n total?: number;\n pageSizeOptions?: number[];\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\n showSizeChanger?: boolean;\n onChange?: (page: number, pageSize: number) => void;\n}\n\nexport interface TableLabels {\n page?: string;\n perPage?: string;\n empty?: string;\n}\n\nexport interface TableProps<TData, TValue = unknown> {\n data: TData[];\n columns: ColumnDef<TData, TValue>[];\n isLoading?: boolean;\n enableSorting?: boolean;\n enableRowSelection?: boolean;\n enableExpanding?: boolean;\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\n getRowCanExpand?: (row: TData) => boolean;\n onSelectionChange?: (selectedRows: TData[]) => void;\n className?: string;\n enableColumnResizing?: boolean;\n columnResizeMode?: ColumnResizeMode;\n pagination?: PaginationConfig | false;\n /** @deprecated Use `labels.empty` instead */\n emptyText?: string;\n labels?: TableLabels;\n virtualize?: boolean;\n virtualHeight?: number;\n estimatedRowHeight?: number;\n}\n\nexport function Table<TData, TValue = unknown>({\n data = [],\n columns,\n isLoading = false,\n enableSorting = true,\n enableRowSelection = false,\n enableExpanding = false,\n renderSubComponent,\n getRowCanExpand,\n onSelectionChange,\n className,\n enableColumnResizing = false,\n columnResizeMode = 'onChange',\n pagination: paginationProp = {},\n emptyText,\n labels,\n virtualize = false,\n virtualHeight = 400,\n estimatedRowHeight = 45,\n}: TableProps<TData>) {\n const resolvedEmptyText = emptyText ?? labels?.empty ?? 'No data';\n const scrollContainerRef = useRef<HTMLDivElement>(null);\n const paginationEnabled = paginationProp !== false;\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\n\n const isServerMode = paginationEnabled && cfg.total !== undefined;\n const [page, setPage] = useState(cfg.current ?? 1);\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\n\n useEffect(() => {\n if (cfg.current !== undefined) setPage(cfg.current);\n }, [cfg.current]);\n\n useEffect(() => {\n if (cfg.pageSize !== undefined) setPageSize(cfg.pageSize);\n }, [cfg.pageSize]);\n\n const pageIndex = page - 1;\n const totalRows = isServerMode ? cfg.total! : data.length;\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\n\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\n const newPage = next.pageIndex + 1;\n const newPageSize = next.pageSize;\n\n if (!isServerMode) {\n setPage(newPage);\n setPageSize(newPageSize);\n }\n cfg.onChange?.(newPage, newPageSize);\n };\n\n const [sorting, setSorting] = useState<SortingState>([]);\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n\n const finalColumns = React.useMemo(() => {\n const cols = [...columns];\n if (enableRowSelection) {\n cols.unshift({\n id: 'select',\n size: 10,\n minSize: 5,\n maxSize: 10,\n meta: { align: 'center' },\n header: ({ table }) => (\n <div className=\"flex items-center justify-center\">\n <Checkbox\n size=\"sm\"\n checked={table.getIsAllRowsSelected()}\n indeterminate={table.getIsSomeRowsSelected()}\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\n />\n </div>\n ),\n cell: ({ row }) => (\n <div className=\"flex items-center justify-center\">\n <Checkbox\n size=\"sm\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\n />\n </div>\n ),\n enableSorting: false,\n });\n }\n if (enableExpanding) {\n cols.unshift({\n id: 'expander',\n header: () => null,\n size: 10,\n minSize: 10,\n maxSize: 10,\n meta: { align: 'center' },\n cell: ({ row }) => row.getCanExpand() ? (\n <div className=\"flex items-center justify-center\">\n <span\n onClick={row.getToggleExpandedHandler()}\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\n >\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\n </span>\n </div>\n ) : null,\n enableSorting: false,\n });\n }\n return cols;\n }, [columns, enableRowSelection, enableExpanding]);\n\n const table = useReactTable({\n data,\n columns: finalColumns,\n state: { sorting, rowSelection, pagination: tanstackPagination },\n onSortingChange: setSorting,\n onRowSelectionChange: setRowSelection,\n onPaginationChange: handlePaginationChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\n getFilteredRowModel: getFilteredRowModel(),\n getExpandedRowModel: getExpandedRowModel(),\n columnResizeMode,\n enableColumnResizing,\n enableRowSelection,\n manualPagination: isServerMode,\n pageCount: isServerMode ? pageCount : undefined,\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\n });\n\n useEffect(() => {\n if (onSelectionChange) {\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\n onSelectionChange(selected);\n }\n }, [rowSelection, onSelectionChange, table]);\n\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\n\n // Virtualizer\n const allRows = virtualize ? table.getCoreRowModel().rows : [];\n const rowVirtualizer = useVirtualizer({\n count: virtualize ? allRows.length : 0,\n getScrollElement: () => scrollContainerRef.current,\n estimateSize: () => estimatedRowHeight,\n overscan: 8,\n enabled: virtualize,\n });\n\n return (\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\n {isLoading && (\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\n <Spinner size=\"lg\" variant=\"primary\" />\n </div>\n )}\n\n <div\n ref={scrollContainerRef}\n className=\"overflow-x-auto w-full\"\n style={virtualize ? { overflowY: 'auto', maxHeight: virtualHeight } : undefined}\n >\n <table\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\n style={{\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\n }}\n >\n <TableHeader\n headerGroups={table.getHeaderGroups()}\n enableSorting={enableSorting}\n enableColumnResizing={enableColumnResizing}\n />\n <tbody>\n {!isLoading && data.length === 0 ? (\n <TableEmpty colSpan={finalColumns.length} text={resolvedEmptyText} />\n ) : virtualize ? (\n <TableVirtualRows\n allRows={allRows}\n rowVirtualizer={rowVirtualizer}\n enableColumnResizing={enableColumnResizing}\n colSpan={finalColumns.length}\n />\n ) : (\n <TableNormalRows\n rows={table.getRowModel().rows}\n enableColumnResizing={enableColumnResizing}\n renderSubComponent={renderSubComponent}\n />\n )}\n </tbody>\n </table>\n </div>\n\n {!virtualize && paginationEnabled && (\n <TablePagination\n table={table}\n totalRows={totalRows}\n totalPageCount={totalPageCount}\n isServerMode={isServerMode}\n cfg={cfg}\n labels={labels}\n />\n )}\n </div>\n );\n}\n"
1013
1050
  },
1014
1051
  {
1015
1052
  "path": "src/components/ui/table/TableBody.tsx",
1016
- "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 className=\"border-b border-border\">\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 const visibleCells = row.getVisibleCells();\r\n return (\r\n <>\r\n {visibleCells.map((cell, index) => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n const isLastColumn = index === visibleCells.length - 1;\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 align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\",\r\n !isLastColumn && \"border-r border-border\"\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 \"border-b border-border 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 className=\"border-b border-border\">\r\n <td colSpan={row.getVisibleCells().length} className=\"p-0 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 \"border-b border-border 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"
1053
+ "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 className=\"border-b border-border\">\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 const visibleCells = row.getVisibleCells();\n return (\n <>\n {visibleCells.map((cell, index) => {\n const meta = cell.column.columnDef.meta;\n const align = meta?.align || 'left';\n const isLastColumn = index === visibleCells.length - 1;\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 align-middle\",\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\",\n !isLastColumn && \"border-r border-border\"\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 \"border-b border-border 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 className=\"border-b border-border\">\n <td colSpan={row.getVisibleCells().length} className=\"p-0 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 \"border-b border-border 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"
1017
1054
  },
1018
1055
  {
1019
1056
  "path": "src/components/ui/table/TableHeader.tsx",
1020
- "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} className=\"border-b border-border\">\r\n {headerGroup.headers.map((header, index) => {\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 const isLastColumn = index === headerGroup.headers.length - 1;\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 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 !isLastColumn && \"border-r border-border\"\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"
1057
+ "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} className=\"border-b border-border\">\n {headerGroup.headers.map((header, index) => {\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 const isLastColumn = index === headerGroup.headers.length - 1;\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 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 !isLastColumn && \"border-r border-border\"\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"
1021
1058
  },
1022
1059
  {
1023
1060
  "path": "src/components/ui/table/TablePagination.tsx",
1024
- "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"
1061
+ "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"
1025
1062
  }
1026
1063
  ]
1027
1064
  },
@@ -1032,7 +1069,7 @@
1032
1069
  "files": [
1033
1070
  {
1034
1071
  "path": "src/components/ui/table-contents/TableContents.tsx",
1035
- "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"
1072
+ "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"
1036
1073
  }
1037
1074
  ]
1038
1075
  },
@@ -1046,7 +1083,7 @@
1046
1083
  "files": [
1047
1084
  {
1048
1085
  "path": "src/components/ui/tabs/Tabs.tsx",
1049
- "content": "import * as React from 'react';\r\nimport { Tabs as BaseTabs } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst tabsVariants = tv({\r\n slots: {\r\n rootSlots: 'flex flex-col w-full',\r\n list: 'relative inline-flex items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground w-fit',\r\n indicator: 'absolute top-1 bottom-1 left-[var(--active-tab-left)] w-[var(--active-tab-width)] rounded-md bg-background shadow-sm transition-all duration-300 ease-out z-0',\r\n trigger: 'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground data-active:font-semibold',\r\n panel: 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n },\r\n variants: {\r\n size: {\r\n xs: { trigger: 'px-2 py-1 text-xs' },\r\n sm: { trigger: 'px-3 py-1.5 text-sm' },\r\n md: { trigger: 'px-4 py-2 text-base' },\r\n lg: { trigger: 'px-5 py-2.5 text-lg' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'sm',\r\n },\r\n});\r\n\r\ntype TabsSize = NonNullable<VariantProps<typeof tabsVariants>['size']>;\r\n\r\nconst TabsSizeContext = React.createContext<TabsSize>('sm');\r\n\r\nconst { rootSlots, list, indicator, panel } = tabsVariants();\r\n\r\ntype TabsRootProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Root>;\r\n\r\nexport type TabsProps = TabsRootProps\r\n\r\nconst Tabs = React.forwardRef<React.ElementRef<typeof BaseTabs.Root>, TabsProps>(\r\n ({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {\r\n const isControlled = valueProp !== undefined;\r\n const [uncontrolledValue, setUncontrolledValue] = React.useState<TabsRootProps['defaultValue']>(\r\n defaultValue ?? false,\r\n );\r\n\r\n const handleValueChange = React.useCallback<NonNullable<TabsRootProps['onValueChange']>>(\r\n (val, event) => {\r\n if (!isControlled) setUncontrolledValue(val);\r\n onValueChange?.(val, event);\r\n },\r\n [isControlled, onValueChange],\r\n );\r\n\r\n return (\r\n <BaseTabs.Root\r\n ref={ref}\r\n className={cn(rootSlots(), className)}\r\n value={isControlled ? valueProp : uncontrolledValue}\r\n onValueChange={handleValueChange}\r\n {...props}\r\n />\r\n );\r\n },\r\n);\r\nTabs.displayName = 'Tabs';\r\n\r\nexport interface TabsListProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.List> {\r\n size?: TabsSize;\r\n}\r\n\r\nconst TabsList = React.forwardRef<React.ElementRef<typeof BaseTabs.List>, TabsListProps>(\r\n ({ className, size = 'sm', children, ...props }, ref) => (\r\n <TabsSizeContext.Provider value={size}>\r\n <BaseTabs.List ref={ref} className={cn(list(), className)} {...props}>\r\n <BaseTabs.Indicator className={indicator()} />\r\n {children}\r\n </BaseTabs.List>\r\n </TabsSizeContext.Provider>\r\n ),\r\n);\r\nTabsList.displayName = 'TabsList';\r\n\r\nexport type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Tab>\r\n\r\nconst TabsTrigger = React.forwardRef<React.ElementRef<typeof BaseTabs.Tab>, TabsTriggerProps>(\r\n ({ className, ...props }, ref) => {\r\n const size = React.useContext(TabsSizeContext);\r\n const { trigger } = tabsVariants({ size });\r\n return (\r\n <BaseTabs.Tab ref={ref} className={cn(trigger(), className)} {...props} />\r\n );\r\n },\r\n);\r\nTabsTrigger.displayName = 'TabsTrigger';\r\n\r\nexport type TabsContentProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Panel>\r\n\r\nconst TabsContent = React.forwardRef<React.ElementRef<typeof BaseTabs.Panel>, TabsContentProps>(\r\n ({ className, ...props }, ref) => (\r\n <BaseTabs.Panel ref={ref} className={cn(panel(), className)} {...props} />\r\n ),\r\n);\r\nTabsContent.displayName = 'TabsContent';\r\n\r\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\r\nexport type { TabsSize };\r\n"
1086
+ "content": "import * as React from 'react';\nimport { Tabs as BaseTabs } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst tabsVariants = tv({\n slots: {\n rootSlots: 'flex flex-col w-full',\n list: 'relative inline-flex items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground w-fit',\n indicator: 'absolute top-1 bottom-1 left-[var(--active-tab-left)] w-[var(--active-tab-width)] rounded-md bg-background shadow-sm transition-all duration-300 ease-out z-0',\n trigger: 'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground data-active:font-semibold',\n panel: 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\n },\n variants: {\n size: {\n xs: { trigger: 'px-2 py-1 text-xs' },\n sm: { trigger: 'px-3 py-1.5 text-sm' },\n md: { trigger: 'px-4 py-2 text-base' },\n lg: { trigger: 'px-5 py-2.5 text-lg' },\n },\n },\n defaultVariants: {\n size: 'sm',\n },\n});\n\ntype TabsSize = NonNullable<VariantProps<typeof tabsVariants>['size']>;\n\nconst TabsSizeContext = React.createContext<TabsSize>('sm');\n\nconst { rootSlots, list, indicator, panel } = tabsVariants();\n\ntype TabsRootProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Root>;\n\nexport type TabsProps = TabsRootProps\n\nconst Tabs = React.forwardRef<React.ElementRef<typeof BaseTabs.Root>, TabsProps>(\n ({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {\n const isControlled = valueProp !== undefined;\n const [uncontrolledValue, setUncontrolledValue] = React.useState<TabsRootProps['defaultValue']>(\n defaultValue ?? false,\n );\n\n const handleValueChange = React.useCallback<NonNullable<TabsRootProps['onValueChange']>>(\n (val, event) => {\n if (!isControlled) setUncontrolledValue(val);\n onValueChange?.(val, event);\n },\n [isControlled, onValueChange],\n );\n\n return (\n <BaseTabs.Root\n ref={ref}\n className={cn(rootSlots(), className)}\n value={isControlled ? valueProp : uncontrolledValue}\n onValueChange={handleValueChange}\n {...props}\n />\n );\n },\n);\nTabs.displayName = 'Tabs';\n\nexport interface TabsListProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.List> {\n size?: TabsSize;\n}\n\nconst TabsList = React.forwardRef<React.ElementRef<typeof BaseTabs.List>, TabsListProps>(\n ({ className, size = 'sm', children, ...props }, ref) => (\n <TabsSizeContext.Provider value={size}>\n <BaseTabs.List ref={ref} className={cn(list(), className)} {...props}>\n <BaseTabs.Indicator className={indicator()} />\n {children}\n </BaseTabs.List>\n </TabsSizeContext.Provider>\n ),\n);\nTabsList.displayName = 'TabsList';\n\nexport type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Tab>\n\nconst TabsTrigger = React.forwardRef<React.ElementRef<typeof BaseTabs.Tab>, TabsTriggerProps>(\n ({ className, ...props }, ref) => {\n const size = React.useContext(TabsSizeContext);\n const { trigger } = tabsVariants({ size });\n return (\n <BaseTabs.Tab ref={ref} className={cn(trigger(), className)} {...props} />\n );\n },\n);\nTabsTrigger.displayName = 'TabsTrigger';\n\nexport type TabsContentProps = React.ComponentPropsWithoutRef<typeof BaseTabs.Panel>\n\nconst TabsContent = React.forwardRef<React.ElementRef<typeof BaseTabs.Panel>, TabsContentProps>(\n ({ className, ...props }, ref) => (\n <BaseTabs.Panel ref={ref} className={cn(panel(), className)} {...props} />\n ),\n);\nTabsContent.displayName = 'TabsContent';\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\nexport type { TabsSize };\n"
1050
1087
  }
1051
1088
  ]
1052
1089
  },
@@ -1060,7 +1097,7 @@
1060
1097
  "files": [
1061
1098
  {
1062
1099
  "path": "src/components/ui/textarea/Textarea.tsx",
1063
- "content": "import * as React from 'react';\r\nimport { Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst textareaVariants = tv({\r\n base: 'flex min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:ring-0 placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus-visible:border-primary focus-visible:ring-0',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n },\r\n});\r\n\r\n/** Props for the Textarea component */\r\nexport interface TextareaProps\r\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'minLength'>,\r\n VariantProps<typeof textareaVariants> {\r\n /** Label text displayed above the textarea */\r\n label?: string;\r\n /** Error message displayed below the textarea (replaces description) */\r\n error?: string;\r\n /** Helper text displayed below the textarea */\r\n description?: string;\r\n /** Show count of characters */\r\n showCount?: boolean;\r\n /** Max characters — enforced natively, displayed as count/max when showCount is true */\r\n maxLength?: number;\r\n /** Min characters — shows hint when current count is below min */\r\n minLength?: number;\r\n required?: boolean;\r\n}\r\n\r\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\r\n (\r\n {\r\n className,\r\n variant,\r\n label,\r\n error,\r\n description,\r\n showCount,\r\n maxLength,\r\n minLength,\r\n required,\r\n onChange,\r\n value,\r\n defaultValue,\r\n ...rest\r\n },\r\n ref,\r\n ) => {\r\n const isControlled = value !== undefined;\r\n\r\n const [charCount, setCharCount] = React.useState<number>(() => {\r\n if (isControlled) return String(value ?? '').length;\r\n if (defaultValue !== undefined) return String(defaultValue).length;\r\n return 0;\r\n });\r\n\r\n React.useEffect(() => {\r\n if (isControlled) setCharCount(String(value ?? '').length);\r\n }, [isControlled, value]);\r\n\r\n const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {\r\n setCharCount(event.target.value.length);\r\n onChange?.(event);\r\n };\r\n\r\n const isNearLimit = maxLength !== undefined && charCount >= Math.floor(maxLength * 0.9);\r\n const isAtLimit = maxLength !== undefined && charCount >= maxLength;\r\n const isBelowMin = minLength !== undefined && charCount > 0 && charCount < minLength;\r\n\r\n const hasFooter = error ?? description ?? isBelowMin;\r\n\r\n return (\r\n <BaseField.Root className=\"flex w-full flex-col gap-1.5\">\r\n {label && (\r\n <BaseField.Label className=\"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-danger\">*</span>}\r\n </BaseField.Label>\r\n )}\r\n\r\n <div className=\"relative\">\r\n <BaseField.Control\r\n render={\r\n <textarea\r\n ref={ref}\r\n className={cn(\r\n textareaVariants({ variant }),\r\n error && 'border-danger focus-visible:ring-danger',\r\n showCount && 'pb-6',\r\n className,\r\n )}\r\n value={value}\r\n defaultValue={!isControlled ? defaultValue : undefined}\r\n maxLength={maxLength}\r\n minLength={minLength}\r\n required={required}\r\n onChange={handleChange}\r\n {...rest}\r\n />\r\n }\r\n />\r\n\r\n {showCount && (\r\n <span\r\n className={cn(\r\n 'pointer-events-none absolute bottom-1 right-3 select-none text-[0.7rem] tabular-nums text-muted-foreground',\r\n isNearLimit && 'font-medium text-warning',\r\n isAtLimit && 'text-danger',\r\n )}\r\n >\r\n {charCount}\r\n {maxLength !== undefined && `/${maxLength}`}\r\n </span>\r\n )}\r\n </div>\r\n\r\n {hasFooter && (\r\n <div className=\"flex flex-col gap-0.5\">\r\n {error ? (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n ) : (\r\n <>\r\n {description && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {isBelowMin && (\r\n <p className=\"text-[0.8rem] text-warning\">Tối thiểu {minLength} ký tự</p>\r\n )}\r\n </>\r\n )}\r\n </div>\r\n )}\r\n </BaseField.Root>\r\n );\r\n },\r\n);\r\nTextarea.displayName = 'Textarea';\r\n\r\nexport { Textarea };\r\n"
1100
+ "content": "import * as React from 'react';\nimport { Field as BaseField } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst textareaVariants = tv({\n base: 'flex min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:ring-0 placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\n variants: {\n variant: {\n default: '',\n filled: 'bg-accent border-transparent focus-visible:border-primary focus-visible:ring-0',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\n/** Props for the Textarea component */\nexport interface TextareaProps\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'minLength'>,\n VariantProps<typeof textareaVariants> {\n /** Label text displayed above the textarea */\n label?: string;\n /** Error message displayed below the textarea (replaces description) */\n error?: string;\n /** Helper text displayed below the textarea */\n description?: string;\n /** Show count of characters */\n showCount?: boolean;\n /** Max characters — enforced natively, displayed as count/max when showCount is true */\n maxLength?: number;\n /** Min characters — shows hint when current count is below min */\n minLength?: number;\n required?: boolean;\n}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n (\n {\n className,\n variant,\n label,\n error,\n description,\n showCount,\n maxLength,\n minLength,\n required,\n onChange,\n value,\n defaultValue,\n ...rest\n },\n ref,\n ) => {\n const isControlled = value !== undefined;\n\n const [charCount, setCharCount] = React.useState<number>(() => {\n if (isControlled) return String(value ?? '').length;\n if (defaultValue !== undefined) return String(defaultValue).length;\n return 0;\n });\n\n React.useEffect(() => {\n if (isControlled) setCharCount(String(value ?? '').length);\n }, [isControlled, value]);\n\n const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n setCharCount(event.target.value.length);\n onChange?.(event);\n };\n\n const isNearLimit = maxLength !== undefined && charCount >= Math.floor(maxLength * 0.9);\n const isAtLimit = maxLength !== undefined && charCount >= maxLength;\n const isBelowMin = minLength !== undefined && charCount > 0 && charCount < minLength;\n\n const hasFooter = error ?? description ?? isBelowMin;\n\n return (\n <BaseField.Root className=\"flex w-full flex-col gap-1.5\">\n {label && (\n <BaseField.Label className=\"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\n {label}\n {required && <span className=\"ml-0.5 text-danger\">*</span>}\n </BaseField.Label>\n )}\n\n <div className=\"relative\">\n <BaseField.Control\n render={\n <textarea\n ref={ref}\n className={cn(\n textareaVariants({ variant }),\n error && 'border-danger focus-visible:ring-danger',\n showCount && 'pb-6',\n className,\n )}\n value={value}\n defaultValue={!isControlled ? defaultValue : undefined}\n maxLength={maxLength}\n minLength={minLength}\n required={required}\n onChange={handleChange}\n {...rest}\n />\n }\n />\n\n {showCount && (\n <span\n className={cn(\n 'pointer-events-none absolute bottom-1 right-3 select-none text-[0.7rem] tabular-nums text-muted-foreground',\n isNearLimit && 'font-medium text-warning',\n isAtLimit && 'text-danger',\n )}\n >\n {charCount}\n {maxLength !== undefined && `/${maxLength}`}\n </span>\n )}\n </div>\n\n {hasFooter && (\n <div className=\"flex flex-col gap-0.5\">\n {error ? (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n ) : (\n <>\n {description && (\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\n {description}\n </BaseField.Description>\n )}\n {isBelowMin && (\n <p className=\"text-[0.8rem] text-warning\">Tối thiểu {minLength} ký tự</p>\n )}\n </>\n )}\n </div>\n )}\n </BaseField.Root>\n );\n },\n);\nTextarea.displayName = 'Textarea';\n\nexport { Textarea };\n"
1064
1101
  }
1065
1102
  ]
1066
1103
  },
@@ -1073,7 +1110,7 @@
1073
1110
  "files": [
1074
1111
  {
1075
1112
  "path": "src/components/ui/timeline/Timeline.tsx",
1076
- "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: 'group relative flex last:pb-0',\r\n indicator: [\r\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full',\r\n 'border-[3px] border-background ring-2 ring-transparent',\r\n 'transition-all duration-400 ease-out',\r\n ' group-hover:ring-2',\r\n ].join(' '),\r\n connector: 'absolute left-0 top-0 bottom-0 flex justify-center',\r\n connectorLine: 'w-[2px] transition-all duration-400 ease-out origin-top rounded-b-full',\r\n contentWrapper: 'flex-1 transition-all duration-400 ease-out ',\r\n title: 'text-sm font-semibold tracking-tight text-foreground transition-colors duration-400',\r\n description: 'mt-1.5 text-sm text-muted-foreground/80 leading-relaxed',\r\n time: 'mt-2 text-[11px] font-medium text-muted-foreground/60 uppercase tracking-widest flex items-center transition-colors duration-400 group-hover:text-muted-foreground/90',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n indicator: 'h-7 w-7 [&_svg]:h-3.5 [&_svg]:w-3.5',\r\n connector: 'w-7',\r\n connectorLine: 'mt-7',\r\n item: 'gap-4 pb-6',\r\n contentWrapper: 'pt-1.5',\r\n },\r\n md: {\r\n indicator: 'h-9 w-9 [&_svg]:h-4 [&_svg]:w-4',\r\n connector: 'w-9',\r\n connectorLine: 'mt-9',\r\n item: 'gap-5 pb-8',\r\n contentWrapper: 'pt-2',\r\n },\r\n lg: {\r\n indicator: 'h-11 w-11 [&_svg]:h-5 [&_svg]:w-5',\r\n connector: 'w-11',\r\n connectorLine: 'mt-11',\r\n item: 'gap-6 pb-12',\r\n contentWrapper: 'pt-2.5',\r\n },\r\n },\r\n variant: {\r\n default: { \r\n indicator: 'bg-muted text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground group-hover:ring-foreground/5 group-hover:shadow-md group-hover:shadow-foreground/5', \r\n connectorLine: 'bg-gradient-to-b from-border/70 to-transparent group-hover:from-foreground/20' \r\n },\r\n primary: { \r\n indicator: 'bg-primary/10 text-primary group-hover:bg-primary/20 group-hover:text-primary group-hover:ring-primary/15 group-hover:shadow-md group-hover:shadow-primary/20', \r\n connectorLine: 'bg-gradient-to-b from-primary/30 to-transparent group-hover:from-primary/40' \r\n },\r\n success: { \r\n indicator: 'bg-success/10 text-success group-hover:bg-success/20 group-hover:text-success group-hover:ring-success/15 group-hover:shadow-md group-hover:shadow-success/20', \r\n connectorLine: 'bg-gradient-to-b from-success/30 to-transparent group-hover:from-success/40' \r\n },\r\n warning: { \r\n indicator: 'bg-warning/10 text-warning group-hover:bg-warning/20 group-hover:text-warning group-hover:ring-warning/15 group-hover:shadow-md group-hover:shadow-warning/20', \r\n connectorLine: 'bg-gradient-to-b from-warning/30 to-transparent group-hover:from-warning/40' \r\n },\r\n danger: { \r\n indicator: 'bg-danger/10 text-danger group-hover:bg-danger/20 group-hover:text-danger group-hover:ring-danger/15 group-hover:shadow-md group-hover:shadow-danger/20', \r\n connectorLine: 'bg-gradient-to-b from-danger/30 to-transparent group-hover:from-danger/40' \r\n },\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={itemStyles.connectorLine()} />\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.contentWrapper()}>\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 };\r\n"
1113
+ "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: 'group relative flex last:pb-0',\n indicator: [\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full',\n 'border-[3px] border-background ring-2 ring-transparent',\n 'transition-all duration-400 ease-out',\n ' group-hover:ring-2',\n ].join(' '),\n connector: 'absolute left-0 top-0 bottom-0 flex justify-center',\n connectorLine: 'w-[2px] transition-all duration-400 ease-out origin-top rounded-b-full',\n contentWrapper: 'flex-1 transition-all duration-400 ease-out ',\n title: 'text-sm font-semibold tracking-tight text-foreground transition-colors duration-400',\n description: 'mt-1.5 text-sm text-muted-foreground/80 leading-relaxed',\n time: 'mt-2 text-[11px] font-medium text-muted-foreground/60 uppercase tracking-widest flex items-center transition-colors duration-400 group-hover:text-muted-foreground/90',\n },\n variants: {\n size: {\n sm: {\n indicator: 'h-7 w-7 [&_svg]:h-3.5 [&_svg]:w-3.5',\n connector: 'w-7',\n connectorLine: 'mt-7',\n item: 'gap-4 pb-6',\n contentWrapper: 'pt-1.5',\n },\n md: {\n indicator: 'h-9 w-9 [&_svg]:h-4 [&_svg]:w-4',\n connector: 'w-9',\n connectorLine: 'mt-9',\n item: 'gap-5 pb-8',\n contentWrapper: 'pt-2',\n },\n lg: {\n indicator: 'h-11 w-11 [&_svg]:h-5 [&_svg]:w-5',\n connector: 'w-11',\n connectorLine: 'mt-11',\n item: 'gap-6 pb-12',\n contentWrapper: 'pt-2.5',\n },\n },\n variant: {\n default: { \n indicator: 'bg-muted text-muted-foreground group-hover:bg-accent group-hover:text-accent-foreground group-hover:ring-foreground/5 group-hover:shadow-md group-hover:shadow-foreground/5', \n connectorLine: 'bg-gradient-to-b from-border/70 to-transparent group-hover:from-foreground/20' \n },\n primary: { \n indicator: 'bg-primary/10 text-primary group-hover:bg-primary/20 group-hover:text-primary group-hover:ring-primary/15 group-hover:shadow-md group-hover:shadow-primary/20', \n connectorLine: 'bg-gradient-to-b from-primary/30 to-transparent group-hover:from-primary/40' \n },\n success: { \n indicator: 'bg-success/10 text-success group-hover:bg-success/20 group-hover:text-success group-hover:ring-success/15 group-hover:shadow-md group-hover:shadow-success/20', \n connectorLine: 'bg-gradient-to-b from-success/30 to-transparent group-hover:from-success/40' \n },\n warning: { \n indicator: 'bg-warning/10 text-warning group-hover:bg-warning/20 group-hover:text-warning group-hover:ring-warning/15 group-hover:shadow-md group-hover:shadow-warning/20', \n connectorLine: 'bg-gradient-to-b from-warning/30 to-transparent group-hover:from-warning/40' \n },\n danger: { \n indicator: 'bg-danger/10 text-danger group-hover:bg-danger/20 group-hover:text-danger group-hover:ring-danger/15 group-hover:shadow-md group-hover:shadow-danger/20', \n connectorLine: 'bg-gradient-to-b from-danger/30 to-transparent group-hover:from-danger/40' \n },\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={itemStyles.connectorLine()} />\n </div>\n )}\n\n {/* Indicator dot */}\n <div className={itemStyles.indicator()}>\n {item.icon}\n </div>\n\n {/* Content */}\n <div className={styles.contentWrapper()}>\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 };\n"
1077
1114
  }
1078
1115
  ]
1079
1116
  },
@@ -1086,7 +1123,7 @@
1086
1123
  "files": [
1087
1124
  {
1088
1125
  "path": "src/components/ui/toast/Toaster.tsx",
1089
- "content": "import { Toaster as Sonner } from 'sonner';\r\nimport * as React from 'react';\r\n\r\ntype ToasterProps = React.ComponentProps<typeof Sonner>;\r\n\r\nconst Toaster = ({ ...props }: ToasterProps) => {\r\n return (\r\n <Sonner\r\n className=\"toaster group\"\r\n toastOptions={{\r\n classNames: {\r\n toast:\r\n 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',\r\n description: 'group-[.toast]:text-muted-foreground',\r\n actionButton:\r\n 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',\r\n cancelButton:\r\n 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',\r\n success: 'group-[.toaster]:text-success group-[.toaster]:border-success/50',\r\n error: 'group-[.toaster]:text-destructive group-[.toaster]:border-destructive/50',\r\n warning: 'group-[.toaster]:text-warning group-[.toaster]:border-warning/50',\r\n info: 'group-[.toaster]:text-blue-500 group-[.toaster]:border-blue-500/50',\r\n },\r\n }}\r\n {...props}\r\n />\r\n );\r\n};\r\n\r\nexport { Toaster };\r\n"
1126
+ "content": "import { Toaster as Sonner } from 'sonner';\nimport * as React from 'react';\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>;\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n return (\n <Sonner\n className=\"toaster group\"\n toastOptions={{\n classNames: {\n toast:\n 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',\n description: 'group-[.toast]:text-muted-foreground',\n actionButton:\n 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',\n cancelButton:\n 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',\n success: 'group-[.toaster]:text-success group-[.toaster]:border-success/50',\n error: 'group-[.toaster]:text-destructive group-[.toaster]:border-destructive/50',\n warning: 'group-[.toaster]:text-warning group-[.toaster]:border-warning/50',\n info: 'group-[.toaster]:text-blue-500 group-[.toaster]:border-blue-500/50',\n },\n }}\n {...props}\n />\n );\n};\n\nexport { Toaster };\n"
1090
1127
  }
1091
1128
  ]
1092
1129
  },
@@ -1099,7 +1136,7 @@
1099
1136
  "files": [
1100
1137
  {
1101
1138
  "path": "src/components/ui/toggle/Toggle.tsx",
1102
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst toggleVariants = tv({\r\n base: [\r\n 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium text-sm',\r\n 'transition-all duration-150 cursor-pointer select-none',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'border',\r\n ],\r\n variants: {\r\n variant: {\r\n default: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-secondary data-[state=on]:text-foreground data-[state=on]:border-secondary',\r\n ],\r\n outline: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:border-primary/50 hover:text-primary',\r\n 'data-[state=on]:bg-primary/10 data-[state=on]:text-primary data-[state=on]:border-primary/50',\r\n ],\r\n solid: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary',\r\n ],\r\n ghost: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-muted data-[state=on]:text-foreground',\r\n ],\r\n },\r\n size: {\r\n sm: 'h-7 px-2 text-xs',\r\n md: 'h-9 px-3 text-sm',\r\n lg: 'h-11 px-4 text-base',\r\n icon: 'h-9 w-9',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Toggle component */\r\nexport interface ToggleProps\r\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>,\r\n VariantProps<typeof toggleVariants> {\r\n /** Controlled pressed state */\r\n pressed?: boolean;\r\n /** Default pressed state (uncontrolled) */\r\n defaultPressed?: boolean;\r\n /** Callback fired when the pressed state changes */\r\n onPressedChange?: (pressed: boolean) => void;\r\n}\r\n\r\nconst Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(\r\n (\r\n {\r\n pressed: controlledPressed,\r\n defaultPressed = false,\r\n onPressedChange,\r\n variant,\r\n size,\r\n className,\r\n children,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledPressed !== undefined;\r\n const [internalPressed, setInternalPressed] = React.useState(defaultPressed);\r\n\r\n const isPressed = isControlled ? controlledPressed! : internalPressed;\r\n\r\n const handleClick = () => {\r\n const next = !isPressed;\r\n if (!isControlled) setInternalPressed(next);\r\n onPressedChange?.(next);\r\n };\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n aria-pressed={isPressed}\r\n data-state={isPressed ? 'on' : 'off'}\r\n onClick={handleClick}\r\n className={toggleVariants({ variant, size, className })}\r\n {...props}\r\n >\r\n {children}\r\n </button>\r\n );\r\n }\r\n);\r\n\r\nToggle.displayName = 'Toggle';\r\n\r\n// ─── ToggleGroup ─────────────────────────────────────────────────────────────\r\n\r\ninterface ToggleGroupContextValue {\r\n value: string[];\r\n onValueChange: (value: string[]) => void;\r\n type: 'single' | 'multiple';\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n}\r\n\r\nconst ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null);\r\n\r\n/** Props for the ToggleGroup component */\r\nexport interface ToggleGroupProps\r\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\r\n /** Whether only one or multiple items can be active at a time */\r\n type?: 'single' | 'multiple';\r\n /** Controlled array of active item values */\r\n value?: string[];\r\n /** Default active values (uncontrolled) */\r\n defaultValue?: string[];\r\n /** Callback fired when the active values change */\r\n onValueChange?: (value: string[]) => void;\r\n /** Variant applied to all child ToggleGroupItems (can be overridden per item) */\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n /** Size applied to all child ToggleGroupItems (can be overridden per item) */\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n children: React.ReactNode;\r\n /** Disable the entire group */\r\n disabled?: boolean;\r\n}\r\n\r\nconst ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(\r\n (\r\n {\r\n type = 'single',\r\n value: controlledValue,\r\n defaultValue = [],\r\n onValueChange,\r\n variant = 'default',\r\n size = 'md',\r\n className,\r\n children,\r\n disabled,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState<string[]>(defaultValue);\r\n const value = isControlled ? controlledValue! : internalValue;\r\n\r\n const handleValueChange = (newValues: string[]) => {\r\n if (!isControlled) setInternalValue(newValues);\r\n onValueChange?.(newValues);\r\n };\r\n\r\n return (\r\n <ToggleGroupContext.Provider value={{ value, onValueChange: handleValueChange, type, variant, size }}>\r\n <div\r\n ref={ref}\r\n role=\"group\"\r\n className={cn('inline-flex items-center gap-1', className)}\r\n aria-disabled={disabled}\r\n {...props}\r\n >\r\n {children}\r\n </div>\r\n </ToggleGroupContext.Provider>\r\n );\r\n }\r\n);\r\n\r\nToggleGroup.displayName = 'ToggleGroup';\r\n\r\n/** Props for the ToggleGroupItem component */\r\nexport interface ToggleGroupItemProps\r\n extends Omit<ToggleProps, 'pressed' | 'onPressedChange'> {\r\n /** Unique value identifying this item within the group */\r\n value: string;\r\n}\r\n\r\nconst ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\r\n ({ value, variant: itemVariant, size: itemSize, children, ...props }, ref) => {\r\n const ctx = React.useContext(ToggleGroupContext);\r\n if (!ctx) throw new Error('ToggleGroupItem must be inside ToggleGroup');\r\n\r\n const { value: groupValue, onValueChange, type, variant: ctxVariant, size: ctxSize } = ctx;\r\n const isPressed = groupValue.includes(value);\r\n\r\n const handlePressedChange = (pressed: boolean) => {\r\n if (type === 'single') {\r\n onValueChange(pressed ? [value] : []);\r\n } else {\r\n onValueChange(\r\n pressed ? [...groupValue, value] : groupValue.filter((v) => v !== value)\r\n );\r\n }\r\n };\r\n\r\n return (\r\n <Toggle\r\n ref={ref}\r\n pressed={isPressed}\r\n onPressedChange={handlePressedChange}\r\n variant={itemVariant ?? ctxVariant}\r\n size={itemSize ?? ctxSize}\r\n {...props}\r\n >\r\n {children}\r\n </Toggle>\r\n );\r\n }\r\n);\r\n\r\nToggleGroupItem.displayName = 'ToggleGroupItem';\r\n\r\nexport { Toggle, ToggleGroup, ToggleGroupItem };\r\n"
1139
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst toggleVariants = tv({\n base: [\n 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium text-sm',\n 'transition-all duration-150 cursor-pointer select-none',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\n 'disabled:pointer-events-none disabled:opacity-50',\n 'border',\n ],\n variants: {\n variant: {\n default: [\n 'bg-transparent border-border text-muted-foreground',\n 'hover:bg-muted hover:text-foreground',\n 'data-[state=on]:bg-secondary data-[state=on]:text-foreground data-[state=on]:border-secondary',\n ],\n outline: [\n 'bg-transparent border-border text-muted-foreground',\n 'hover:border-primary/50 hover:text-primary',\n 'data-[state=on]:bg-primary/10 data-[state=on]:text-primary data-[state=on]:border-primary/50',\n ],\n solid: [\n 'bg-transparent border-transparent text-muted-foreground',\n 'hover:bg-muted hover:text-foreground',\n 'data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary',\n ],\n ghost: [\n 'bg-transparent border-transparent text-muted-foreground',\n 'hover:bg-muted hover:text-foreground',\n 'data-[state=on]:bg-muted data-[state=on]:text-foreground',\n ],\n },\n size: {\n sm: 'h-7 px-2 text-xs',\n md: 'h-9 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n icon: 'h-9 w-9',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'md',\n },\n});\n\n/** Props for the Toggle component */\nexport interface ToggleProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>,\n VariantProps<typeof toggleVariants> {\n /** Controlled pressed state */\n pressed?: boolean;\n /** Default pressed state (uncontrolled) */\n defaultPressed?: boolean;\n /** Callback fired when the pressed state changes */\n onPressedChange?: (pressed: boolean) => void;\n}\n\nconst Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(\n (\n {\n pressed: controlledPressed,\n defaultPressed = false,\n onPressedChange,\n variant,\n size,\n className,\n children,\n ...props\n },\n ref\n ) => {\n const isControlled = controlledPressed !== undefined;\n const [internalPressed, setInternalPressed] = React.useState(defaultPressed);\n\n const isPressed = isControlled ? controlledPressed! : internalPressed;\n\n const handleClick = () => {\n const next = !isPressed;\n if (!isControlled) setInternalPressed(next);\n onPressedChange?.(next);\n };\n\n return (\n <button\n ref={ref}\n type=\"button\"\n aria-pressed={isPressed}\n data-state={isPressed ? 'on' : 'off'}\n onClick={handleClick}\n className={toggleVariants({ variant, size, className })}\n {...props}\n >\n {children}\n </button>\n );\n }\n);\n\nToggle.displayName = 'Toggle';\n\n// ─── ToggleGroup ─────────────────────────────────────────────────────────────\n\ninterface ToggleGroupContextValue {\n value: string[];\n onValueChange: (value: string[]) => void;\n type: 'single' | 'multiple';\n variant?: VariantProps<typeof toggleVariants>['variant'];\n size?: VariantProps<typeof toggleVariants>['size'];\n}\n\nconst ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null);\n\n/** Props for the ToggleGroup component */\nexport interface ToggleGroupProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n /** Whether only one or multiple items can be active at a time */\n type?: 'single' | 'multiple';\n /** Controlled array of active item values */\n value?: string[];\n /** Default active values (uncontrolled) */\n defaultValue?: string[];\n /** Callback fired when the active values change */\n onValueChange?: (value: string[]) => void;\n /** Variant applied to all child ToggleGroupItems (can be overridden per item) */\n variant?: VariantProps<typeof toggleVariants>['variant'];\n /** Size applied to all child ToggleGroupItems (can be overridden per item) */\n size?: VariantProps<typeof toggleVariants>['size'];\n children: React.ReactNode;\n /** Disable the entire group */\n disabled?: boolean;\n}\n\nconst ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(\n (\n {\n type = 'single',\n value: controlledValue,\n defaultValue = [],\n onValueChange,\n variant = 'default',\n size = 'md',\n className,\n children,\n disabled,\n ...props\n },\n ref\n ) => {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState<string[]>(defaultValue);\n const value = isControlled ? controlledValue! : internalValue;\n\n const handleValueChange = (newValues: string[]) => {\n if (!isControlled) setInternalValue(newValues);\n onValueChange?.(newValues);\n };\n\n return (\n <ToggleGroupContext.Provider value={{ value, onValueChange: handleValueChange, type, variant, size }}>\n <div\n ref={ref}\n role=\"group\"\n className={cn('inline-flex items-center gap-1', className)}\n aria-disabled={disabled}\n {...props}\n >\n {children}\n </div>\n </ToggleGroupContext.Provider>\n );\n }\n);\n\nToggleGroup.displayName = 'ToggleGroup';\n\n/** Props for the ToggleGroupItem component */\nexport interface ToggleGroupItemProps\n extends Omit<ToggleProps, 'pressed' | 'onPressedChange'> {\n /** Unique value identifying this item within the group */\n value: string;\n}\n\nconst ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\n ({ value, variant: itemVariant, size: itemSize, children, ...props }, ref) => {\n const ctx = React.useContext(ToggleGroupContext);\n if (!ctx) throw new Error('ToggleGroupItem must be inside ToggleGroup');\n\n const { value: groupValue, onValueChange, type, variant: ctxVariant, size: ctxSize } = ctx;\n const isPressed = groupValue.includes(value);\n\n const handlePressedChange = (pressed: boolean) => {\n if (type === 'single') {\n onValueChange(pressed ? [value] : []);\n } else {\n onValueChange(\n pressed ? [...groupValue, value] : groupValue.filter((v) => v !== value)\n );\n }\n };\n\n return (\n <Toggle\n ref={ref}\n pressed={isPressed}\n onPressedChange={handlePressedChange}\n variant={itemVariant ?? ctxVariant}\n size={itemSize ?? ctxSize}\n {...props}\n >\n {children}\n </Toggle>\n );\n }\n);\n\nToggleGroupItem.displayName = 'ToggleGroupItem';\n\nexport { Toggle, ToggleGroup, ToggleGroupItem };\n"
1103
1140
  }
1104
1141
  ]
1105
1142
  },
@@ -1128,7 +1165,7 @@
1128
1165
  "files": [
1129
1166
  {
1130
1167
  "path": "src/components/ui/tree-view/TreeView.tsx",
1131
- "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 };\r\n"
1168
+ "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 };\n"
1132
1169
  }
1133
1170
  ]
1134
1171
  },
@@ -1142,11 +1179,32 @@
1142
1179
  "files": [
1143
1180
  {
1144
1181
  "path": "src/components/ui/typography/Typography.tsx",
1145
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Copy, Check, ExternalLink } from 'lucide-react';\r\nimport { useCopy } from '@/hooks/useCopy';\r\n\r\n// ─── Shared text style variants ───────────────────────────────────────────────\r\n\r\nconst textVariants = tv({\r\n base: '',\r\n variants: {\r\n size: {\r\n xs: 'text-xs',\r\n sm: 'text-sm',\r\n md: 'text-base',\r\n lg: 'text-lg',\r\n xl: 'text-xl',\r\n '2xl': 'text-2xl',\r\n '3xl': 'text-3xl',\r\n },\r\n weight: {\r\n thin: 'font-thin',\r\n light: 'font-light',\r\n normal: 'font-normal',\r\n medium: 'font-medium',\r\n semibold: 'font-semibold',\r\n bold: 'font-bold',\r\n extrabold: 'font-extrabold',\r\n },\r\n color: {\r\n default: 'text-foreground',\r\n muted: 'text-muted-foreground',\r\n primary: 'text-primary',\r\n success: 'text-success',\r\n warning: 'text-warning',\r\n danger: 'text-danger',\r\n inherit: 'text-inherit',\r\n },\r\n align: {\r\n left: 'text-left',\r\n center: 'text-center',\r\n right: 'text-right',\r\n justify: 'text-justify',\r\n },\r\n leading: {\r\n none: 'leading-none',\r\n tight: 'leading-tight',\r\n normal: 'leading-normal',\r\n relaxed: 'leading-relaxed',\r\n loose: 'leading-loose',\r\n },\r\n tracking: {\r\n tighter: 'tracking-tighter',\r\n tight: 'tracking-tight',\r\n normal: 'tracking-normal',\r\n wide: 'tracking-wide',\r\n widest: 'tracking-widest',\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'default',\r\n },\r\n});\r\n\r\n// ─── Text ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface TextProps\r\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** HTML tag to render — default `span` */\r\n as?: React.ElementType;\r\n /** Bold */\r\n strong?: boolean;\r\n /** Italic */\r\n italic?: boolean;\r\n /** Underline */\r\n underline?: boolean;\r\n /** Strikethrough */\r\n strikethrough?: boolean;\r\n /** Gradient text (primary → indigo) */\r\n gradient?: boolean;\r\n /** Highlighted mark background */\r\n mark?: boolean;\r\n /** Single-line truncate with ellipsis */\r\n truncate?: boolean;\r\n /** Multi-line clamp (number of lines) */\r\n lines?: 1 | 2 | 3 | 4 | 5;\r\n /** Tabular numbers — fixed-width digits */\r\n numeric?: boolean;\r\n /** Inline code styling */\r\n code?: boolean;\r\n /** Show copy icon on hover, copies text content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst LINES_MAP: Record<number, string> = {\r\n 1: 'line-clamp-1',\r\n 2: 'line-clamp-2',\r\n 3: 'line-clamp-3',\r\n 4: 'line-clamp-4',\r\n 5: 'line-clamp-5',\r\n};\r\n\r\nconst Text = React.forwardRef<HTMLElement, TextProps>(\r\n (\r\n {\r\n as: Tag = 'span',\r\n size,\r\n weight,\r\n color,\r\n align,\r\n leading,\r\n tracking,\r\n strong,\r\n italic,\r\n underline,\r\n strikethrough,\r\n gradient,\r\n mark,\r\n truncate,\r\n lines,\r\n numeric,\r\n code,\r\n copyable,\r\n className,\r\n children,\r\n onClick,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\r\n if (copyable && elRef.current) {\r\n copy(elRef.current.innerText);\r\n }\r\n onClick?.(e);\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading, tracking }),\r\n strong && 'font-bold',\r\n italic && 'italic',\r\n underline && 'underline underline-offset-2',\r\n strikethrough && 'line-through',\r\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\r\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\r\n truncate && 'block max-w-full truncate',\r\n lines && cn('block', LINES_MAP[lines]),\r\n numeric && 'tabular-nums',\r\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\r\n className,\r\n )}\r\n onClick={handleClick}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\r\n : <Copy className=\"w-3.5 h-3.5\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nText.displayName = 'Text';\r\n\r\n// ─── Heading ──────────────────────────────────────────────────────────────────\r\n\r\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\r\n 1: 'text-4xl font-extrabold tracking-tight',\r\n 2: 'text-3xl font-bold tracking-tight',\r\n 3: 'text-2xl font-semibold tracking-tight',\r\n 4: 'text-xl font-semibold',\r\n 5: 'text-lg font-medium',\r\n 6: 'text-base font-medium',\r\n};\r\n\r\nexport interface HeadingProps\r\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Heading level 1–6, also sets default size (default: 2) */\r\n level?: 1 | 2 | 3 | 4 | 5 | 6;\r\n /** Show copy icon on hover */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\r\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\r\n const Tag = `h${level}` as React.ElementType;\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLHeadingElement>(null);\r\n const mergedRef = (node: HTMLHeadingElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n HEADING_SIZE[level],\r\n textVariants({ size, weight, color, align }),\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\r\n className,\r\n )}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-4 h-4 text-success\" />\r\n : <Copy className=\"w-4 h-4\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nHeading.displayName = 'Heading';\r\n\r\n// ─── Paragraph ────────────────────────────────────────────────────────────────\r\n\r\nexport interface ParagraphProps\r\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Larger intro-text styling */\r\n lead?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\r\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading }),\r\n lead && 'text-xl text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nParagraph.displayName = 'Paragraph';\r\n\r\n// ─── Lead ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nLead.displayName = 'Lead';\r\n\r\n// ─── Blockquote ───────────────────────────────────────────────────────────────\r\n\r\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\r\n /** Citation source text shown below the quote */\r\n cite?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\r\n ({ cite, className, children, ...props }, ref) => (\r\n <figure className=\"my-1\">\r\n <blockquote\r\n ref={ref}\r\n className={cn(\r\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </blockquote>\r\n {cite && (\r\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\r\n — {cite}\r\n </figcaption>\r\n )}\r\n </figure>\r\n )\r\n);\r\nBlockquote.displayName = 'Blockquote';\r\n\r\n// ─── Code (inline) ────────────────────────────────────────────────────────────\r\n\r\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Copy content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\r\n ({ copyable, className, children, ...props }, ref) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <code\r\n ref={mergedRef}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n className={cn(\r\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\r\n {copied\r\n ? <Check className=\"w-3 h-3 text-success inline\" />\r\n : <Copy className=\"w-3 h-3 inline\" />\r\n }\r\n </span>\r\n )}\r\n </code>\r\n );\r\n }\r\n);\r\nCode.displayName = 'Code';\r\n\r\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\r\n\r\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Array of keys to display; joined with `+` separator */\r\n keys?: string[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <kbd\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={cn(\r\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\r\n 'font-mono text-xs font-medium',\r\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\r\n 'text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </kbd>\r\n )\r\n);\r\nKbdKey.displayName = 'KbdKey';\r\n\r\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\r\n ({ keys, className, children, ...props }, ref) => {\r\n const items = keys ?? (children ? [children] : []);\r\n\r\n return (\r\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\r\n {items.map((key, i) => (\r\n <React.Fragment key={i}>\r\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\r\n <KbdKey>{key}</KbdKey>\r\n </React.Fragment>\r\n ))}\r\n </span>\r\n );\r\n }\r\n);\r\nKbd.displayName = 'Kbd';\r\n\r\n// ─── Link ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\r\n /** Open in new tab with rel=\"noopener noreferrer\" */\r\n external?: boolean;\r\n /** Underline behaviour — default `hover` */\r\n underline?: 'always' | 'hover' | 'none';\r\n /** Color variant */\r\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\r\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\r\n <a\r\n ref={ref}\r\n target={external ? '_blank' : props.target}\r\n rel={external ? 'noopener noreferrer' : props.rel}\r\n className={cn(\r\n 'inline-flex items-center gap-0.5 transition-colors',\r\n color === 'primary' && 'text-primary',\r\n color === 'muted' && 'text-muted-foreground',\r\n color === 'danger' && 'text-danger',\r\n color === 'foreground' && 'text-foreground',\r\n underline === 'always' && 'underline underline-offset-2',\r\n underline === 'hover' && 'hover:underline underline-offset-2',\r\n underline === 'none' && 'no-underline',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\r\n </a>\r\n )\r\n);\r\nLink.displayName = 'Link';\r\n\r\n// ─── Mark ─────────────────────────────────────────────────────────────────────\r\n\r\nconst markVariants = tv({\r\n base: 'rounded px-0.5 py-px font-medium',\r\n variants: {\r\n variant: {\r\n default: 'bg-warning/25 text-warning-foreground',\r\n primary: 'bg-primary/15 text-primary',\r\n success: 'bg-success/15 text-success',\r\n warning: 'bg-warning/25 text-warning-foreground',\r\n danger: 'bg-danger/15 text-danger',\r\n },\r\n },\r\n defaultVariants: { variant: 'default' },\r\n});\r\n\r\nexport interface MarkProps\r\n extends React.HTMLAttributes<HTMLElement>,\r\n VariantProps<typeof markVariants> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\r\n ({ variant, className, children, ...props }, ref) => (\r\n <mark\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={markVariants({ variant, className })}\r\n {...props}\r\n >\r\n {children}\r\n </mark>\r\n )\r\n);\r\nMark.displayName = 'Mark';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Text,\r\n Heading,\r\n Paragraph,\r\n Lead,\r\n Blockquote,\r\n Code,\r\n Kbd,\r\n KbdKey,\r\n Link,\r\n Mark,\r\n textVariants,\r\n markVariants,\r\n};\r\n"
1182
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Copy, Check, ExternalLink } from 'lucide-react';\nimport { useCopy } from '@/hooks/useCopy';\n\n// ─── Shared text style variants ───────────────────────────────────────────────\n\nconst textVariants = tv({\n base: '',\n variants: {\n size: {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n xl: 'text-xl',\n '2xl': 'text-2xl',\n '3xl': 'text-3xl',\n },\n weight: {\n thin: 'font-thin',\n light: 'font-light',\n normal: 'font-normal',\n medium: 'font-medium',\n semibold: 'font-semibold',\n bold: 'font-bold',\n extrabold: 'font-extrabold',\n },\n color: {\n default: 'text-foreground',\n muted: 'text-muted-foreground',\n primary: 'text-primary',\n success: 'text-success',\n warning: 'text-warning',\n danger: 'text-danger',\n inherit: 'text-inherit',\n },\n align: {\n left: 'text-left',\n center: 'text-center',\n right: 'text-right',\n justify: 'text-justify',\n },\n leading: {\n none: 'leading-none',\n tight: 'leading-tight',\n normal: 'leading-normal',\n relaxed: 'leading-relaxed',\n loose: 'leading-loose',\n },\n tracking: {\n tighter: 'tracking-tighter',\n tight: 'tracking-tight',\n normal: 'tracking-normal',\n wide: 'tracking-wide',\n widest: 'tracking-widest',\n },\n },\n defaultVariants: {\n color: 'default',\n },\n});\n\n// ─── Text ─────────────────────────────────────────────────────────────────────\n\nexport interface TextProps\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\n VariantProps<typeof textVariants> {\n /** HTML tag to render — default `span` */\n as?: React.ElementType;\n /** Bold */\n strong?: boolean;\n /** Italic */\n italic?: boolean;\n /** Underline */\n underline?: boolean;\n /** Strikethrough */\n strikethrough?: boolean;\n /** Gradient text (primary → indigo) */\n gradient?: boolean;\n /** Highlighted mark background */\n mark?: boolean;\n /** Single-line truncate with ellipsis */\n truncate?: boolean;\n /** Multi-line clamp (number of lines) */\n lines?: 1 | 2 | 3 | 4 | 5;\n /** Tabular numbers — fixed-width digits */\n numeric?: boolean;\n /** Inline code styling */\n code?: boolean;\n /** Show copy icon on hover, copies text content on click */\n copyable?: boolean;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst LINES_MAP: Record<number, string> = {\n 1: 'line-clamp-1',\n 2: 'line-clamp-2',\n 3: 'line-clamp-3',\n 4: 'line-clamp-4',\n 5: 'line-clamp-5',\n};\n\nconst Text = React.forwardRef<HTMLElement, TextProps>(\n (\n {\n as: Tag = 'span',\n size,\n weight,\n color,\n align,\n leading,\n tracking,\n strong,\n italic,\n underline,\n strikethrough,\n gradient,\n mark,\n truncate,\n lines,\n numeric,\n code,\n copyable,\n className,\n children,\n onClick,\n ...props\n },\n ref\n ) => {\n const { copied, copy } = useCopy();\n const elRef = React.useRef<HTMLElement>(null);\n const mergedRef = (node: HTMLElement | null) => {\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\n };\n\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\n if (copyable && elRef.current) {\n copy(elRef.current.innerText);\n }\n onClick?.(e);\n };\n\n return (\n <Tag\n ref={mergedRef}\n className={cn(\n textVariants({ size, weight, color, align, leading, tracking }),\n strong && 'font-bold',\n italic && 'italic',\n underline && 'underline underline-offset-2',\n strikethrough && 'line-through',\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\n truncate && 'block max-w-full truncate',\n lines && cn('block', LINES_MAP[lines]),\n numeric && 'tabular-nums',\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\n className,\n )}\n onClick={handleClick}\n {...props}\n >\n {children}\n {copyable && (\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\n {copied\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\n : <Copy className=\"w-3.5 h-3.5\" />\n }\n </span>\n )}\n </Tag>\n );\n }\n);\nText.displayName = 'Text';\n\n// ─── Heading ──────────────────────────────────────────────────────────────────\n\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\n 1: 'text-4xl font-extrabold tracking-tight',\n 2: 'text-3xl font-bold tracking-tight',\n 3: 'text-2xl font-semibold tracking-tight',\n 4: 'text-xl font-semibold',\n 5: 'text-lg font-medium',\n 6: 'text-base font-medium',\n};\n\nexport interface HeadingProps\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\n VariantProps<typeof textVariants> {\n /** Heading level 1–6, also sets default size (default: 2) */\n level?: 1 | 2 | 3 | 4 | 5 | 6;\n /** Show copy icon on hover */\n copyable?: boolean;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\n const Tag = `h${level}` as React.ElementType;\n const { copied, copy } = useCopy();\n const elRef = React.useRef<HTMLHeadingElement>(null);\n const mergedRef = (node: HTMLHeadingElement | null) => {\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\n };\n\n return (\n <Tag\n ref={mergedRef}\n className={cn(\n HEADING_SIZE[level],\n textVariants({ size, weight, color, align }),\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\n className,\n )}\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\n {...props}\n >\n {children}\n {copyable && (\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\n {copied\n ? <Check className=\"w-4 h-4 text-success\" />\n : <Copy className=\"w-4 h-4\" />\n }\n </span>\n )}\n </Tag>\n );\n }\n);\nHeading.displayName = 'Heading';\n\n// ─── Paragraph ────────────────────────────────────────────────────────────────\n\nexport interface ParagraphProps\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\n VariantProps<typeof textVariants> {\n /** Larger intro-text styling */\n lead?: boolean;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\n <p\n ref={ref}\n className={cn(\n textVariants({ size, weight, color, align, leading }),\n lead && 'text-xl text-muted-foreground',\n className,\n )}\n {...props}\n >\n {children}\n </p>\n )\n);\nParagraph.displayName = 'Paragraph';\n\n// ─── Lead ────────────────────────────────────────────────────────────────────\n\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\n ({ className, children, ...props }, ref) => (\n <p\n ref={ref}\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\n {...props}\n >\n {children}\n </p>\n )\n);\nLead.displayName = 'Lead';\n\n// ─── Blockquote ───────────────────────────────────────────────────────────────\n\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\n /** Citation source text shown below the quote */\n cite?: string;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\n ({ cite, className, children, ...props }, ref) => (\n <figure className=\"my-1\">\n <blockquote\n ref={ref}\n className={cn(\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\n className,\n )}\n {...props}\n >\n {children}\n </blockquote>\n {cite && (\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\n — {cite}\n </figcaption>\n )}\n </figure>\n )\n);\nBlockquote.displayName = 'Blockquote';\n\n// ─── Code (inline) ────────────────────────────────────────────────────────────\n\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\n /** Copy content on click */\n copyable?: boolean;\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\n ({ copyable, className, children, ...props }, ref) => {\n const { copied, copy } = useCopy();\n const elRef = React.useRef<HTMLElement>(null);\n const mergedRef = (node: HTMLElement | null) => {\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\n };\n\n return (\n <code\n ref={mergedRef}\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\n className={cn(\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\n className,\n )}\n {...props}\n >\n {children}\n {copyable && (\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\n {copied\n ? <Check className=\"w-3 h-3 text-success inline\" />\n : <Copy className=\"w-3 h-3 inline\" />\n }\n </span>\n )}\n </code>\n );\n }\n);\nCode.displayName = 'Code';\n\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\n\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\n /** Array of keys to display; joined with `+` separator */\n keys?: string[];\n className?: string;\n children?: React.ReactNode;\n}\n\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\n ({ className, children, ...props }, ref) => (\n <kbd\n ref={ref as React.Ref<HTMLElement>}\n className={cn(\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\n 'font-mono text-xs font-medium',\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\n 'text-muted-foreground',\n className,\n )}\n {...props}\n >\n {children}\n </kbd>\n )\n);\nKbdKey.displayName = 'KbdKey';\n\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\n ({ keys, className, children, ...props }, ref) => {\n const items = keys ?? (children ? [children] : []);\n\n return (\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\n {items.map((key, i) => (\n <React.Fragment key={i}>\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\n <KbdKey>{key}</KbdKey>\n </React.Fragment>\n ))}\n </span>\n );\n }\n);\nKbd.displayName = 'Kbd';\n\n// ─── Link ─────────────────────────────────────────────────────────────────────\n\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\n /** Open in new tab with rel=\"noopener noreferrer\" */\n external?: boolean;\n /** Underline behaviour — default `hover` */\n underline?: 'always' | 'hover' | 'none';\n /** Color variant */\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\n <a\n ref={ref}\n target={external ? '_blank' : props.target}\n rel={external ? 'noopener noreferrer' : props.rel}\n className={cn(\n 'inline-flex items-center gap-0.5 transition-colors',\n color === 'primary' && 'text-primary',\n color === 'muted' && 'text-muted-foreground',\n color === 'danger' && 'text-danger',\n color === 'foreground' && 'text-foreground',\n underline === 'always' && 'underline underline-offset-2',\n underline === 'hover' && 'hover:underline underline-offset-2',\n underline === 'none' && 'no-underline',\n className,\n )}\n {...props}\n >\n {children}\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\n </a>\n )\n);\nLink.displayName = 'Link';\n\n// ─── Mark ─────────────────────────────────────────────────────────────────────\n\nconst markVariants = tv({\n base: 'rounded px-0.5 py-px font-medium',\n variants: {\n variant: {\n default: 'bg-warning/25 text-warning-foreground',\n primary: 'bg-primary/15 text-primary',\n success: 'bg-success/15 text-success',\n warning: 'bg-warning/25 text-warning-foreground',\n danger: 'bg-danger/15 text-danger',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport interface MarkProps\n extends React.HTMLAttributes<HTMLElement>,\n VariantProps<typeof markVariants> {\n className?: string;\n children?: React.ReactNode;\n}\n\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\n ({ variant, className, children, ...props }, ref) => (\n <mark\n ref={ref as React.Ref<HTMLElement>}\n className={markVariants({ variant, className })}\n {...props}\n >\n {children}\n </mark>\n )\n);\nMark.displayName = 'Mark';\n\n// ─── Exports ──────────────────────────────────────────────────────────────────\n\nexport {\n Text,\n Heading,\n Paragraph,\n Lead,\n Blockquote,\n Code,\n Kbd,\n KbdKey,\n Link,\n Mark,\n textVariants,\n markVariants,\n};\n"
1146
1183
  },
1147
1184
  {
1148
1185
  "path": "src/hooks/useCopy.ts",
1149
- "content": "import { useState, useCallback } from 'react';\r\n\r\nexport function useCopy(timeout = 2000) {\r\n const [copied, setCopied] = useState(false);\r\n\r\n const copy = useCallback(\r\n async (text: string) => {\r\n try {\r\n await navigator.clipboard.writeText(text);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), timeout);\r\n } catch {\r\n /* noop */\r\n }\r\n },\r\n [timeout],\r\n );\r\n\r\n return { copied, copy };\r\n}\r\n"
1186
+ "content": "import { useState, useCallback } from 'react';\n\nexport function useCopy(timeout = 2000) {\n const [copied, setCopied] = useState(false);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n setTimeout(() => setCopied(false), timeout);\n } catch {\n /* noop */\n }\n },\n [timeout],\n );\n\n return { copied, copy };\n}\n"
1187
+ }
1188
+ ]
1189
+ },
1190
+ "watermark": {
1191
+ "name": "watermark",
1192
+ "dependencies": [
1193
+ "tailwind-variants"
1194
+ ],
1195
+ "internalDependencies": [],
1196
+ "files": [
1197
+ {
1198
+ "path": "src/components/ui/watermark/Watermark.tsx",
1199
+ "content": "import * as React from 'react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { buildWatermarkSvg, type WatermarkFont } from './buildWatermarkSvg';\n\nconst watermarkVariants = tv({\n slots: {\n root: 'relative',\n layer: 'pointer-events-none absolute inset-0 bg-repeat select-none',\n },\n});\n\nexport type { WatermarkFont } from './buildWatermarkSvg';\n\nexport interface WatermarkProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {\n /** Text watermark. Pass an array to render multiple lines. */\n content?: string | string[];\n /** Image URL used as watermark. Takes priority over `content`. */\n image?: string;\n /** Width of a single watermark cell (content area). */\n width?: number;\n /** Height of a single watermark cell (content area). */\n height?: number;\n /** Rotation in degrees. */\n rotate?: number;\n /** Gap between repetitions: `[x, y]` in pixels. */\n gap?: [number, number];\n /** Offset of the first watermark cell: `[x, y]` in pixels. */\n offset?: [number, number];\n /** Font styling for text watermarks. */\n font?: WatermarkFont;\n /** Stacking context above children. */\n zIndex?: number;\n}\n\nconst DEFAULT_FONT: Required<WatermarkFont> = {\n color: 'rgba(0, 0, 0, 0.15)',\n fontSize: 16,\n fontWeight: 'normal',\n fontFamily: 'sans-serif',\n fontStyle: 'normal',\n};\n\nconst DEFAULT_GAP: [number, number] = [100, 100];\nconst DEFAULT_ROTATE = -22;\nconst DEFAULT_TEXT_WIDTH = 120;\nconst DEFAULT_TEXT_HEIGHT = 64;\nconst DEFAULT_IMAGE_SIZE = 120;\n\nconst toLines = (content?: string | string[]): string[] => {\n if (Array.isArray(content)) return content;\n if (typeof content === 'string' && content.length > 0) return [content];\n return [];\n};\n\nconst Watermark = React.forwardRef<HTMLDivElement, WatermarkProps>(\n (\n {\n content,\n image,\n width,\n height,\n rotate = DEFAULT_ROTATE,\n gap = DEFAULT_GAP,\n offset,\n font,\n zIndex = 9,\n className,\n style,\n children,\n ...rest\n },\n ref,\n ) => {\n const { root, layer } = watermarkVariants();\n const lines = React.useMemo(() => toLines(content), [content]);\n const mergedFont = React.useMemo<Required<WatermarkFont>>(\n () => ({ ...DEFAULT_FONT, ...font }),\n [font],\n );\n\n const cellWidth = width ?? (image ? DEFAULT_IMAGE_SIZE : DEFAULT_TEXT_WIDTH);\n const cellHeight = height ?? (image ? DEFAULT_IMAGE_SIZE : DEFAULT_TEXT_HEIGHT);\n const [gapX, gapY] = gap;\n\n const svg = React.useMemo(\n () =>\n buildWatermarkSvg({\n lines,\n image,\n width: cellWidth,\n height: cellHeight,\n gapX,\n gapY,\n rotate,\n font: mergedFont,\n }),\n [lines, image, cellWidth, cellHeight, gapX, gapY, rotate, mergedFont],\n );\n\n const hasWatermark = Boolean(image) || lines.length > 0;\n\n return (\n <div ref={ref} className={cn(root(), className)} style={style} {...rest}>\n {children}\n {hasWatermark && (\n <div\n aria-hidden=\"true\"\n data-testid=\"watermark-layer\"\n className={layer()}\n style={{\n zIndex,\n backgroundImage: `url(\"${svg.url}\")`,\n backgroundSize: `${svg.tileWidth}px ${svg.tileHeight}px`,\n backgroundPosition: offset ? `${offset[0]}px ${offset[1]}px` : undefined,\n }}\n />\n )}\n </div>\n );\n },\n);\nWatermark.displayName = 'Watermark';\n\nexport { Watermark };\n"
1200
+ },
1201
+ {
1202
+ "path": "src/components/ui/watermark/buildWatermarkSvg.ts",
1203
+ "content": "/**\n * Pure helpers used by the Watermark component.\n * Kept framework-agnostic so they can be unit-tested in isolation.\n */\n\nexport interface WatermarkFont {\n color?: string;\n fontSize?: number;\n fontWeight?: number | string;\n fontFamily?: string;\n fontStyle?: 'normal' | 'italic';\n}\n\nexport interface BuildWatermarkSvgOptions {\n /** Lines of text rendered as the watermark. Ignored when `image` is set. */\n lines?: string[];\n /** Image URL rendered as the watermark. Takes priority over text. */\n image?: string;\n /** Width of the watermark content area (excluding gap). */\n width: number;\n /** Height of the watermark content area (excluding gap). */\n height: number;\n /** Horizontal gap between repetitions, in pixels. */\n gapX: number;\n /** Vertical gap between repetitions, in pixels. */\n gapY: number;\n /** Rotation in degrees, centered on the content. */\n rotate: number;\n /** Font configuration for text watermarks. */\n font: Required<WatermarkFont>;\n}\n\nexport interface BuildWatermarkSvgResult {\n /** `data:image/svg+xml,...` URL safe to use as `background-image`. */\n url: string;\n /** Full tile size = content + gap. */\n tileWidth: number;\n tileHeight: number;\n}\n\nconst escapeXml = (value: string): string =>\n value.replace(/[<>&\"']/g, (ch) => {\n switch (ch) {\n case '<': return '&lt;';\n case '>': return '&gt;';\n case '&': return '&amp;';\n case '\"': return '&quot;';\n default: return '&apos;';\n }\n });\n\nconst renderTextNode = (lines: string[], font: Required<WatermarkFont>, cx: number, cy: number): string => {\n const lineHeight = font.fontSize * 1.2;\n const startY = cy - ((lines.length - 1) * lineHeight) / 2;\n const tspans = lines\n .map((line, i) =>\n `<tspan x=\"${cx}\" y=\"${startY + i * lineHeight}\">${escapeXml(line)}</tspan>`,\n )\n .join('');\n return (\n `<text fill=\"${font.color}\" font-size=\"${font.fontSize}\" font-weight=\"${font.fontWeight}\" ` +\n `font-family=\"${font.fontFamily}\" font-style=\"${font.fontStyle}\" ` +\n `text-anchor=\"middle\" dominant-baseline=\"middle\">${tspans}</text>`\n );\n};\n\nconst renderImageNode = (image: string, width: number, height: number, cx: number, cy: number): string =>\n `<image href=\"${escapeXml(image)}\" x=\"${cx - width / 2}\" y=\"${cy - height / 2}\" ` +\n `width=\"${width}\" height=\"${height}\" preserveAspectRatio=\"xMidYMid meet\" />`;\n\n/**\n * Build a tiled SVG data URL representing one watermark cell.\n * The cell is `(width + gapX) × (height + gapY)`; the watermark content is\n * centered and rotated. Browsers tile it via `background-repeat`.\n */\nexport function buildWatermarkSvg(opts: BuildWatermarkSvgOptions): BuildWatermarkSvgResult {\n const { lines, image, width, height, gapX, gapY, rotate, font } = opts;\n const tileWidth = width + gapX;\n const tileHeight = height + gapY;\n const cx = tileWidth / 2;\n const cy = tileHeight / 2;\n\n const inner = image\n ? renderImageNode(image, width, height, cx, cy)\n : renderTextNode(lines ?? [], font, cx, cy);\n\n const svg =\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${tileWidth}\" height=\"${tileHeight}\" ` +\n `viewBox=\"0 0 ${tileWidth} ${tileHeight}\">` +\n `<g transform=\"rotate(${rotate} ${cx} ${cy})\">${inner}</g></svg>`;\n\n return {\n url: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,\n tileWidth,\n tileHeight,\n };\n}\n"
1204
+ },
1205
+ {
1206
+ "path": "src/components/ui/watermark/index.ts",
1207
+ "content": "export * from './Watermark';\n"
1150
1208
  }
1151
1209
  ]
1152
1210
  }