Compare commits

...
Author SHA1 Message Date
hjlarry 2fa13cdf86 if session unauthorized, rejoin 2025-11-11 16:38:55 +08:00
hjlarry 39de7673eb add redis key expire time for collaboration 2025-11-11 16:13:05 +08:00
hjlarry d930d8cc4a fix setting dialog z-index 2025-11-10 18:02:36 +08:00
hjlarry 97626a3ba5 can't zoomOnPinch when mouse over comment preview 2025-11-07 09:27:49 +08:00
hjlarry b7f7d04639 fix comment input mention not display avatar 2025-11-05 18:09:42 +08:00
hjlarry 13674bd859 comment input mode click empty place can close 2025-11-05 17:41:10 +08:00
hjlarry fb9cbc0471 comment mode can't click node 2025-11-05 14:14:36 +08:00
hjlarry 2f60288d86 fix: resize workflow canvas cause incorrect comment position 2025-11-05 14:08:21 +08:00
hjlarry ee3ded0fc2 fix control layer 2025-10-22 10:25:31 +08:00
hjlarry 351bad9ec4 fix minimap disable collobroation 2025-10-22 10:21:25 +08:00
hjlarry 9bf7473bbf hide comments when disable collaboration 2025-10-22 10:10:23 +08:00
hjlarry fa09c88f5c add CollaborationEnabled for comment shortcut 2025-10-22 09:59:43 +08:00
hjlarry 83df78d0c8 hide comments icon when disable collabrotion mode 2025-10-22 09:50:37 +08:00
hjlarry 79266f7302 add note node sync data 2025-10-21 15:34:44 +08:00
hjlarry 7fecc7236c add more collaboration manager unit tests 2025-10-21 14:37:31 +08:00
hjlarry 9c7f6b7b71 add crdt provider unittests 2025-10-21 14:27:13 +08:00
hjlarry b46da93e99 add unittests for event-emitter 2025-10-21 14:12:13 +08:00
hjlarry e299a1fb20 add ws manager unit tests 2025-10-21 14:09:25 +08:00
hjlarry 122033cadb sort out code 2025-10-21 12:27:11 +08:00
hjlarry df9bd1b3b5 add Parameters of ParametersExtractor node sync 2025-10-21 12:14:48 +08:00
hjlarry f74492eb59 add prompt_template of LLM node sync 2025-10-21 12:00:42 +08:00
hjlarry eaf1ae37dd add ENABLE_COLLABORATION_MODE 2025-10-21 11:46:28 +08:00
hjlarry 8e3b412ff6 fix websocket cookie auth 2025-10-21 11:46:00 +08:00
hjlarry ba17f576e9 Merge remote-tracking branch 'myori/main' into feat/collaboration 2025-10-21 08:47:01 +08:00
JoelandGitHub c11cdf7468 fix: infinite reload (#27150) 2025-10-20 21:18:26 +09:00
GuanMuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
6217c96576 Fix type error (#27152)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-20 19:35:46 +08:00
Bohan FengandGitHub 977690590e fix: parameter extractor instructions placeholder not replaced (#26235) (#27135) 2025-10-20 19:39:20 +09:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
fd845c8b6c chore: add more stories (#27142)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-20 18:30:52 +08:00
d7d9abb007 chore: use new api to check login status (#27143)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 16:54:31 +08:00
9f22b2726b chore: translate i18n files and update type definitions (#27141)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-10-20 15:45:06 +08:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f28b519556 Allow custom app headers in CORS configuration (#27133)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-20 15:39:07 +08:00
croatialuandGitHub 762cf91133 feat(web): Add parameter rendering to MCP tool item component (#27099) 2025-10-20 15:37:30 +08:00
GuanMuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
9dd3dcff2b Fix type error 5 (#27139)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-20 15:35:13 +08:00
lyzno1 9415ce4512 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-20 10:04:13 +08:00
lyzno1 239536933b Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-17 19:33:40 +08:00
hjlarry 80b34598e9 try to fix start node collaboration 2025-10-16 10:18:37 +08:00
lyzno1 9c66b92c34 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-15 21:08:08 +08:00
lyzno1 79872ea5e2 Refine workflow comment avatar highlight ring 2025-10-15 14:58:03 +08:00
lyzno1 cbf181bd76 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-15 11:06:23 +08:00
lyzno1 1393d21858 fix(web): adjust online users badge sizing and add pointer cursor to chevron 2025-10-15 11:06:04 +08:00
lyzno1 3a46b7bd18 fix(web): restyle workflow online-users avatar stack and dropdown 2025-10-15 10:48:38 +08:00
lyzno1 0bbfd81d26 fix: tooltip font 2025-10-15 10:35:42 +08:00
lyzno1 86db517142 fix(web): make workflow online-users dropdown click-based with revised spacing 2025-10-15 10:34:00 +08:00
lyzno1 50151f4007 fix(web): adjust workflow online-users icon and label styles 2025-10-15 10:21:54 +08:00
lyzno1 0395d1f91f Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-15 10:02:55 +08:00
lyzno1 5f4c1e4057 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-15 09:33:54 +08:00
hjlarry d14413f3b0 comment click caculate the panel width 2025-10-15 09:11:44 +08:00
lyzno1 4fd968270c Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-14 18:56:27 +08:00
hjlarry 708a7dd362 fix comment mode can't drag node 2025-10-14 17:31:03 +08:00
hjlarry cd85b75312 fix control panel hovered by comment icon 2025-10-14 17:16:33 +08:00
hjlarry d685da377e fix minimap 2025-10-14 17:11:22 +08:00
hjlarry 8583992d23 when new user connected should rebroadcast the graph data 2025-10-14 16:57:02 +08:00
hjlarry 23fec75c90 cache the new created comment 2025-10-14 11:21:18 +08:00
hjlarry ebe7303894 fix loop variable not sync well 2025-10-14 10:10:34 +08:00
hjlarry 79fb977f10 fix loop/iteration incorrect nodes width 2025-10-14 09:54:37 +08:00
lyzno1 c0af3414a3 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-14 07:54:05 +08:00
hjlarry 1857d37fae sync app published 2025-10-13 16:42:17 +08:00
hjlarry 60fdbb56a9 fix all lines missing 2025-10-13 16:38:50 +08:00
hjlarry 4c7853164d fix mcp server edit modal disappear 2025-10-13 16:36:39 +08:00
hjlarry 6c7a3ce4bb sync workflow publish to mcp server 2025-10-13 14:07:26 +08:00
lyzno1 a9e74b21f1 fix: increase ContentDialog z-index to display above workflow operators
The collaboration feature increased workflow operator z-index from z-10 to z-[60].
This caused the AppInfo ContentDialog (z-30) to appear below the operator buttons.
Increased ContentDialog z-index to z-[70] to ensure proper layer hierarchy.
2025-10-13 14:00:28 +08:00
lyzno1 e6730f7164 fix: dropdown menu border 2025-10-13 13:15:54 +08:00
lyzno1 3344723393 fix: prevent Enter key from triggering submit during IME composition
Add isComposing check at the start of handleKeyDown to ignore keyboard events during IME (Chinese/Japanese/Korean) input composition. This follows the existing pattern used in tag-management component and prevents premature form submission when users press Enter to confirm IME candidates.
2025-10-13 13:09:52 +08:00
lyzno1 c571185a91 fix: extract @mention highlighting from content in real-time to persist after edit 2025-10-13 13:03:55 +08:00
lyzno1 325c1cfa41 fix: prevent Save button flash by maintaining loading state until edit closes 2025-10-13 12:56:18 +08:00
lyzno1 1069421753 refactor: replace keyboard shortcut icons with custom EnterKey icon 2025-10-13 12:52:07 +08:00
lyzno1 b33a97ea5b style: update comment thread UI with design specs
- Fix edit bubble: keep avatar visible and match ThreadMessage layout
- Update edit container: rounded-xl, p-1, shadow-md, backdrop-blur
- Add keyboard shortcut icons (Cmd+Enter) to Save button
- Fix hover background: full-width with -mx-4 negative margin technique
- Apply design tokens consistently across components
2025-10-13 12:42:41 +08:00
lyzno1 d2c1d4c337 style: update mention dropdown UI to match design specs
- Update container: rounded-xl, border-0.5px, backdrop-blur, bg opacity 95%
- Update items: rounded-md with asymmetric padding (py-1 pl-2 pr-3)
- Use project design tokens (shadow-lg, bg-state-base-hover)
2025-10-13 12:24:28 +08:00
lyzno1 67762cf1d8 chore: resolve merge conflict in pnpm-lock.yaml
Merged origin/main into feat/collaboration and resolved dependency lock file conflicts by regenerating pnpm-lock.yaml through clean install.

Changes:
- Resolved eslint version differences (9.36.0 vs 9.35.0)
- Updated lock file reflects current dependency resolution
- All other changes from main branch successfully merged
2025-10-13 11:53:43 +08:00
hjlarry eadce0287c app meta sync 2025-10-13 11:49:54 +08:00
hjlarry ecaff5b63f fix loop var change cause collaboration crash 2025-10-13 10:06:50 +08:00
hjlarry a300c9ef96 fix canvas empty on the bottom 2025-10-13 09:38:59 +08:00
lyzno1 44fe71e4db fix: ensure comment thread always scrolls to bottom on first render 2025-10-12 13:27:42 +08:00
lyzno1 0ac32188c5 feat: implement comprehensive focus management for comment thread
- Add forwardRef support to MentionInput to expose textarea ref
- Auto-focus reply input when thread opens (100ms delay)
- Restore focus after reply submission and edit operations
- Add Esc key handler to close thread with smart guards
- Enhance accessibility with ARIA attributes (dialog, modal, labelledby)
- Improve keyboard navigation and user experience

Implements P0-P3 priorities following WCAG 2.1 AA accessibility standards
2025-10-12 13:21:57 +08:00
lyzno1 9aaace706b feat: optimize comments panel filter UI and interaction logic 2025-10-12 13:04:24 +08:00
lyzno1 b22de5a824 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-12 13:04:07 +08:00
lyzno1 97463661c1 fix: translations 2025-10-11 20:33:55 +08:00
lyzno1 239a11855a fix: prevent dropdown from closing when showing inline delete confirmation
Use pre-rendering strategy with CSS visibility control instead of conditional rendering to avoid race condition between React state update and PortalToFollowElem's click-outside detection.
2025-10-11 20:21:52 +08:00
lyzno1 0632557d91 feat: use inline delete confirm for comment reply deletion(second time) 2025-10-11 18:37:41 +08:00
lyzno1 44be7d4c51 Revert "feat: use inline delete confirm for comment reply deletion"
This reverts commit a077a3f609.
2025-10-11 18:24:15 +08:00
lyzno1 efb4a9d327 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-11 18:18:40 +08:00
lyzno1 a077a3f609 feat: use inline delete confirm for comment reply deletion 2025-10-11 18:06:31 +08:00
lyzno1 3ccec0aab0 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-11 17:21:05 +08:00
hjlarry 3006133f0e sync node title 2025-10-11 15:48:51 +08:00
lyzno1 79beb25530 feat: add tooltips and improve delete button styling in CommentThread
- Add compact tooltips to Delete, Resolve, Previous, and Next buttons
- Change delete button hover to red background and text
- Use existing i18n translations for tooltip content
2025-10-11 15:22:37 +08:00
lyzno1 b47b228164 fix: align dropdown menu styles with design specs in CommentThread
- Update background to blur variant with backdrop filter
- Change border radius from lg to xl (12px)
- Add rounded corners to menu items to prevent hover overflow
2025-10-11 15:10:57 +08:00
lyzno1 be91db14d9 fix: add hover effect to first message in CommentThread
Wrap the root comment message with the same hover container as replies to ensure consistent hover behavior across all messages.
2025-10-11 15:08:27 +08:00
lyzno1 120893209e fix: align CommentPreview styles with design specs
- Update border radius to 24px with 3px bottom-left corner
- Change border width to 0.5px
- Add backdrop blur effect with bg-blur variant
- Replace custom shadow with standard shadow-lg
- Maintain proper Tailwind utility class usage
2025-10-11 15:02:06 +08:00
lyzno1 f19630bcf5 Merge remote-tracking branch 'origin/main' into feat/collaboration 2025-10-11 14:43:20 +08:00
lyzno1 9d93fda471 refactor: separate loading states for comment operations
Separate loading states to distinguish between different operations:
- activeCommentDetailLoading: loading comment details, delete/resolve operations
- replySubmitting: sending new replies
- replyUpdating: editing existing replies

Changes:
- Add replySubmitting and replyUpdating states to comment store
- Restore full-screen loading overlay for comment detail loading
- Use inline spinner (RiLoader2Line) in send/save buttons for reply operations
- Update loading state usage in handleCommentReply and handleCommentReplyUpdate
- Pass separated loading states from workflow index to CommentThread component

Benefits:
- UI clarity: different loading states have appropriate visual feedback
- Better UX: users can still navigate while sending replies
- Clear separation of concerns: each operation has its own loading state
2025-10-11 14:34:35 +08:00
lyzno1 d986659add chore: replace Chinese/Japanese comments with English translations 2025-10-11 14:20:37 +08:00
lyzno1 00dab7ca5f feat: improve mention input loading state and prevent button flash on submit 2025-10-11 14:20:37 +08:00
lyzno1 a4add403fb Fix MentionInput layout and improve comment hover styling 2025-10-11 14:20:37 +08:00
lyzno1 e9cdc96c74 feat: prevent duplicate @ insertion in mention input with visual feedback 2025-10-11 14:20:37 +08:00
lyzno1 6af1fea232 fix: update mention button icon color for better visibility in light mode 2025-10-11 14:20:37 +08:00
lyzno1 45d5d9e44f fix: mention input cannot scroll 2025-10-11 14:20:36 +08:00
lyzno1 376a084aca refactor: use PortalToFollowElem for dropdown with scroll handling
- Replace inline dropdown with PortalToFollowElem to prevent container overflow
- Use z-[100] for dropdown to ensure proper stacking
- Remove redundant outside click handler (handled by PortalToFollowElem)
- Add scroll event listener to auto-close dropdown when scrolling
- Dropdown now renders via portal outside message container
2025-10-11 14:20:36 +08:00
lyzno1 d1f42d47fe fix: improve dropdown menu hover and positioning 2025-10-11 14:20:36 +08:00
lyzno1 64b8fd87ad fix: improve dropdown menu positioning and z-index 2025-10-11 14:20:36 +08:00
lyzno1 364be48248 feat: add smooth scroll to comment thread 2025-10-11 14:20:36 +08:00
hjlarry 2bce046278 fix node error default value not sync 2025-10-11 14:17:58 +08:00
hjlarry 1120d552b6 fix knowledge node add/delete dataset not sync 2025-10-11 14:09:37 +08:00
hjlarry 69cab0817f fix comment input hoverd by comment content 2025-10-11 10:41:28 +08:00
hjlarry c4d03bf378 change event type name of websocket 2025-10-11 09:07:02 +08:00
hjlarry 6c039be2ca fix jump to other page not disconnect websocket 2025-10-10 16:51:57 +08:00
hjlarry 832dabc8a4 only author can move the comment position 2025-10-10 15:58:01 +08:00
hjlarry 1da2028d9d keep the previous private property when import node data 2025-10-10 13:26:55 +08:00
hjlarry 7c3f6dcc8d use cloneDeep instead of json.parse 2025-10-10 10:34:00 +08:00
hjlarry 1472884eb5 sync the create/delete app in the list page 2025-10-10 10:18:23 +08:00
hjlarry ec22b1c706 fix user uploaded avatar display incorrect 2025-10-09 17:40:20 +08:00
hjlarry a1712df7c2 comment author avatar is the first avatar 2025-10-09 17:12:37 +08:00
hjlarry a40e11cb3e only can edit own replies 2025-10-09 17:02:39 +08:00
hjlarry 61c46bea40 fix missing i18n 2025-10-09 16:55:53 +08:00
hjlarry 1c5c28a82c fix switch to cursor mode comment input still exists 2025-10-09 16:36:20 +08:00
hjlarry 2310145937 comment reply auto scoll down to bottom 2025-10-09 15:50:23 +08:00
hjlarry 6a9c9cadd0 fix comment hover the variable panel 2025-10-09 15:44:56 +08:00
hjlarry 7774ff9944 fix version not display 2025-10-09 15:07:36 +08:00
hjlarry 33d4c95470 can update comment position 2025-10-05 10:17:04 +08:00
hjlarry 659cbc05a9 fix mention-input in the bottom of the browser 2025-10-04 21:24:27 +08:00
hjlarry 6ce65de2cd fix merged main issues 2025-10-04 21:11:59 +08:00
hjlarry 93b2eb3ff6 Merge remote-tracking branch 'myori/main' into p284 2025-10-04 15:28:29 +08:00
hjlarry bf71300635 improve comment cursor move 2025-10-04 14:36:10 +08:00
hjlarry 37ecd4a0bc fix @ input problem 2025-10-04 13:39:00 +08:00
hjlarry 827a1b181b fix comment icon position 2025-10-04 13:25:59 +08:00
hjlarry c4e7cb75cd cache the mentioned users 2025-10-04 11:22:02 +08:00
hjlarry 98e4bfcda8 click comment icon not switch to comment mode 2025-10-03 23:36:56 +08:00
hjlarry ee48ca7671 fix default comment icon 2025-09-30 15:23:43 +08:00
hjlarry 4ba6de1116 add leader session more check 2025-09-29 14:01:42 +08:00
hjlarry bfbe636555 fix docker file websocket mode 2025-09-29 13:35:10 +08:00
hjlarry 54ae43ef47 sync children node data 2025-09-26 14:07:34 +08:00
hjlarry 7a74b5ee3e fix add child node resize parent node size 2025-09-26 14:04:50 +08:00
hjlarry 0e9d43d605 http node data sync 2025-09-26 11:13:20 +08:00
hjlarry cc54363c27 sync the prompt editor 2025-09-26 10:48:00 +08:00
hjlarry 89affe3139 fix opened panel be affected 2025-09-26 09:20:33 +08:00
hjlarry 2c4977dbb1 fix bug 2025-09-25 16:56:06 +08:00
hjlarry e240175116 sync nodes 2025-09-25 16:31:46 +08:00
hjlarry 2398ed6fe8 fix update env api update time error 2025-09-25 16:28:33 +08:00
hjlarry a8420ac33c add fragment to prevent list missing key 2025-09-25 09:52:08 +08:00
hjlarry 8470be6411 improve delete comment i18n 2025-09-25 09:41:59 +08:00
hjlarry 3d6295c622 refactor delete comment and reply 2025-09-25 09:35:46 +08:00
ff2f7206f3 bump nextjs to 15.5 and turbopack for development mode (#24346)
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
2025-09-25 09:10:09 +08:00
hjlarry b937fc8978 app online user list 2025-09-24 17:03:33 +08:00
hjlarry 86a9a51952 add comment preview 2025-09-24 12:54:54 +08:00
hjlarry 4188c9a1dd fix dark theme 2025-09-24 10:08:33 +08:00
hjlarry 8c00f89e36 add icon to zoom2fit 2025-09-23 22:22:28 +08:00
hjlarry 9e8ac5c96b refactor cursor and add hide comment 2025-09-23 22:13:02 +08:00
hjlarry 05a67f4716 add display/hide collaborator cursors 2025-09-23 17:37:40 +08:00
hjlarry f49476a206 add show/hide minimap 2025-09-23 17:20:41 +08:00
hjlarry c1e9c56e25 fix style 2025-09-23 17:19:36 +08:00
hjlarry d5dd73cacf add i18n for comment 2025-09-23 16:19:04 +08:00
hjlarry 21f7a49b4e fix restore page crash 2025-09-23 15:44:57 +08:00
hjlarry 716ac04e13 add comment shortcut 2025-09-23 15:40:53 +08:00
hjlarry c28a32fc47 fix handleModeComment 2025-09-23 15:35:28 +08:00
hjlarry 31cba28e8a improve comment cursor icon 2025-09-23 15:28:22 +08:00
hjlarry 48cd7e6481 input comment should not cancel comment mode 2025-09-23 14:48:31 +08:00
hjlarry 47aba1c9f9 fix style 2025-09-23 14:41:34 +08:00
hjlarry 0f3f8bc0d9 make mention input can display name different color 2025-09-23 11:38:38 +08:00
hjlarry e0df12c212 fix mentioned names color 2025-09-23 11:24:17 +08:00
hjlarry eb448d9bb8 fix avatar background color 2025-09-23 11:09:02 +08:00
hjlarry 0ba77f13db fix avatar inset 2025-09-23 10:46:18 +08:00
hjlarry f0a2eb843c fix user cursor should not over the panel 2025-09-23 10:35:16 +08:00
hjlarry 5cf3d9e4d9 fix nginx config 2025-09-22 14:21:07 +08:00
hjlarry 41958f55cd fix CSP 2025-09-22 14:20:11 +08:00
hjlarry 600ad232e1 fix config 2025-09-22 14:20:11 +08:00
hjlarry 7a3825cfce fix docker config 2025-09-22 14:20:11 +08:00
hjlarry 9519653422 change default ws url 2025-09-22 14:20:11 +08:00
hjlarry efa2307c73 change default ws url 2025-09-22 14:20:11 +08:00
hjlarry 068fa3d0e3 fix CI 2025-09-22 14:20:11 +08:00
hjlarry 13d8dbd542 fix CI 2025-09-22 14:20:08 +08:00
hjlarry b442ba8b2b fix UserAvatarList background color 2025-09-19 12:07:07 +08:00
hjlarry 10e36d2355 add avatar on canvas node 2025-09-19 10:43:28 +08:00
hjlarry 13c53fedad add avatar display on node 2025-09-19 10:07:01 +08:00
hjlarry 4bda1bd884 open node panel not affect others 2025-09-18 17:42:02 +08:00
hjlarry 3abe7850d6 fix migration file 2025-09-18 16:30:40 +08:00
hjlarry b50284d864 fix merge problem 2025-09-18 15:45:53 +08:00
hjlarry 81c6e52401 Merge remote-tracking branch 'origin/p254' into p284 2025-09-18 15:14:55 +08:00
hjlarry 847d257366 Merge branch 'p254' into p284 2025-09-18 14:50:59 +08:00
hjlarry 687662cf1f comment sync 2025-09-18 13:27:27 +08:00
hjlarry 6432d98469 improve the icon display on canvas 2025-09-18 11:49:43 +08:00
hjlarry 088ccf8b8d add UserAvatarList component 2025-09-18 09:47:07 +08:00
hjlarry e8683bf957 fix comment cursor position 2025-09-18 09:17:45 +08:00
hjlarry 4653981b6b not display more icon when in edit mode 2025-09-17 20:45:54 +08:00
hjlarry e2547413d3 fix edit input mouse pos 2025-09-17 20:40:59 +08:00
hjlarry ea17f41b5b refactor reply code 2025-09-17 20:29:23 +08:00
hjlarry 29178d8adf can edit and delete a reply 2025-09-17 17:44:09 +08:00
hjlarry 7e86ead574 upgrade style 2025-09-17 16:41:10 +08:00
hjlarry 72debcb228 refactor mention input 2025-09-17 16:28:47 +08:00
hjlarry 72737dabc7 fix at can't click bug 2025-09-17 14:50:05 +08:00
hjlarry f6e5cb4381 improve comment detail 2025-09-17 14:34:36 +08:00
hjlarry ffad3b5fb1 comment detail window fix height 2025-09-17 13:45:56 +08:00
hjlarry cba9fc3020 add comment reply 2025-09-17 12:50:42 +08:00
hjlarry e776accaf3 add top operation buttons of comment detail 2025-09-17 10:45:15 +08:00
hjlarry 3eac26929a sync the comment panel and canvas 2025-09-17 09:13:31 +08:00
hjlarry 4d3adec738 click canvas icon display the active comment detail 2025-09-17 09:01:16 +08:00
hjlarry 89bed479e4 improve comment panel 2025-09-16 17:25:51 +08:00
hjlarry fdd673a3a9 improve comments panel 2025-09-16 13:39:31 +08:00
hjlarry 22f6d285c7 fix comment cursor in panel incorrect 2025-09-16 10:20:12 +08:00
hjlarry 10aa16b471 add workflow comment panel 2025-09-16 09:51:12 +08:00
hjlarry b3838581fd improve mention 2025-09-15 17:13:46 +08:00
hjlarry affbe7ccdb can mention user in the create comment 2025-09-15 16:42:31 +08:00
hjlarry dd8577f832 comments display on canvas 2025-09-15 14:16:06 +08:00
hjlarry d7f5da5df4 display comments avatar on the canvas 2025-09-15 11:41:06 +08:00
hjlarry 9fda130b3a fix click comment once more then esc not work 2025-09-15 11:11:07 +08:00
hjlarry 72cdbdba0f fix chat input style 2025-09-15 09:20:06 +08:00
hjlarry b92a153902 refactor code 2025-09-14 13:03:08 +08:00
hjlarry 9f2927979b fix comment cursor icon 2025-09-14 12:50:18 +08:00
hjlarry 75257232c3 add create comment frontend 2025-09-14 12:10:37 +08:00
hjlarry 1721314c62 add frontend comment service 2025-09-13 17:57:19 +08:00
hjlarry fc230bcc59 add force update workflow to support restore 2025-09-12 16:27:12 +08:00
hjlarry b4636ddf44 add leader restore workflow 2025-09-12 15:34:41 +08:00
hjlarry b1140301a4 sync import dsl 2025-09-12 14:46:40 +08:00
hjlarry 58cd785da6 use const for cursor move config 2025-09-11 09:36:22 +08:00
hjlarry 2035186cd2 click avatar to follow user cursor position 2025-09-11 09:26:05 +08:00
hjlarry 53ba6aadff cursor pos transform to canvas 2025-09-11 09:07:03 +08:00
hjlarry f091868b7c use new get avatar api 2025-09-10 15:15:43 +08:00
hjlarry 89bedae0d3 remove the test code for develop collaboration 2025-09-10 14:27:20 +08:00
hjlarry c8acc48976 ruff format 2025-09-10 14:25:37 +08:00
hjlarry 21fee59b22 use new features update api 2025-09-10 14:24:38 +08:00
hjlarry 957a8253f8 change user list to conversation var panel left 2025-09-10 09:26:38 +08:00
hjlarry d5fc3e7bed add new conversation vars update api 2025-09-10 09:24:22 +08:00
hjlarry ab438b42da use new env variables update api 2025-09-10 09:07:55 +08:00
hjlarry 3867fece4a mcp server update 2025-09-09 15:01:38 +08:00
hjlarry 2b908d4fbe add app state update 2025-09-09 14:24:37 +08:00
hjlarry 8ff062ec8b change user default color 2025-09-09 10:20:02 +08:00
hjlarry 294fc41aec add redo undo manager of CRDT 2025-09-09 09:58:55 +08:00
hjlarry 684f7df158 node data use crdt data 2025-09-08 14:46:28 +08:00
hjlarry c3287755e3 add request leader to sync graph 2025-09-08 09:00:20 +08:00
hjlarry 9f97f4d79e fix cursor style 2025-09-06 15:54:19 +08:00
hjlarry 34eb421649 add currentUserId is me 2025-09-06 12:27:54 +08:00
hjlarry 850b05573e add dropdown users list 2025-09-06 12:01:49 +08:00
hjlarry 6ec8bfdfee add mouse over avatar display username 2025-09-06 11:29:45 +08:00
hjlarry 81638c248e use one getUserColor func 2025-09-06 11:22:59 +08:00
hjlarry 2e11b1298e add online users avatar 2025-09-06 11:19:47 +08:00
hjlarry 20320f3a27 show online users on the canvas 2025-09-06 00:08:17 +08:00
hjlarry 4019c12d26 fix missing import 2025-09-05 22:20:07 +08:00
hjlarry cf72184ce4 each browser tab session a ws connected obj 2025-09-05 22:19:16 +08:00
hjlarry ca8d15bc64 add mention user list api 2025-08-31 13:42:59 +08:00
hjlarry a91c897fd3 improve code 2025-08-31 00:43:34 +08:00
hjlarry 816bdf0320 add delete comment and reply 2025-08-31 00:28:01 +08:00
hjlarry d4a6acbd99 add update reply 2025-08-30 23:49:27 +08:00
hjlarry e421db4005 add resolve comment 2025-08-30 22:37:01 +08:00
hjlarry 9067c2a9c1 add update comment 2025-08-22 17:48:14 +08:00
hjlarry 9f7321ca1a add create reply 2025-08-22 17:33:47 +08:00
hjlarry 5fa01132b9 add create and list comment api 2025-08-22 16:47:08 +08:00
hjlarry e082b6d599 add workflow comment models 2025-08-22 11:28:26 +08:00
hjlarry d44be2d835 add leader submit graph data 2025-08-21 17:53:39 +08:00
hjlarry 7dc8557033 add Leader election 2025-08-21 16:17:16 +08:00
hjlarry 72037a1865 improve cursors logic 2025-08-21 14:27:41 +08:00
hjlarry 2d1621c43d add leader but not review 2025-08-08 14:54:18 +08:00
hjlarry d1a5db3310 rm useCollaborativeCursors compoent 2025-08-07 18:03:12 +08:00
hjlarry ad8fd8fecc clone the node to avoid loro recursive 2025-08-07 17:45:38 +08:00
hjlarry be74b76079 refactor websocket init 2025-08-07 17:31:12 +08:00
hjlarry dd64af728f refactor the cursors component 2025-08-07 14:29:23 +08:00
hjlarry e43b46786d refactor all the frontend code 2025-08-07 10:58:53 +08:00
hjlarry 3f3b37b843 refactor to support mutli websocket connections 2025-08-06 17:05:39 +08:00
hjlarry 2ecf9f6ddf add features collaboration 2025-08-06 10:58:32 +08:00
hjlarry 48c069fe68 support env vars collaborate 2025-08-05 15:22:22 +08:00
hjlarry 9c5c597c85 support empty collaboration event data 2025-08-05 15:21:41 +08:00
hjlarry c2eec8545d collaborate conversation vars 2025-08-05 14:24:51 +08:00
hjlarry 2395d4be26 fix imported updates also broadcast to other clients 2025-08-05 10:21:22 +08:00
hjlarry 9455476705 handle edge delete 2025-08-04 14:17:59 +08:00
hjlarry 494e223706 some operations don't need to broadcast 2025-08-03 14:18:48 +08:00
hjlarry 348fd18230 refactor collaboration 2025-08-03 13:34:07 +08:00
hjlarry 7233b4de55 the initial data to collaboration store 2025-07-31 16:27:01 +08:00
hjlarry af6df05685 add setNodes and setEdges of collaboration store 2025-07-31 15:25:50 +08:00
hjlarry 965b65db6e use loro for crdt data 2025-07-31 14:02:53 +08:00
hjlarry 4cc01c8aa8 try a lot for yjs, but update data still not work... 2025-07-30 14:36:29 +08:00
hjlarry 41372168b6 refactor code 2025-07-23 10:04:16 +08:00
hjlarry f4438b0a08 support mouse display 2025-07-22 18:08:35 +08:00
hjlarry 897c842637 ruff format 2025-07-21 16:13:04 +08:00
hjlarry ee86ceb906 fix gunicorn gvent 2025-07-21 16:09:51 +08:00
hjlarry e298732499 refactor code 2025-07-21 16:07:22 +08:00
hjlarry 4081937e22 migrate to python-socketio 2025-07-21 14:57:28 +08:00
hjlarry f9aedb2118 add collaborate event 2025-07-21 11:10:23 +08:00
hjlarry 74b4719af8 support broadcast online users 2025-07-18 15:02:34 +08:00
hjlarry 2f35cc9188 add online users backend api and frontend submit cursor pos 2025-07-18 11:17:08 +08:00
hjlarry 2f966d8c38 fix websocket auth 2025-07-17 17:16:52 +08:00
hjlarry b0868d9136 fix websocket auth 2025-07-17 17:16:38 +08:00
hjlarry 37440e9416 ruff format 2025-07-17 15:37:13 +08:00
hjlarry 0d7d27ec0b establish websocket connection 2025-07-17 15:36:50 +08:00
248 changed files with 18325 additions and 1210 deletions
+3
View File
@@ -30,6 +30,9 @@ INTERNAL_FILES_URL=http://127.0.0.1:5001
# The time in seconds after the signature is rejected
FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
ENABLE_COLLABORATION_MODE=false
# Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60
+17 -3
View File
@@ -1,3 +1,4 @@
import os
import sys
@@ -8,10 +9,16 @@ def is_db_command():
# create app
celery = None
flask_app = None
socketio_app = None
if is_db_command():
from app_factory import create_migrations_app
app = create_migrations_app()
socketio_app = app
flask_app = app
else:
# It seems that JetBrains Python debugger does not work well with gevent,
# so we need to disable gevent in debug mode.
@@ -33,8 +40,15 @@ else:
from app_factory import create_app
app = create_app()
celery = app.extensions["celery"]
socketio_app, flask_app = create_app()
app = flask_app
celery = flask_app.extensions["celery"]
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", 5001))
server = pywsgi.WSGIServer((host, port), socketio_app, handler_class=WebSocketHandler)
server.serve_forever()
+10 -2
View File
@@ -31,14 +31,22 @@ def create_flask_app_with_configs() -> DifyApp:
return dify_app
def create_app() -> DifyApp:
def create_app() -> tuple[any, DifyApp]:
start_time = time.perf_counter()
app = create_flask_app_with_configs()
initialize_extensions(app)
import socketio
from extensions.ext_socketio import sio
sio.app = app
socketio_app = socketio.WSGIApp(sio, app)
end_time = time.perf_counter()
if dify_config.DEBUG:
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
return app
return socketio_app, app
def initialize_extensions(app: DifyApp):
+8
View File
@@ -1048,6 +1048,13 @@ class PositionConfig(BaseSettings):
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
class CollaborationConfig(BaseSettings):
ENABLE_COLLABORATION_MODE: bool = Field(
description="Whether to enable collaboration mode features across the workspace",
default=False,
)
class LoginConfig(BaseSettings):
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
description="whether to enable email code login",
@@ -1136,6 +1143,7 @@ class FeatureConfig(
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
CollaborationConfig,
LoginConfig,
AccountConfig,
SwaggerUIConfig,
+2
View File
@@ -58,11 +58,13 @@ from .app import (
mcp_server,
message,
model_config,
online_user,
ops_trace,
site,
statistic,
workflow,
workflow_app_log,
workflow_comment,
workflow_draft_variable,
workflow_run,
workflow_statistic,
+339
View File
@@ -0,0 +1,339 @@
import json
import time
from werkzeug.wrappers import Request as WerkzeugRequest
from extensions.ext_redis import redis_client
from extensions.ext_socketio import sio
from libs.passport import PassportService
from libs.token import extract_access_token
from services.account_service import AccountService
SESSION_STATE_TTL_SECONDS = 3600
WORKFLOW_ONLINE_USERS_PREFIX = "workflow_online_users:"
WORKFLOW_LEADER_PREFIX = "workflow_leader:"
WS_SID_MAP_PREFIX = "ws_sid_map:"
def _workflow_key(workflow_id: str) -> str:
return f"{WORKFLOW_ONLINE_USERS_PREFIX}{workflow_id}"
def _leader_key(workflow_id: str) -> str:
return f"{WORKFLOW_LEADER_PREFIX}{workflow_id}"
def _sid_key(sid: str) -> str:
return f"{WS_SID_MAP_PREFIX}{sid}"
def _refresh_session_state(workflow_id: str, sid: str) -> None:
"""
Refresh TTLs for workflow + session keys so healthy sessions do not linger forever after crashes.
"""
workflow_key = _workflow_key(workflow_id)
sid_key = _sid_key(sid)
if redis_client.exists(workflow_key):
redis_client.expire(workflow_key, SESSION_STATE_TTL_SECONDS)
if redis_client.exists(sid_key):
redis_client.expire(sid_key, SESSION_STATE_TTL_SECONDS)
@sio.on("connect")
def socket_connect(sid, environ, auth):
"""
WebSocket connect event, do authentication here.
"""
token = None
if auth and isinstance(auth, dict):
token = auth.get("token")
if not token:
try:
request_environ = WerkzeugRequest(environ)
token = extract_access_token(request_environ)
except Exception:
token = None
if not token:
return False
try:
decoded = PassportService().verify(token)
user_id = decoded.get("user_id")
if not user_id:
return False
with sio.app.app_context():
user = AccountService.load_logged_in_account(account_id=user_id)
if not user:
return False
sio.save_session(sid, {"user_id": user.id, "username": user.name, "avatar": user.avatar})
return True
except Exception:
return False
@sio.on("user_connect")
def handle_user_connect(sid, data):
"""
Handle user connect event. Each session (tab) is treated as an independent collaborator.
"""
workflow_id = data.get("workflow_id")
if not workflow_id:
return {"msg": "workflow_id is required"}, 400
session = sio.get_session(sid)
user_id = session.get("user_id")
if not user_id:
return {"msg": "unauthorized"}, 401
# Each session is stored independently with sid as key
session_info = {
"user_id": user_id,
"username": session.get("username", "Unknown"),
"avatar": session.get("avatar", None),
"sid": sid,
"connected_at": int(time.time()), # Add timestamp to differentiate tabs
}
workflow_key = _workflow_key(workflow_id)
# Store session info with sid as key
redis_client.hset(workflow_key, sid, json.dumps(session_info))
redis_client.set(
_sid_key(sid),
json.dumps({"workflow_id": workflow_id, "user_id": user_id}),
ex=SESSION_STATE_TTL_SECONDS,
)
_refresh_session_state(workflow_id, sid)
# Leader election: first session becomes the leader
leader_sid = get_or_set_leader(workflow_id, sid)
is_leader = leader_sid == sid
sio.enter_room(sid, workflow_id)
broadcast_online_users(workflow_id)
# Notify this session of their leader status
sio.emit("status", {"isLeader": is_leader}, room=sid)
return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader}
@sio.on("disconnect")
def handle_disconnect(sid):
"""
Handle session disconnect event. Remove the specific session from online users.
"""
mapping = redis_client.get(_sid_key(sid))
if mapping:
data = json.loads(mapping)
workflow_id = data["workflow_id"]
# Remove this specific session
redis_client.hdel(_workflow_key(workflow_id), sid)
redis_client.delete(_sid_key(sid))
# Handle leader re-election if the leader session disconnected
handle_leader_disconnect(workflow_id, sid)
broadcast_online_users(workflow_id)
def _clear_session_state(workflow_id: str, sid: str) -> None:
redis_client.hdel(_workflow_key(workflow_id), sid)
redis_client.delete(_sid_key(sid))
def _is_session_active(workflow_id: str, sid: str) -> bool:
if not sid:
return False
try:
if not sio.manager.is_connected(sid, "/"):
return False
except AttributeError:
return False
if not redis_client.hexists(_workflow_key(workflow_id), sid):
return False
if not redis_client.exists(_sid_key(sid)):
return False
return True
def get_or_set_leader(workflow_id: str, sid: str) -> str:
"""
Get current leader session or set this session as leader if no valid leader exists.
Returns the leader session id (sid).
"""
raw_leader = redis_client.get(_leader_key(workflow_id))
current_leader = raw_leader.decode("utf-8") if isinstance(raw_leader, bytes) else raw_leader
leader_replaced = False
if current_leader and not _is_session_active(workflow_id, current_leader):
_clear_session_state(workflow_id, current_leader)
redis_client.delete(_leader_key(workflow_id))
current_leader = None
leader_replaced = True
if not current_leader:
redis_client.set(_leader_key(workflow_id), sid, ex=SESSION_STATE_TTL_SECONDS) # Expire in 1 hour
if leader_replaced:
broadcast_leader_change(workflow_id, sid)
return sid
return current_leader
def handle_leader_disconnect(workflow_id, disconnected_sid):
"""
Handle leader re-election when a session disconnects.
If the disconnected session was the leader, elect a new leader from remaining sessions.
"""
current_leader = redis_client.get(_leader_key(workflow_id))
if current_leader:
current_leader = current_leader.decode("utf-8") if isinstance(current_leader, bytes) else current_leader
if current_leader == disconnected_sid:
# Leader session disconnected, elect a new leader
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
if sessions_json:
# Get the first remaining session as new leader
new_leader_sid = list(sessions_json.keys())[0]
if isinstance(new_leader_sid, bytes):
new_leader_sid = new_leader_sid.decode("utf-8")
redis_client.set(_leader_key(workflow_id), new_leader_sid, ex=SESSION_STATE_TTL_SECONDS)
# Notify all sessions about the new leader
broadcast_leader_change(workflow_id, new_leader_sid)
else:
# No sessions left, remove leader
redis_client.delete(_leader_key(workflow_id))
def broadcast_leader_change(workflow_id, new_leader_sid):
"""
Broadcast leader change to all sessions in the workflow.
"""
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
for sid, session_info_json in sessions_json.items():
try:
sid_str = sid.decode("utf-8") if isinstance(sid, bytes) else sid
is_leader = sid_str == new_leader_sid
# Emit to each session whether they are the new leader
sio.emit("status", {"isLeader": is_leader}, room=sid_str)
except Exception:
continue
def get_current_leader(workflow_id):
"""
Get the current leader for a workflow.
"""
leader = redis_client.get(_leader_key(workflow_id))
return leader.decode("utf-8") if leader and isinstance(leader, bytes) else leader
def broadcast_online_users(workflow_id):
"""
Broadcast online users to the workflow room.
Each session is shown as a separate user (even if same person has multiple tabs).
"""
sessions_json = redis_client.hgetall(_workflow_key(workflow_id))
users = []
for sid, session_info_json in sessions_json.items():
try:
session_info = json.loads(session_info_json)
# Each session appears as a separate "user" in the UI
users.append(
{
"user_id": session_info["user_id"],
"username": session_info["username"],
"avatar": session_info.get("avatar"),
"sid": session_info["sid"],
"connected_at": session_info.get("connected_at"),
}
)
except Exception:
continue
# Sort by connection time to maintain consistent order
users.sort(key=lambda x: x.get("connected_at") or 0)
# Get current leader session
leader_sid = get_current_leader(workflow_id)
sio.emit("online_users", {"workflow_id": workflow_id, "users": users, "leader": leader_sid}, room=workflow_id)
@sio.on("collaboration_event")
def handle_collaboration_event(sid, data):
"""
Handle general collaboration events, include:
1. mouse_move
2. vars_and_features_update
3. sync_request (ask leader to update graph)
4. app_state_update
5. mcp_server_update
6. workflow_update
7. comments_update
8. node_panel_presence
"""
mapping = redis_client.get(_sid_key(sid))
if not mapping:
return {"msg": "unauthorized"}, 401
mapping_data = json.loads(mapping)
workflow_id = mapping_data["workflow_id"]
user_id = mapping_data["user_id"]
_refresh_session_state(workflow_id, sid)
event_type = data.get("type")
event_data = data.get("data")
timestamp = data.get("timestamp", int(time.time()))
if not event_type:
return {"msg": "invalid event type"}, 400
sio.emit(
"collaboration_update",
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
room=workflow_id,
skip_sid=sid,
)
return {"msg": "event_broadcasted"}
@sio.on("graph_event")
def handle_graph_event(sid, data):
"""
Handle graph events - simple broadcast relay.
"""
mapping = redis_client.get(_sid_key(sid))
if not mapping:
return {"msg": "unauthorized"}, 401
mapping_data = json.loads(mapping)
workflow_id = mapping_data["workflow_id"]
_refresh_session_state(workflow_id, sid)
sio.emit("graph_update", data, room=workflow_id, skip_sid=sid)
return {"msg": "graph_update_broadcasted"}
+73
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from configs import dify_config
from controllers.console import api, console_ns
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.wraps import get_app_model
@@ -21,7 +22,9 @@ from core.file.models import File
from core.helper.trace_id_helper import get_external_trace_id
from core.workflow.graph_engine.manager import GraphEngineManager
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory
from fields.online_user_fields import online_user_list_fields
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper
@@ -122,6 +125,7 @@ class DraftWorkflowApi(Resource):
.add_argument("hash", type=str, required=False, location="json")
.add_argument("environment_variables", type=list, required=True, location="json")
.add_argument("conversation_variables", type=list, required=False, location="json")
.add_argument("force_upload", type=bool, required=False, default=False, location="json")
)
args = parser.parse_args()
elif "text/plain" in content_type:
@@ -139,6 +143,7 @@ class DraftWorkflowApi(Resource):
"hash": data.get("hash"),
"environment_variables": data.get("environment_variables"),
"conversation_variables": data.get("conversation_variables"),
"force_upload": data.get("force_upload", False),
}
except json.JSONDecodeError:
return {"message": "Invalid JSON data"}, 400
@@ -163,6 +168,7 @@ class DraftWorkflowApi(Resource):
account=current_user,
environment_variables=environment_variables,
conversation_variables=conversation_variables,
force_upload=args.get("force_upload", False),
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
@@ -734,6 +740,46 @@ class ConvertToWorkflowApi(Resource):
}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/config")
class WorkflowConfigApi(Resource):
"""Resource for workflow configuration."""
@api.doc("get_workflow_config")
@api.doc(description="Get workflow configuration")
@api.doc(params={"app_id": "Application ID"})
@api.response(200, "Workflow configuration retrieved successfully")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def get(self, app_model: App):
return {
"parallel_depth_limit": dify_config.WORKFLOW_PARALLEL_DEPTH_LIMIT,
}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
class WorkflowFeaturesApi(Resource):
"""Update draft workflow features."""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def post(self, app_model: App):
current_user, _ = current_account_with_tenant()
parser = reqparse.RequestParser().add_argument("features", type=dict, required=True, location="json")
args = parser.parse_args()
features = args.get("features")
workflow_service = WorkflowService()
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/workflows")
class PublishedAllWorkflowApi(Resource):
@api.doc("get_all_published_workflows")
@@ -915,3 +961,30 @@ class DraftWorkflowNodeLastRunApi(Resource):
if node_exec is None:
raise NotFound("last run not found")
return node_exec
@console_ns.route("/apps/workflows/online-users")
class WorkflowOnlineUsersApi(Resource):
@setup_required
@login_required
@account_initialization_required
@marshal_with(online_user_list_fields)
def get(self):
parser = reqparse.RequestParser().add_argument("workflow_ids", type=str, required=True, location="args")
args = parser.parse_args()
workflow_ids = [workflow_id.strip() for workflow_id in args["workflow_ids"].split(",")]
results = []
for workflow_id in workflow_ids:
users_json = redis_client.hgetall(f"workflow_online_users:{workflow_id}")
users = []
for _, user_info_json in users_json.items():
try:
users.append(json.loads(user_info_json))
except Exception:
continue
results.append({"workflow_id": workflow_id, "users": users})
return {"data": results}
@@ -0,0 +1,240 @@
import logging
from flask_restx import Resource, fields, marshal_with, reqparse
from controllers.console import api
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from fields.member_fields import account_with_role_fields
from fields.workflow_comment_fields import (
workflow_comment_basic_fields,
workflow_comment_create_fields,
workflow_comment_detail_fields,
workflow_comment_reply_create_fields,
workflow_comment_reply_update_fields,
workflow_comment_resolve_fields,
workflow_comment_update_fields,
)
from libs.login import current_user, login_required
from models import App
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__)
class WorkflowCommentListApi(Resource):
"""API for listing and creating workflow comments."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_basic_fields, envelope="data")
def get(self, app_model: App):
"""Get all comments for a workflow."""
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
return comments
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_create_fields)
def post(self, app_model: App):
"""Create a new workflow comment."""
parser = reqparse.RequestParser()
parser.add_argument("position_x", type=float, required=True, location="json")
parser.add_argument("position_y", type=float, required=True, location="json")
parser.add_argument("content", type=str, required=True, location="json")
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
args = parser.parse_args()
result = WorkflowCommentService.create_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
created_by=current_user.id,
content=args.content,
position_x=args.position_x,
position_y=args.position_y,
mentioned_user_ids=args.mentioned_user_ids,
)
return result, 201
class WorkflowCommentDetailApi(Resource):
"""API for managing individual workflow comments."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_detail_fields)
def get(self, app_model: App, comment_id: str):
"""Get a specific workflow comment."""
comment = WorkflowCommentService.get_comment(
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
)
return comment
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_update_fields)
def put(self, app_model: App, comment_id: str):
"""Update a workflow comment."""
parser = reqparse.RequestParser()
parser.add_argument("content", type=str, required=True, location="json")
parser.add_argument("position_x", type=float, required=False, location="json")
parser.add_argument("position_y", type=float, required=False, location="json")
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
args = parser.parse_args()
result = WorkflowCommentService.update_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
content=args.content,
position_x=args.position_x,
position_y=args.position_y,
mentioned_user_ids=args.mentioned_user_ids,
)
return result
@login_required
@setup_required
@account_initialization_required
@get_app_model
def delete(self, app_model: App, comment_id: str):
"""Delete a workflow comment."""
WorkflowCommentService.delete_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
)
return {"result": "success"}, 204
class WorkflowCommentResolveApi(Resource):
"""API for resolving and reopening workflow comments."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_resolve_fields)
def post(self, app_model: App, comment_id: str):
"""Resolve a workflow comment."""
comment = WorkflowCommentService.resolve_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
)
return comment
class WorkflowCommentReplyApi(Resource):
"""API for managing comment replies."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_reply_create_fields)
def post(self, app_model: App, comment_id: str):
"""Add a reply to a workflow comment."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
parser = reqparse.RequestParser()
parser.add_argument("content", type=str, required=True, location="json")
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
args = parser.parse_args()
result = WorkflowCommentService.create_reply(
comment_id=comment_id,
content=args.content,
created_by=current_user.id,
mentioned_user_ids=args.mentioned_user_ids,
)
return result, 201
class WorkflowCommentReplyDetailApi(Resource):
"""API for managing individual comment replies."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with(workflow_comment_reply_update_fields)
def put(self, app_model: App, comment_id: str, reply_id: str):
"""Update a comment reply."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
parser = reqparse.RequestParser()
parser.add_argument("content", type=str, required=True, location="json")
parser.add_argument("mentioned_user_ids", type=list, location="json", default=[])
args = parser.parse_args()
reply = WorkflowCommentService.update_reply(
reply_id=reply_id, user_id=current_user.id, content=args.content, mentioned_user_ids=args.mentioned_user_ids
)
return reply
@login_required
@setup_required
@account_initialization_required
@get_app_model
def delete(self, app_model: App, comment_id: str, reply_id: str):
"""Delete a comment reply."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
WorkflowCommentService.delete_reply(reply_id=reply_id, user_id=current_user.id)
return {"result": "success"}, 204
class WorkflowCommentMentionUsersApi(Resource):
"""API for getting mentionable users for workflow comments."""
@login_required
@setup_required
@account_initialization_required
@get_app_model
@marshal_with({"users": fields.List(fields.Nested(account_with_role_fields))})
def get(self, app_model: App):
"""Get all users in current tenant for mentions."""
members = TenantService.get_tenant_members(current_user.current_tenant)
return {"users": members}
# Register API routes
api.add_resource(WorkflowCommentListApi, "/apps/<uuid:app_id>/workflow/comments")
api.add_resource(WorkflowCommentDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
api.add_resource(WorkflowCommentResolveApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
api.add_resource(WorkflowCommentReplyApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
api.add_resource(
WorkflowCommentReplyDetailApi, "/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>"
)
api.add_resource(WorkflowCommentMentionUsersApi, "/apps/<uuid:app_id>/workflow/comments/mention-users")
@@ -19,8 +19,8 @@ from core.variables.segments import ArrayFileSegment, FileSegment, Segment
from core.variables.types import SegmentType
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db
from factories import variable_factory
from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type
from libs.login import current_user, login_required
from models import Account, App, AppMode
from models.workflow import WorkflowDraftVariable
@@ -355,7 +355,7 @@ class VariableApi(Resource):
if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
new_value = build_segment_with_type(variable.value_type, raw_value)
new_value = variable_factory.build_segment_with_type(variable.value_type, raw_value)
draft_var_srv.update_variable(variable, name=new_name, value=new_value)
db.session.commit()
return variable
@@ -448,8 +448,35 @@ class ConversationVariableCollectionApi(Resource):
db.session.commit()
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT)
def post(self, app_model: App):
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("conversation_variables", type=list, required=True, location="json")
args = parser.parse_args()
workflow_service = WorkflowService()
conversation_variables_list = args.get("conversation_variables") or []
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
]
workflow_service.update_draft_workflow_conversation_variables(
app_model=app_model,
account=current_user,
conversation_variables=conversation_variables,
)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
class SystemVariableCollectionApi(Resource):
@api.doc("get_system_variables")
@api.doc(description="Get system variables for workflow")
@@ -499,3 +526,44 @@ class EnvironmentVariableCollectionApi(Resource):
)
return {"items": env_vars_list}
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def post(self, app_model: App):
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("environment_variables", type=list, required=True, location="json")
args = parser.parse_args()
workflow_service = WorkflowService()
environment_variables_list = args.get("environment_variables") or []
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
workflow_service.update_draft_workflow_environment_variables(
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
)
return {"result": "success"}
api.add_resource(
WorkflowVariableCollectionApi,
"/apps/<uuid:app_id>/workflows/draft/variables",
)
api.add_resource(NodeVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
api.add_resource(VariableApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>")
api.add_resource(VariableResetApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>/reset")
api.add_resource(ConversationVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/conversation-variables")
api.add_resource(SystemVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/system-variables")
api.add_resource(EnvironmentVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/environment-variables")
@@ -32,6 +32,7 @@ from controllers.console.wraps import (
only_edition_cloud,
setup_required,
)
from core.file import helpers as file_helpers
from extensions.ext_database import db
from fields.member_fields import account_fields
from libs.datetime_utils import naive_utc_now
@@ -128,6 +129,17 @@ class AccountNameApi(Resource):
@console_ns.route("/account/avatar")
class AccountAvatarApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
parser = reqparse.RequestParser()
parser.add_argument("avatar", type=str, required=True, location="args")
args = parser.parse_args()
avatar_url = file_helpers.get_signed_file_url(args["avatar"])
return {"avatar_url": avatar_url}
@setup_required
@login_required
@account_initialization_required
@@ -289,7 +289,8 @@ class OracleVector(BaseVector):
words = pseg.cut(query)
current_entity = ""
for word, pos in words:
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}: # nr: 人名,ns: 地名,nt: 机构名
# nr: person name, ns: place name, nt: organization name
if pos in {"nr", "Ng", "eng", "nz", "n", "ORG", "v"}:
current_entity += word
else:
if current_entity:
@@ -213,7 +213,7 @@ class VastbaseVector(BaseVector):
with self._get_cursor() as cur:
cur.execute(SQL_CREATE_TABLE.format(table_name=self.table_name, dimension=dimension))
# Vastbase 支持的向量维度取值范围为 [1,16000]
# Vastbase supports vector dimensions in range [1, 16000]
if dimension <= 16000:
cur.execute(SQL_CREATE_INDEX.format(table_name=self.table_name))
redis_client.set(collection_exist_cache_key, 1, ex=3600)
@@ -747,7 +747,7 @@ class ParameterExtractorNode(Node):
if model_mode == ModelMode.CHAT:
system_prompt_messages = ChatModelMessage(
role=PromptMessageRole.SYSTEM,
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str).replace("{{instructions}}", instruction),
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str, instructions=instruction),
)
user_prompt_message = ChatModelMessage(role=PromptMessageRole.USER, text=input_text)
return [system_prompt_messages, user_prompt_message]
@@ -135,7 +135,7 @@ Here are the chat histories between human and assistant, inside <histories></his
### Instructions:
Some extra information are provided below, you should always follow the instructions as possible as you can.
<instructions>
{{instructions}}
{instructions}
</instructions>
"""
+5 -3
View File
@@ -38,14 +38,16 @@ elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
else
if [[ "${DEBUG}" == "true" ]]; then
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
export HOST=${DIFY_BIND_ADDRESS:-0.0.0.0}
export PORT=${DIFY_PORT:-5001}
exec python -m app
else
exec gunicorn \
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-gevent} \
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
--worker-connections ${SERVER_WORKER_CONNECTIONS:-10} \
--timeout ${GUNICORN_TIMEOUT:-200} \
app:app
app:socketio_app
fi
fi
+10 -5
View File
@@ -1,7 +1,12 @@
from configs import dify_config
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT
from dify_app import DifyApp
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
def init_app(app: DifyApp):
# register blueprint routers
@@ -17,7 +22,7 @@ def init_app(app: DifyApp):
CORS(
service_api_bp,
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE],
allow_headers=list(SERVICE_API_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
)
app.register_blueprint(service_api_bp)
@@ -26,7 +31,7 @@ def init_app(app: DifyApp):
web_bp,
resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}},
supports_credentials=True,
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN],
allow_headers=list(AUTHENTICATED_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"],
)
@@ -36,7 +41,7 @@ def init_app(app: DifyApp):
console_app_bp,
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
supports_credentials=True,
allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN],
allow_headers=list(AUTHENTICATED_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"],
)
@@ -44,7 +49,7 @@ def init_app(app: DifyApp):
CORS(
files_bp,
allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN],
allow_headers=list(FILES_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
)
app.register_blueprint(files_bp)
+3
View File
@@ -0,0 +1,3 @@
import socketio
sio = socketio.Server(async_mode="gevent", cors_allowed_origins="*")
+17
View File
@@ -0,0 +1,17 @@
from flask_restx import fields
online_user_partial_fields = {
"user_id": fields.String,
"username": fields.String,
"avatar": fields.String,
"sid": fields.String,
}
workflow_online_users_fields = {
"workflow_id": fields.String,
"users": fields.List(fields.Nested(online_user_partial_fields)),
}
online_user_list_fields = {
"data": fields.List(fields.Nested(workflow_online_users_fields)),
}
+96
View File
@@ -0,0 +1,96 @@
from flask_restx import fields
from libs.helper import AvatarUrlField, TimestampField
# basic account fields for comments
account_fields = {
"id": fields.String,
"name": fields.String,
"email": fields.String,
"avatar_url": AvatarUrlField,
}
# Comment mention fields
workflow_comment_mention_fields = {
"mentioned_user_id": fields.String,
"mentioned_user_account": fields.Nested(account_fields, allow_null=True),
"reply_id": fields.String,
}
# Comment reply fields
workflow_comment_reply_fields = {
"id": fields.String,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
}
# Basic comment fields (for list views)
workflow_comment_basic_fields = {
"id": fields.String,
"position_x": fields.Float,
"position_y": fields.Float,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
"updated_at": TimestampField,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
"reply_count": fields.Integer,
"mention_count": fields.Integer,
"participants": fields.List(fields.Nested(account_fields)),
}
# Detailed comment fields (for single comment view)
workflow_comment_detail_fields = {
"id": fields.String,
"position_x": fields.Float,
"position_y": fields.Float,
"content": fields.String,
"created_by": fields.String,
"created_by_account": fields.Nested(account_fields, allow_null=True),
"created_at": TimestampField,
"updated_at": TimestampField,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
"resolved_by_account": fields.Nested(account_fields, allow_null=True),
"replies": fields.List(fields.Nested(workflow_comment_reply_fields)),
"mentions": fields.List(fields.Nested(workflow_comment_mention_fields)),
}
# Comment creation response fields (simplified)
workflow_comment_create_fields = {
"id": fields.String,
"created_at": TimestampField,
}
# Comment update response fields (simplified)
workflow_comment_update_fields = {
"id": fields.String,
"updated_at": TimestampField,
}
# Comment resolve response fields
workflow_comment_resolve_fields = {
"id": fields.String,
"resolved": fields.Boolean,
"resolved_at": TimestampField,
"resolved_by": fields.String,
}
# Reply creation response fields (simplified)
workflow_comment_reply_create_fields = {
"id": fields.String,
"created_at": TimestampField,
}
# Reply update response fields
workflow_comment_reply_update_fields = {
"id": fields.String,
"updated_at": TimestampField,
}
@@ -0,0 +1,90 @@
"""Add workflow comments table
Revision ID: 227822d22895
Revises: 68519ad5cd18
Create Date: 2025-08-22 17:26:15.255980
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '227822d22895'
down_revision = '68519ad5cd18'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workflow_comments',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('position_x', sa.Float(), nullable=False),
sa.Column('position_y', sa.Float(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('resolved', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.Column('resolved_by', models.types.StringUUID(), nullable=True),
sa.PrimaryKeyConstraint('id', name='workflow_comments_pkey')
)
with op.batch_alter_table('workflow_comments', schema=None) as batch_op:
batch_op.create_index('workflow_comments_app_idx', ['tenant_id', 'app_id'], unique=False)
batch_op.create_index('workflow_comments_created_at_idx', ['created_at'], unique=False)
op.create_table('workflow_comment_replies',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('comment_id', models.types.StringUUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_by', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_replies_comment_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='workflow_comment_replies_pkey')
)
with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op:
batch_op.create_index('comment_replies_comment_idx', ['comment_id'], unique=False)
batch_op.create_index('comment_replies_created_at_idx', ['created_at'], unique=False)
op.create_table('workflow_comment_mentions',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('comment_id', models.types.StringUUID(), nullable=False),
sa.Column('reply_id', models.types.StringUUID(), nullable=True),
sa.Column('mentioned_user_id', models.types.StringUUID(), nullable=False),
sa.ForeignKeyConstraint(['comment_id'], ['workflow_comments.id'], name=op.f('workflow_comment_mentions_comment_id_fkey'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['reply_id'], ['workflow_comment_replies.id'], name=op.f('workflow_comment_mentions_reply_id_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='workflow_comment_mentions_pkey')
)
with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op:
batch_op.create_index('comment_mentions_comment_idx', ['comment_id'], unique=False)
batch_op.create_index('comment_mentions_reply_idx', ['reply_id'], unique=False)
batch_op.create_index('comment_mentions_user_idx', ['mentioned_user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('workflow_comment_mentions', schema=None) as batch_op:
batch_op.drop_index('comment_mentions_user_idx')
batch_op.drop_index('comment_mentions_reply_idx')
batch_op.drop_index('comment_mentions_comment_idx')
op.drop_table('workflow_comment_mentions')
with op.batch_alter_table('workflow_comment_replies', schema=None) as batch_op:
batch_op.drop_index('comment_replies_created_at_idx')
batch_op.drop_index('comment_replies_comment_idx')
op.drop_table('workflow_comment_replies')
with op.batch_alter_table('workflow_comments', schema=None) as batch_op:
batch_op.drop_index('workflow_comments_created_at_idx')
batch_op.drop_index('workflow_comments_app_idx')
op.drop_table('workflow_comments')
# ### end Alembic commands ###
+8
View File
@@ -9,6 +9,11 @@ from .account import (
TenantStatus,
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
WorkflowCommentMention,
WorkflowCommentReply,
)
from .dataset import (
AppDatasetJoin,
Dataset,
@@ -174,6 +179,9 @@ __all__ = [
"Workflow",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowComment",
"WorkflowCommentMention",
"WorkflowCommentReply",
"WorkflowNodeExecutionModel",
"WorkflowNodeExecutionOffload",
"WorkflowNodeExecutionTriggeredFrom",
+189
View File
@@ -0,0 +1,189 @@
"""Workflow comment models."""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .account import Account
from .base import Base
from .engine import db
from .types import StringUUID
if TYPE_CHECKING:
pass
class WorkflowComment(Base):
"""Workflow comment model for canvas commenting functionality.
Comments are associated with apps rather than specific workflow versions,
since an app has only one draft workflow at a time and comments should persist
across workflow version changes.
Attributes:
id: Comment ID
tenant_id: Workspace ID
app_id: App ID (primary association, comments belong to apps)
position_x: X coordinate on canvas
position_y: Y coordinate on canvas
content: Comment content
created_by: Creator account ID
created_at: Creation time
updated_at: Last update time
resolved: Whether comment is resolved
resolved_at: Resolution time
resolved_by: Resolver account ID
"""
__tablename__ = "workflow_comments"
__table_args__ = (
db.PrimaryKeyConstraint("id", name="workflow_comments_pkey"),
Index("workflow_comments_app_idx", "tenant_id", "app_id"),
Index("workflow_comments_created_at_idx", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
position_x: Mapped[float] = mapped_column(db.Float)
position_y: Mapped[float] = mapped_column(db.Float)
content: Mapped[str] = mapped_column(db.Text, nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
resolved: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false"))
resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime)
resolved_by: Mapped[Optional[str]] = mapped_column(StringUUID)
# Relationships
replies: Mapped[list["WorkflowCommentReply"]] = relationship(
"WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan"
)
mentions: Mapped[list["WorkflowCommentMention"]] = relationship(
"WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan"
)
@property
def created_by_account(self):
"""Get creator account."""
return db.session.get(Account, self.created_by)
@property
def resolved_by_account(self):
"""Get resolver account."""
if self.resolved_by:
return db.session.get(Account, self.resolved_by)
return None
@property
def reply_count(self):
"""Get reply count."""
return len(self.replies)
@property
def mention_count(self):
"""Get mention count."""
return len(self.mentions)
@property
def participants(self):
"""Get all participants (creator + repliers + mentioned users)."""
participant_ids = set()
# Add comment creator
participant_ids.add(self.created_by)
# Add reply creators
participant_ids.update(reply.created_by for reply in self.replies)
# Add mentioned users
participant_ids.update(mention.mentioned_user_id for mention in self.mentions)
# Get account objects
participants = []
for user_id in participant_ids:
account = db.session.get(Account, user_id)
if account:
participants.append(account)
return participants
class WorkflowCommentReply(Base):
"""Workflow comment reply model.
Attributes:
id: Reply ID
comment_id: Parent comment ID
content: Reply content
created_by: Creator account ID
created_at: Creation time
"""
__tablename__ = "workflow_comment_replies"
__table_args__ = (
db.PrimaryKeyConstraint("id", name="workflow_comment_replies_pkey"),
Index("comment_replies_comment_idx", "comment_id"),
Index("comment_replies_created_at_idx", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
comment_id: Mapped[str] = mapped_column(
StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
)
content: Mapped[str] = mapped_column(db.Text, nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
db.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
# Relationships
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies")
@property
def created_by_account(self):
"""Get creator account."""
return db.session.get(Account, self.created_by)
class WorkflowCommentMention(Base):
"""Workflow comment mention model.
Mentions are only for internal accounts since end users
cannot access workflow canvas and commenting features.
Attributes:
id: Mention ID
comment_id: Parent comment ID
mentioned_user_id: Mentioned account ID
"""
__tablename__ = "workflow_comment_mentions"
__table_args__ = (
db.PrimaryKeyConstraint("id", name="workflow_comment_mentions_pkey"),
Index("comment_mentions_comment_idx", "comment_id"),
Index("comment_mentions_reply_idx", "reply_id"),
Index("comment_mentions_user_idx", "mentioned_user_id"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
comment_id: Mapped[str] = mapped_column(
StringUUID, db.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
)
reply_id: Mapped[Optional[str]] = mapped_column(
StringUUID, db.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True
)
mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# Relationships
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions")
reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply")
@property
def mentioned_user_account(self):
"""Get mentioned account."""
return db.session.get(Account, self.mentioned_user_id)
+1 -1
View File
@@ -335,7 +335,7 @@ class Workflow(Base):
:return: hash
"""
entity = {"graph": self.graph_dict, "features": self.features_dict}
entity = {"graph": self.graph_dict}
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
+2
View File
@@ -20,6 +20,7 @@ dependencies = [
"flask-orjson~=2.0.0",
"flask-sqlalchemy~=3.1.1",
"gevent~=25.9.1",
"gevent-websocket~=0.10.1",
"gmpy2~=2.2.1",
"google-api-core==2.18.0",
"google-api-python-client==2.90.0",
@@ -68,6 +69,7 @@ dependencies = [
"pypdfium2==4.30.0",
"python-docx~=1.1.0",
"python-dotenv==1.0.1",
"python-socketio~=5.13.0",
"pyyaml~=6.0.1",
"readabilipy~=0.3.0",
"redis[hiredis]~=6.1.0",
+2
View File
@@ -151,6 +151,7 @@ class SystemFeatureModel(BaseModel):
enable_email_code_login: bool = False
enable_email_password_login: bool = True
enable_social_oauth_login: bool = False
enable_collaboration_mode: bool = False
is_allow_register: bool = False
is_allow_create_workspace: bool = False
is_email_setup: bool = False
@@ -211,6 +212,7 @@ class FeatureService:
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
+311
View File
@@ -0,0 +1,311 @@
import logging
from typing import Optional
from sqlalchemy import desc, select
from sqlalchemy.orm import Session, selectinload
from werkzeug.exceptions import Forbidden, NotFound
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import uuid_value
from models import WorkflowComment, WorkflowCommentMention, WorkflowCommentReply
logger = logging.getLogger(__name__)
class WorkflowCommentService:
"""Service for managing workflow comments."""
@staticmethod
def _validate_content(content: str) -> None:
if len(content.strip()) == 0:
raise ValueError("Comment content cannot be empty")
if len(content) > 1000:
raise ValueError("Comment content cannot exceed 1000 characters")
@staticmethod
def get_comments(tenant_id: str, app_id: str) -> list[WorkflowComment]:
"""Get all comments for a workflow."""
with Session(db.engine) as session:
# Get all comments with eager loading
stmt = (
select(WorkflowComment)
.options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions))
.where(WorkflowComment.tenant_id == tenant_id, WorkflowComment.app_id == app_id)
.order_by(desc(WorkflowComment.created_at))
)
comments = session.scalars(stmt).all()
return comments
@staticmethod
def get_comment(tenant_id: str, app_id: str, comment_id: str, session: Session = None) -> WorkflowComment:
"""Get a specific comment."""
def _get_comment(session: Session) -> WorkflowComment:
stmt = (
select(WorkflowComment)
.options(selectinload(WorkflowComment.replies), selectinload(WorkflowComment.mentions))
.where(
WorkflowComment.id == comment_id,
WorkflowComment.tenant_id == tenant_id,
WorkflowComment.app_id == app_id,
)
)
comment = session.scalar(stmt)
if not comment:
raise NotFound("Comment not found")
return comment
if session is not None:
return _get_comment(session)
else:
with Session(db.engine, expire_on_commit=False) as session:
return _get_comment(session)
@staticmethod
def create_comment(
tenant_id: str,
app_id: str,
created_by: str,
content: str,
position_x: float,
position_y: float,
mentioned_user_ids: Optional[list[str]] = None,
) -> WorkflowComment:
"""Create a new workflow comment."""
WorkflowCommentService._validate_content(content)
with Session(db.engine) as session:
comment = WorkflowComment(
tenant_id=tenant_id,
app_id=app_id,
position_x=position_x,
position_y=position_y,
content=content,
created_by=created_by,
)
session.add(comment)
session.flush() # Get the comment ID for mentions
# Create mentions if specified
mentioned_user_ids = mentioned_user_ids or []
for user_id in mentioned_user_ids:
if isinstance(user_id, str) and uuid_value(user_id):
mention = WorkflowCommentMention(
comment_id=comment.id,
reply_id=None, # This is a comment mention, not reply mention
mentioned_user_id=user_id,
)
session.add(mention)
session.commit()
# Return only what we need - id and created_at
return {"id": comment.id, "created_at": comment.created_at}
@staticmethod
def update_comment(
tenant_id: str,
app_id: str,
comment_id: str,
user_id: str,
content: str,
position_x: Optional[float] = None,
position_y: Optional[float] = None,
mentioned_user_ids: Optional[list[str]] = None,
) -> dict:
"""Update a workflow comment."""
WorkflowCommentService._validate_content(content)
with Session(db.engine, expire_on_commit=False) as session:
# Get comment with validation
stmt = select(WorkflowComment).where(
WorkflowComment.id == comment_id,
WorkflowComment.tenant_id == tenant_id,
WorkflowComment.app_id == app_id,
)
comment = session.scalar(stmt)
if not comment:
raise NotFound("Comment not found")
# Only the creator can update the comment
if comment.created_by != user_id:
raise Forbidden("Only the comment creator can update it")
# Update comment fields
comment.content = content
if position_x is not None:
comment.position_x = position_x
if position_y is not None:
comment.position_y = position_y
# Update mentions - first remove existing mentions for this comment only (not replies)
existing_mentions = session.scalars(
select(WorkflowCommentMention).where(
WorkflowCommentMention.comment_id == comment.id,
WorkflowCommentMention.reply_id.is_(None), # Only comment mentions, not reply mentions
)
).all()
for mention in existing_mentions:
session.delete(mention)
# Add new mentions
mentioned_user_ids = mentioned_user_ids or []
for user_id_str in mentioned_user_ids:
if isinstance(user_id_str, str) and uuid_value(user_id_str):
mention = WorkflowCommentMention(
comment_id=comment.id,
reply_id=None, # This is a comment mention
mentioned_user_id=user_id_str,
)
session.add(mention)
session.commit()
return {"id": comment.id, "updated_at": comment.updated_at}
@staticmethod
def delete_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> None:
"""Delete a workflow comment."""
with Session(db.engine, expire_on_commit=False) as session:
comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session)
# Only the creator can delete the comment
if comment.created_by != user_id:
raise Forbidden("Only the comment creator can delete it")
# Delete associated mentions (both comment and reply mentions)
mentions = session.scalars(
select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment_id)
).all()
for mention in mentions:
session.delete(mention)
# Delete associated replies
replies = session.scalars(
select(WorkflowCommentReply).where(WorkflowCommentReply.comment_id == comment_id)
).all()
for reply in replies:
session.delete(reply)
session.delete(comment)
session.commit()
@staticmethod
def resolve_comment(tenant_id: str, app_id: str, comment_id: str, user_id: str) -> WorkflowComment:
"""Resolve a workflow comment."""
with Session(db.engine, expire_on_commit=False) as session:
comment = WorkflowCommentService.get_comment(tenant_id, app_id, comment_id, session)
if comment.resolved:
return comment
comment.resolved = True
comment.resolved_at = naive_utc_now()
comment.resolved_by = user_id
session.commit()
return comment
@staticmethod
def create_reply(
comment_id: str, content: str, created_by: str, mentioned_user_ids: Optional[list[str]] = None
) -> dict:
"""Add a reply to a workflow comment."""
WorkflowCommentService._validate_content(content)
with Session(db.engine, expire_on_commit=False) as session:
# Check if comment exists
comment = session.get(WorkflowComment, comment_id)
if not comment:
raise NotFound("Comment not found")
reply = WorkflowCommentReply(comment_id=comment_id, content=content, created_by=created_by)
session.add(reply)
session.flush() # Get the reply ID for mentions
# Create mentions if specified
mentioned_user_ids = mentioned_user_ids or []
for user_id in mentioned_user_ids:
if isinstance(user_id, str) and uuid_value(user_id):
# Create mention linking to specific reply
mention = WorkflowCommentMention(
comment_id=comment_id, reply_id=reply.id, mentioned_user_id=user_id
)
session.add(mention)
session.commit()
return {"id": reply.id, "created_at": reply.created_at}
@staticmethod
def update_reply(
reply_id: str, user_id: str, content: str, mentioned_user_ids: Optional[list[str]] = None
) -> WorkflowCommentReply:
"""Update a comment reply."""
WorkflowCommentService._validate_content(content)
with Session(db.engine, expire_on_commit=False) as session:
reply = session.get(WorkflowCommentReply, reply_id)
if not reply:
raise NotFound("Reply not found")
# Only the creator can update the reply
if reply.created_by != user_id:
raise Forbidden("Only the reply creator can update it")
reply.content = content
# Update mentions - first remove existing mentions for this reply
existing_mentions = session.scalars(
select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id)
).all()
for mention in existing_mentions:
session.delete(mention)
# Add mentions
mentioned_user_ids = mentioned_user_ids or []
for user_id_str in mentioned_user_ids:
if isinstance(user_id_str, str) and uuid_value(user_id_str):
mention = WorkflowCommentMention(
comment_id=reply.comment_id, reply_id=reply.id, mentioned_user_id=user_id_str
)
session.add(mention)
session.commit()
session.refresh(reply) # Refresh to get updated timestamp
return {"id": reply.id, "updated_at": reply.updated_at}
@staticmethod
def delete_reply(reply_id: str, user_id: str) -> None:
"""Delete a comment reply."""
with Session(db.engine, expire_on_commit=False) as session:
reply = session.get(WorkflowCommentReply, reply_id)
if not reply:
raise NotFound("Reply not found")
# Only the creator can delete the reply
if reply.created_by != user_id:
raise Forbidden("Only the reply creator can delete it")
# Delete associated mentions first
mentions = session.scalars(
select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply_id)
).all()
for mention in mentions:
session.delete(mention)
session.delete(reply)
session.commit()
@staticmethod
def validate_comment_access(comment_id: str, tenant_id: str, app_id: str) -> WorkflowComment:
"""Validate that a comment belongs to the specified tenant and app."""
return WorkflowCommentService.get_comment(tenant_id, app_id, comment_id)
+75 -1
View File
@@ -197,15 +197,17 @@ class WorkflowService:
account: Account,
environment_variables: Sequence[Variable],
conversation_variables: Sequence[Variable],
force_upload: bool = False,
) -> Workflow:
"""
Sync draft workflow
:param force_upload: Skip hash validation when True (for restore operations)
:raises WorkflowHashNotEqualError
"""
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model)
if workflow and workflow.unique_hash != unique_hash:
if workflow and workflow.unique_hash != unique_hash and not force_upload:
raise WorkflowHashNotEqualError()
# validate features structure
@@ -243,6 +245,78 @@ class WorkflowService:
# return draft workflow
return workflow
def update_draft_workflow_environment_variables(
self,
*,
app_model: App,
environment_variables: Sequence[Variable],
account: Account,
):
"""
Update draft workflow environment variables
"""
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model)
if not workflow:
raise ValueError("No draft workflow found.")
workflow.environment_variables = environment_variables
workflow.updated_by = account.id
workflow.updated_at = naive_utc_now()
# commit db session changes
db.session.commit()
def update_draft_workflow_conversation_variables(
self,
*,
app_model: App,
conversation_variables: Sequence[Variable],
account: Account,
):
"""
Update draft workflow conversation variables
"""
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model)
if not workflow:
raise ValueError("No draft workflow found.")
workflow.conversation_variables = conversation_variables
workflow.updated_by = account.id
workflow.updated_at = naive_utc_now()
# commit db session changes
db.session.commit()
def update_draft_workflow_features(
self,
*,
app_model: App,
features: dict,
account: Account,
):
"""
Update draft workflow features
"""
# fetch draft workflow by app_model
workflow = self.get_draft_workflow(app_model=app_model)
if not workflow:
raise ValueError("No draft workflow found.")
# validate features structure
self.validate_features_structure(app_model=app_model, features=features)
workflow.features = json.dumps(features)
workflow.updated_by = account.id
workflow.updated_at = naive_utc_now()
# commit db session changes
db.session.commit()
def publish_workflow(
self,
*,
@@ -267,6 +267,7 @@ class TestFeatureService:
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
mock_config.ENABLE_COLLABORATION_MODE = True
mock_config.ALLOW_REGISTER = False
mock_config.ALLOW_CREATE_WORKSPACE = False
mock_config.MAIL_TYPE = "smtp"
@@ -291,6 +292,7 @@ class TestFeatureService:
# Verify authentication settings
assert result.enable_email_code_login is True
assert result.enable_email_password_login is False
assert result.enable_collaboration_mode is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
@@ -340,6 +342,7 @@ class TestFeatureService:
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
mock_config.ENABLE_COLLABORATION_MODE = False
mock_config.ALLOW_REGISTER = True
mock_config.ALLOW_CREATE_WORKSPACE = True
mock_config.MAIL_TYPE = "smtp"
@@ -361,6 +364,7 @@ class TestFeatureService:
assert result.enable_email_code_login is True
assert result.enable_email_password_login is True
assert result.enable_social_oauth_login is False
assert result.enable_collaboration_mode is False
assert result.is_allow_register is True
assert result.is_allow_create_workspace is True
assert result.is_email_setup is True
Generated
+74
View File
@@ -553,6 +553,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/f4/a69c20ee4f660081a7dedb1ac57f29be9378e04edfcb90c526b923d4bebc/beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a", size = 142979, upload-time = "2023-04-07T15:02:50.77Z" },
]
[[package]]
name = "bidict"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
]
[[package]]
name = "billiard"
version = "4.2.2"
@@ -1312,6 +1321,7 @@ dependencies = [
{ name = "flask-restx" },
{ name = "flask-sqlalchemy" },
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gmpy2" },
{ name = "google-api-core" },
{ name = "google-api-python-client" },
@@ -1359,6 +1369,7 @@ dependencies = [
{ name = "pypdfium2" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-socketio" },
{ name = "pyyaml" },
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
@@ -1503,6 +1514,7 @@ requires-dist = [
{ name = "flask-restx", specifier = "~=1.3.0" },
{ name = "flask-sqlalchemy", specifier = "~=3.1.1" },
{ name = "gevent", specifier = "~=25.9.1" },
{ name = "gevent-websocket", specifier = "~=0.10.1" },
{ name = "gmpy2", specifier = "~=2.2.1" },
{ name = "google-api-core", specifier = "==2.18.0" },
{ name = "google-api-python-client", specifier = "==2.90.0" },
@@ -1550,6 +1562,7 @@ requires-dist = [
{ name = "pypdfium2", specifier = "==4.30.0" },
{ name = "python-docx", specifier = "~=1.1.0" },
{ name = "python-dotenv", specifier = "==1.0.1" },
{ name = "python-socketio", specifier = "~=5.13.0" },
{ name = "pyyaml", specifier = "~=6.0.1" },
{ name = "readabilipy", specifier = "~=0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" },
@@ -2104,6 +2117,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" },
]
[[package]]
name = "gevent-websocket"
version = "0.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gevent" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/d2/6fa19239ff1ab072af40ebf339acd91fb97f34617c2ee625b8e34bf42393/gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0", size = 18366, upload-time = "2017-03-12T22:46:05.68Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/84/2dc373eb6493e00c884cc11e6c059ec97abae2678d42f06bf780570b0193/gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242", size = 22987, upload-time = "2017-03-12T22:46:03.611Z" },
]
[[package]]
name = "gitdb"
version = "4.0.12"
@@ -5073,6 +5098,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
]
[[package]]
name = "python-engineio"
version = "4.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simple-websocket" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" },
]
[[package]]
name = "python-http-client"
version = "3.3.7"
@@ -5129,6 +5166,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
]
[[package]]
name = "python-socketio"
version = "5.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload-time = "2025-04-12T15:46:59.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload-time = "2025-04-12T15:46:58.412Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
@@ -5634,6 +5684,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "simple-websocket"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -7032,6 +7094,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
]
[[package]]
name = "wsproto"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" },
]
[[package]]
name = "xinference-client"
version = "1.2.2"
+5
View File
@@ -122,6 +122,10 @@ MIGRATION_ENABLED=true
# The default value is 300 seconds.
FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker
ENABLE_COLLABORATION_MODE=false
# Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60
@@ -149,6 +153,7 @@ DIFY_PORT=5001
SERVER_WORKER_AMOUNT=1
# Defaults to gevent. If using windows, it can be switched to sync or solo.
# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker
SERVER_WORKER_CLASS=gevent
# Default number of worker connections, the default is 10.
@@ -14,6 +14,14 @@ server {
include proxy.conf;
}
location /socket.io/ {
proxy_pass http://api:5001;
include proxy.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
}
location /v1 {
proxy_pass http://api:5001;
include proxy.conf;
+1 -1
View File
@@ -5,7 +5,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_http_version 1.1;
proxy_set_header Connection "";
# proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT};
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT};
@@ -0,0 +1,4 @@
// Mock for context-block plugin to avoid circular dependency in Storybook
export const ContextBlockNode = null
export const ContextBlockReplacementBlock = null
export default null
@@ -0,0 +1,4 @@
// Mock for history-block plugin to avoid circular dependency in Storybook
export const HistoryBlockNode = null
export const HistoryBlockReplacementBlock = null
export default null
+4
View File
@@ -0,0 +1,4 @@
// Mock for query-block plugin to avoid circular dependency in Storybook
export const QueryBlockNode = null
export const QueryBlockReplacementBlock = null
export default null
+17
View File
@@ -1,4 +1,9 @@
import type { StorybookConfig } from '@storybook/nextjs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const config: StorybookConfig = {
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@@ -25,5 +30,17 @@ const config: StorybookConfig = {
docs: {
defaultName: 'Documentation',
},
webpackFinal: async (config) => {
// Add alias to mock problematic modules with circular dependencies
config.resolve = config.resolve || {}
config.resolve.alias = {
...config.resolve.alias,
// Mock the plugin index files to avoid circular dependencies
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'),
}
return config
},
}
export default config
+1 -2
View File
@@ -160,8 +160,7 @@ describe('Navigation Utilities', () => {
page: 1,
limit: '',
keyword: 'test',
empty: null,
undefined,
filter: '',
})
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
+147 -102
View File
@@ -39,28 +39,38 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
const matches = isDarkQuery ? systemPrefersDark : false
const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => {
listeners.add(listener)
}
const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => {
listeners.delete(listener)
}
const handleAddEventListener = (_event: string, listener: EventListener) => {
if (typeof listener === 'function')
listeners.add(listener as (event: MediaQueryListEvent) => void)
}
const handleRemoveEventListener = (_event: string, listener: EventListener) => {
if (typeof listener === 'function')
listeners.delete(listener as (event: MediaQueryListEvent) => void)
}
const handleDispatchEvent = (event: Event) => {
listeners.forEach(listener => listener(event as MediaQueryListEvent))
return true
}
const mediaQueryList: MediaQueryList = {
matches,
media: query,
onchange: null,
addListener: (listener: MediaQueryListListener) => {
listeners.add(listener)
},
removeListener: (listener: MediaQueryListListener) => {
listeners.delete(listener)
},
addEventListener: (_event, listener: EventListener) => {
if (typeof listener === 'function')
listeners.add(listener as MediaQueryListListener)
},
removeEventListener: (_event, listener: EventListener) => {
if (typeof listener === 'function')
listeners.delete(listener as MediaQueryListListener)
},
dispatchEvent: (event: Event) => {
listeners.forEach(listener => listener(event as MediaQueryListEvent))
return true
},
addListener: handleAddListener,
removeListener: handleRemoveListener,
addEventListener: handleAddEventListener,
removeEventListener: handleRemoveEventListener,
dispatchEvent: handleDispatchEvent,
}
return mediaQueryList
@@ -69,6 +79,121 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
}
// Helper function to create timing page component
const createTimingPageComponent = (
timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>,
) => {
const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => {
timingData.push({
phase,
timestamp: performance.now(),
styles,
})
}
const TimingPageComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
const isDark = mounted ? theme === 'dark' : false
const currentStyles = {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
color: isDark ? '#ffffff' : '#000000',
}
recordTiming(mounted ? 'CSR' : 'Initial', currentStyles)
useEffect(() => {
setMounted(true)
}, [])
return (
<div
data-testid="timing-page"
style={currentStyles}
>
<div data-testid="timing-status">
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
</div>
</div>
)
}
return TimingPageComponent
}
// Helper function to create CSS test component
const createCSSTestComponent = (
cssStates: Array<{ className: string; timestamp: number }>,
) => {
const recordCSSState = (className: string) => {
cssStates.push({
className,
timestamp: performance.now(),
})
}
const CSSTestComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
const isDark = mounted ? theme === 'dark' : false
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
recordCSSState(className)
useEffect(() => {
setMounted(true)
}, [])
return (
<div
data-testid="css-component"
className={className}
>
<div data-testid="css-classes">Classes: {className}</div>
</div>
)
}
return CSSTestComponent
}
// Helper function to create performance test component
const createPerformanceTestComponent = (
performanceMarks: Array<{ event: string; timestamp: number }>,
) => {
const recordPerformanceMark = (event: string) => {
performanceMarks.push({ event, timestamp: performance.now() })
}
const PerformanceTestComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
recordPerformanceMark('component-render')
useEffect(() => {
recordPerformanceMark('mount-start')
setMounted(true)
recordPerformanceMark('mount-complete')
}, [])
useEffect(() => {
if (theme)
recordPerformanceMark('theme-available')
}, [theme])
return (
<div data-testid="performance-test">
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
</div>
)
}
return PerformanceTestComponent
}
// Simulate real page component based on Dify's actual theme usage
const PageComponent = () => {
const [mounted, setMounted] = useState(false)
@@ -227,39 +352,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
const TimingPageComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
const isDark = mounted ? theme === 'dark' : false
// Record timing and styles for each render phase
const currentStyles = {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
color: isDark ? '#ffffff' : '#000000',
}
timingData.push({
phase: mounted ? 'CSR' : 'Initial',
timestamp: performance.now(),
styles: currentStyles,
})
useEffect(() => {
setMounted(true)
}, [])
return (
<div
data-testid="timing-page"
style={currentStyles}
>
<div data-testid="timing-status">
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
</div>
</div>
)
}
const TimingPageComponent = createTimingPageComponent(timingData)
render(
<TestThemeProvider>
@@ -295,33 +388,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
const cssStates: Array<{ className: string; timestamp: number }> = []
const CSSTestComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
const isDark = mounted ? theme === 'dark' : false
// Simulate Tailwind CSS class application
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
cssStates.push({
className,
timestamp: performance.now(),
})
useEffect(() => {
setMounted(true)
}, [])
return (
<div
data-testid="css-component"
className={className}
>
<div data-testid="css-classes">Classes: {className}</div>
</div>
)
}
const CSSTestComponent = createCSSTestComponent(cssStates)
render(
<TestThemeProvider>
@@ -413,34 +480,12 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
const performanceMarks: Array<{ event: string; timestamp: number }> = []
const PerformanceTestComponent = () => {
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
performanceMarks.push({ event: 'component-render', timestamp: performance.now() })
useEffect(() => {
performanceMarks.push({ event: 'mount-start', timestamp: performance.now() })
setMounted(true)
performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() })
}, [])
useEffect(() => {
if (theme)
performanceMarks.push({ event: 'theme-available', timestamp: performance.now() })
}, [theme])
return (
<div data-testid="performance-test">
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
</div>
)
}
setupMockEnvironment('dark')
expect(window.localStorage.getItem('theme')).toBe('dark')
const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks)
render(
<TestThemeProvider>
<PerformanceTestComponent />
+19 -13
View File
@@ -70,14 +70,18 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
})
describe('Fallback Logic (from layout-main.tsx)', () => {
type Tag = { id: string; name: string }
type AppDetail = { tags: Tag[] }
type FallbackResult = { tags?: Tag[] } | null
// no-op
it('should trigger fallback when tags are missing or empty', () => {
const appDetailWithoutTags = { tags: [] }
const appDetailWithTags = { tags: [{ id: 'tag1' }] }
const appDetailWithUndefinedTags = { tags: undefined as any }
const appDetailWithoutTags: AppDetail = { tags: [] }
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
// This simulates the condition in layout-main.tsx
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
const shouldFallback2 = appDetailWithTags.tags.length === 0
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
@@ -86,24 +90,26 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
})
it('should preserve tags when fallback succeeds', () => {
const originalAppDetail = { tags: [] as any[] }
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
const originalAppDetail: AppDetail = { tags: [] }
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
// This simulates the successful fallback in layout-main.tsx
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags
const tags = fallbackResult.tags
if (tags)
originalAppDetail.tags = tags
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
expect(originalAppDetail.tags.length).toBe(1)
})
it('should continue with empty tags when fallback fails', () => {
const originalAppDetail: { tags: any[] } = { tags: [] }
const fallbackResult: { tags?: any[] } | null = null
const originalAppDetail: AppDetail = { tags: [] }
const fallbackResult = null as FallbackResult
// This simulates fallback failure in layout-main.tsx
if (fallbackResult?.tags)
originalAppDetail.tags = fallbackResult.tags
const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
if (tags)
originalAppDetail.tags = tags
expect(originalAppDetail.tags).toEqual([])
})
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
@@ -19,6 +19,8 @@ import { asyncRunSafe } from '@/utils'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
export type ICardViewProps = {
appId: string
@@ -47,15 +49,44 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
if (type === 'success')
if (type === 'success') {
updateAppDetail()
// Emit collaboration event to notify other clients of app state changes
const socket = webSocketClient.getSocket(appId)
if (socket) {
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
}
notify({
type,
message: t(`common.actionMsg.${message}`),
})
}
// Listen for collaborative app state updates from other clients
useEffect(() => {
if (!appId) return
const unsubscribe = collaborationManager.onAppStateUpdate(async (update: any) => {
try {
console.log('Received app state update from collaboration:', update)
// Update app detail when other clients modify app state
await updateAppDetail()
}
catch (error) {
console.error('app state update failed:', error)
}
})
return unsubscribe
}, [appId])
const onChangeSiteStatus = async (value: boolean) => {
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
@@ -73,7 +73,7 @@ const ConfigPopup: FC<PopupProps> = ({
}
}, [onChooseProvider])
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => {
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig) => {
onConfigUpdated(currentProvider!, payload)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigUpdated])
+36 -3
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
@@ -16,7 +16,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -31,6 +31,8 @@ import type { Operation } from './app-operations'
import AppOperations from './app-operations'
import dynamic from 'next/dynamic'
import cn from '@/utils/classnames'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
@@ -74,6 +76,19 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [showExportWarning, setShowExportWarning] = useState(false)
const emitAppMetaUpdate = useCallback(() => {
if (!appDetail?.id)
return
const socket = webSocketClient.getSocket(appDetail.id)
if (socket) {
socket.emit('collaboration_event', {
type: 'app_meta_update',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
}, [appDetail?.id])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@@ -102,11 +117,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
message: t('app.editDone'),
})
setAppDetail(app)
emitAppMetaUpdate()
}
catch {
notify({ type: 'error', message: t('app.editFailed') })
}
}, [appDetail, notify, setAppDetail, t])
}, [appDetail, notify, setAppDetail, t, emitAppMetaUpdate])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
@@ -203,6 +219,23 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
setShowConfirmDelete(false)
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
useEffect(() => {
if (!appDetail?.id)
return
const unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
setAppDetail({ ...res })
}
catch (error) {
console.error('failed to refresh app detail from collaboration update:', error)
}
})
return unsubscribe
}, [appDetail?.id, setAppDetail])
const { isCurrentWorkspaceEditor } = useAppContext()
if (!appDetail)
+33 -1
View File
@@ -47,6 +47,9 @@ import { AccessMode } from '@/models/access-control'
import { fetchAppDetail } from '@/service/apps'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
export type AppPublisherProps = {
disabled?: boolean
@@ -96,6 +99,7 @@ const AppPublisher = ({
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const invalidateAppWorkflow = useInvalidateAppWorkflow()
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
@@ -120,11 +124,27 @@ const AppPublisher = ({
try {
await onPublish?.(params)
setPublished(true)
const appId = appDetail?.id
const socket = appId ? webSocketClient.getSocket(appId) : null
if (appId)
invalidateAppWorkflow(appId)
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_publish_update',
data: {
action: 'published',
timestamp,
},
timestamp,
})
}
}
catch {
setPublished(false)
}
}, [onPublish])
}, [appDetail?.id, onPublish, invalidateAppWorkflow])
const handleRestore = useCallback(async () => {
try {
@@ -178,6 +198,18 @@ const AppPublisher = ({
handlePublish()
}, { exactMatch: true, useCapture: true })
useEffect(() => {
const appId = appDetail?.id
if (!appId) return
const unsubscribe = collaborationManager.onAppPublishUpdate((update: any) => {
if (update?.data?.action === 'published')
invalidateAppWorkflow(appId)
})
return unsubscribe
}, [appDetail?.id, invalidateAppWorkflow])
return (
<>
<PortalToFollowElem
+22 -1
View File
@@ -32,6 +32,8 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { formatTime } from '@/utils/time'
import { useGetUserCanAccessApp } from '@/service/access-control'
import dynamic from 'next/dynamic'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import type { WorkflowOnlineUser } from '@/models/app'
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
ssr: false,
@@ -55,9 +57,10 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
export type AppCardProps = {
app: App
onRefresh?: () => void
onlineUsers?: WorkflowOnlineUser[]
}
const AppCard = ({ app, onRefresh }: AppCardProps) => {
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@@ -333,6 +336,19 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
return `${t('datasetDocuments.segment.editedAt')} ${timeText}`
}, [app.updated_at, app.created_at])
const onlineUserAvatars = useMemo(() => {
if (!onlineUsers.length)
return []
return onlineUsers
.map(user => ({
id: user.user_id || user.sid || '',
name: user.username || 'User',
avatar_url: user.avatar || undefined,
}))
.filter(user => !!user.id)
}, [onlineUsers])
return (
<>
<div
@@ -377,6 +393,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<RiVerifiedBadgeLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
</div>
<div>
{onlineUserAvatars.length > 0 && (
<UserAvatarList users={onlineUserAvatars} maxVisible={3} size={20} />
)}
</div>
</div>
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
<div
+40 -4
View File
@@ -1,10 +1,11 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
useRouter,
} from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import {
@@ -19,8 +20,8 @@ import AppCard from './app-card'
import NewAppCard from './new-app-card'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import type { AppListResponse } from '@/models/app'
import { fetchAppList } from '@/service/apps'
import type { AppListResponse, WorkflowOnlineUser } from '@/models/app'
import { fetchAppList, fetchWorkflowOnlineUsers } from '@/service/apps'
import { useAppContext } from '@/context/app-context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { CheckModal } from '@/hooks/use-pay'
@@ -112,6 +113,36 @@ const List = () => {
},
)
const apps = useMemo(() => data?.flatMap(page => page.data) ?? [], [data])
const workflowIds = useMemo(() => {
const ids = new Set<string>()
apps.forEach((appItem) => {
const workflowId = appItem.id
if (!workflowId)
return
if (appItem.mode === 'workflow' || appItem.mode === 'advanced-chat')
ids.add(workflowId)
})
return Array.from(ids)
}, [apps])
const { data: onlineUsersByWorkflow, mutate: refreshOnlineUsers } = useSWR<Record<string, WorkflowOnlineUser[]>>(
workflowIds.length ? { workflowIds } : null,
fetchWorkflowOnlineUsers,
)
useEffect(() => {
const timer = window.setInterval(() => {
mutate()
if (workflowIds.length)
refreshOnlineUsers()
}, 10000)
return () => window.clearInterval(timer)
}, [workflowIds.join(','), mutate, refreshOnlineUsers])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='mr-1 h-[14px] w-[14px]' /> },
@@ -213,7 +244,12 @@ const List = () => {
{isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
{data.map(({ data: apps }) => apps.map(app => (
<AppCard key={app.id} app={app} onRefresh={mutate} />
<AppCard
key={app.id}
app={app}
onRefresh={mutate}
onlineUsers={onlineUsersByWorkflow?.[app.id] ?? []}
/>
)))}
</div>
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
@@ -0,0 +1,262 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
import ActionButton, { ActionButtonState } from '.'
const meta = {
title: 'Base/ActionButton',
component: ActionButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'm', 'l', 'xl'],
description: 'Button size',
},
state: {
control: 'select',
options: [
ActionButtonState.Default,
ActionButtonState.Active,
ActionButtonState.Disabled,
ActionButtonState.Destructive,
ActionButtonState.Hover,
],
description: 'Button state',
},
children: {
control: 'text',
description: 'Button content',
},
disabled: {
control: 'boolean',
description: 'Native disabled state',
},
},
} satisfies Meta<typeof ActionButton>
export default meta
type Story = StoryObj<typeof meta>
// Default state
export const Default: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
// With text
export const WithText: Story = {
args: {
size: 'm',
children: 'Edit',
},
}
// Icon with text
export const IconWithText: Story = {
args: {
size: 'm',
children: (
<>
<RiAddLine className="mr-1 h-4 w-4" />
Add Item
</>
),
},
}
// Size variations
export const ExtraSmall: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3 w-3" />,
},
}
export const Small: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3.5 w-3.5" />,
},
}
export const Medium: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
export const Large: Story = {
args: {
size: 'l',
children: <RiEditLine className="h-5 w-5" />,
},
}
export const ExtraLarge: Story = {
args: {
size: 'xl',
children: <RiEditLine className="h-6 w-6" />,
},
}
// State variations
export const ActiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Active,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DisabledState: Story = {
args: {
size: 'm',
state: ActionButtonState.Disabled,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DestructiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Destructive,
children: <RiDeleteBinLine className="h-4 w-4" />,
},
}
export const HoverState: Story = {
args: {
size: 'm',
state: ActionButtonState.Hover,
children: <RiEditLine className="h-4 w-4" />,
},
}
// Real-world examples
export const ToolbarActions: Story = {
render: () => (
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiShareLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiSaveLine className="h-4 w-4" />
</ActionButton>
<div className="mx-1 h-4 w-px bg-divider-regular" />
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</div>
),
}
export const InlineActions: Story = {
render: () => (
<div className="flex items-center gap-2">
<span className="text-text-secondary">Item name</span>
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<ActionButton size="xs">
<RiMore2Fill className="h-3.5 w-3.5" />
</ActionButton>
</div>
),
}
export const SizeComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3 w-3" />
</ActionButton>
<span className="text-xs text-text-tertiary">XS</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<span className="text-xs text-text-tertiary">S</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">M</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="l">
<RiEditLine className="h-5 w-5" />
</ActionButton>
<span className="text-xs text-text-tertiary">L</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xl">
<RiEditLine className="h-6 w-6" />
</ActionButton>
<span className="text-xs text-text-tertiary">XL</span>
</div>
</div>
),
}
export const StateComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Default}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Default</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Active}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Active</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Hover}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Hover</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Disabled}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Disabled</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Destructive</span>
</div>
</div>
),
}
// Interactive playground
export const Playground: Story = {
args: {
size: 'm',
state: ActionButtonState.Default,
children: <RiEditLine className="h-4 w-4" />,
},
}
@@ -0,0 +1,204 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import AutoHeightTextarea from '.'
const meta = {
title: 'Base/AutoHeightTextarea',
component: AutoHeightTextarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.',
},
},
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text',
},
value: {
control: 'text',
description: 'Textarea value',
},
minHeight: {
control: 'number',
description: 'Minimum height in pixels',
},
maxHeight: {
control: 'number',
description: 'Maximum height in pixels',
},
autoFocus: {
control: 'boolean',
description: 'Auto focus on mount',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
wrapperClassName: {
control: 'text',
description: 'Wrapper CSS classes',
},
},
} satisfies Meta<typeof AutoHeightTextarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const AutoHeightTextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<AutoHeightTextarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Text changed:', e.target.value)
}}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: '',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'This is a pre-filled textarea with some initial content.',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With multiline content
export const MultilineContent: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Custom min height
export const CustomMinHeight: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Taller minimum height...',
value: '',
minHeight: 100,
maxHeight: 200,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Small max height (scrollable)
export const SmallMaxHeight: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type multiple lines...',
value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.',
minHeight: 36,
maxHeight: 80,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Auto focus enabled
export const AutoFocus: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'This textarea auto-focuses on mount',
value: '',
minHeight: 36,
maxHeight: 96,
autoFocus: true,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With custom styling
export const CustomStyling: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Custom styled textarea...',
value: '',
minHeight: 50,
maxHeight: 150,
className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600',
wrapperClassName: 'shadow-lg',
},
}
// Long content example
export const LongContent: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
minHeight: 36,
maxHeight: 200,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Real-world example - Chat input
export const ChatInput: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type your message...',
value: '',
minHeight: 40,
maxHeight: 120,
className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500',
},
}
// Real-world example - Comment box
export const CommentBox: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Write a comment...',
value: '',
minHeight: 60,
maxHeight: 200,
className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500',
},
}
// Interactive playground
export const Playground: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: '',
minHeight: 36,
maxHeight: 96,
autoFocus: false,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
wrapperClassName: '',
},
}
@@ -31,7 +31,7 @@ const AutoHeightTextarea = (
onKeyDown,
onKeyUp,
}: IProps & {
ref: React.RefObject<unknown>;
ref?: React.RefObject<HTMLTextAreaElement>;
},
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
+22 -8
View File
@@ -9,6 +9,7 @@ export type AvatarProps = {
className?: string
textClassName?: string
onError?: (x: boolean) => void
backgroundColor?: string
}
const Avatar = ({
name,
@@ -17,9 +18,18 @@ const Avatar = ({
className,
textClassName,
onError,
backgroundColor,
}: AvatarProps) => {
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
const avatarClassName = backgroundColor
? 'shrink-0 flex items-center rounded-full'
: 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = {
width: `${size}px`,
height: `${size}px`,
fontSize: `${size}px`,
lineHeight: `${size}px`,
...(backgroundColor && !avatar ? { backgroundColor } : {}),
}
const [imgError, setImgError] = useState(false)
const handleError = () => {
@@ -35,14 +45,18 @@ const Avatar = ({
if (avatar && !imgError) {
return (
<img
<span
className={cn(avatarClassName, className)}
style={style}
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
>
<img
className='h-full w-full rounded-full object-cover'
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
</span>
)
}
@@ -0,0 +1,191 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import BlockInput from '.'
const meta = {
title: 'Base/BlockInput',
component: BlockInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Input value (supports {{variable}} syntax)',
},
className: {
control: 'text',
description: 'Wrapper CSS classes',
},
highLightClassName: {
control: 'text',
description: 'CSS class for highlighted variables (default: text-blue-500)',
},
readonly: {
control: 'boolean',
description: 'Read-only mode',
},
},
} satisfies Meta<typeof BlockInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const BlockInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
const [keys, setKeys] = useState<string[]>([])
return (
<div style={{ width: '600px' }}>
<BlockInput
{...args}
value={value}
onConfirm={(newValue, extractedKeys) => {
setValue(newValue)
setKeys(extractedKeys)
console.log('Value confirmed:', newValue)
console.log('Extracted keys:', extractedKeys)
}}
/>
{keys.length > 0 && (
<div className="mt-4 rounded-lg bg-blue-50 p-3">
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
<div className="flex flex-wrap gap-2">
{keys.map(key => (
<span key={key} className="rounded bg-blue-500 px-2 py-1 text-xs text-white">
{key}
</span>
))}
</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// With single variable
export const SingleVariable: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hello {{name}}, welcome to the application!',
readonly: false,
},
}
// With multiple variables
export const MultipleVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
readonly: false,
},
}
// Complex template
export const ComplexTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
readonly: false,
},
}
// Read-only mode
export const ReadOnlyMode: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
readonly: true,
},
}
// Empty state
export const EmptyState: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// Long content
export const LongContent: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
readonly: false,
},
}
// Variables with underscores
export const VariablesWithUnderscores: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
readonly: false,
},
}
// Adjacent variables
export const AdjacentVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
readonly: false,
},
}
// Real-world example - Email template
export const EmailTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
readonly: false,
},
}
// Real-world example - Notification template
export const NotificationTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
readonly: false,
},
}
// Custom styling
export const CustomStyling: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This template uses {{custom_variable}} with custom styling.',
readonly: false,
className: 'bg-gray-50 border-2 border-blue-200',
},
}
// Interactive playground
export const Playground: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Try editing this text and adding variables like {{example}}',
readonly: false,
className: '',
highLightClassName: '',
},
}
@@ -1,7 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ChatItem } from '../../types'
import { mockedWorkflowProcess } from './__mocks__/workflowProcess'
import { markdownContent } from './__mocks__/markdownContent'
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
@@ -34,6 +32,11 @@ const mockedBaseChatItem = {
content: 'Hello, how can I assist you today?',
} satisfies ChatItem
const mockedWorkflowProcess = {
status: 'succeeded',
tracing: [],
}
export const Basic: Story = {
args: {
item: mockedBaseChatItem,
@@ -0,0 +1,394 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Checkbox from '.'
// Helper function for toggling items in an array
const createToggleItem = <T extends { id: string; checked: boolean }>(
items: T[],
setItems: (items: T[]) => void,
) => (id: string) => {
setItems(items.map(item =>
item.id === id ? { ...item, checked: !item.checked } as T : item,
))
}
const meta = {
title: 'Base/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.',
},
},
},
tags: ['autodocs'],
argTypes: {
checked: {
control: 'boolean',
description: 'Checked state',
},
indeterminate: {
control: 'boolean',
description: 'Indeterminate state (partially checked)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
id: {
control: 'text',
description: 'HTML id attribute',
},
},
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const CheckboxDemo = (args: any) => {
const [checked, setChecked] = useState(args.checked || false)
return (
<div className="flex items-center gap-3">
<Checkbox
{...args}
checked={checked}
onCheck={() => {
if (!args.disabled) {
setChecked(!checked)
console.log('Checkbox toggled:', !checked)
}
}}
/>
<span className="text-sm text-gray-700">
{checked ? 'Checked' : 'Unchecked'}
</span>
</div>
)
}
// Default unchecked
export const Default: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: false,
indeterminate: false,
},
}
// Checked state
export const Checked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
disabled: false,
indeterminate: false,
},
}
// Indeterminate state
export const Indeterminate: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: false,
indeterminate: true,
},
}
// Disabled unchecked
export const DisabledUnchecked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: true,
indeterminate: false,
},
}
// Disabled checked
export const DisabledChecked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
disabled: true,
indeterminate: false,
},
}
// Disabled indeterminate
export const DisabledIndeterminate: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: true,
indeterminate: true,
},
}
// State comparison
export const StateComparison: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Unchecked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Checked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} indeterminate={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Indeterminate</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={true} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled Checked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} indeterminate={true} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled Indeterminate</span>
</div>
</div>
</div>
),
}
// With labels
const WithLabelsDemo = () => {
const [items, setItems] = useState([
{ id: '1', label: 'Enable notifications', checked: true },
{ id: '2', label: 'Enable email updates', checked: false },
{ id: '3', label: 'Enable SMS alerts', checked: false },
])
const toggleItem = createToggleItem(items, setItems)
return (
<div className="flex flex-col gap-3">
{items.map(item => (
<div key={item.id} className="flex items-center gap-3">
<Checkbox
id={item.id}
checked={item.checked}
onCheck={() => toggleItem(item.id)}
/>
<label
htmlFor={item.id}
className="cursor-pointer text-sm text-gray-700"
onClick={() => toggleItem(item.id)}
>
{item.label}
</label>
</div>
))}
</div>
)
}
export const WithLabels: Story = {
render: () => <WithLabelsDemo />,
}
// Select all example
const SelectAllExampleDemo = () => {
const [items, setItems] = useState([
{ id: '1', label: 'Item 1', checked: false },
{ id: '2', label: 'Item 2', checked: false },
{ id: '3', label: 'Item 3', checked: false },
])
const allChecked = items.every(item => item.checked)
const someChecked = items.some(item => item.checked)
const indeterminate = someChecked && !allChecked
const toggleAll = () => {
const newChecked = !allChecked
setItems(items.map(item => ({ ...item, checked: newChecked })))
}
const toggleItem = createToggleItem(items, setItems)
return (
<div className="flex flex-col gap-3 rounded-lg bg-gray-50 p-4">
<div className="flex items-center gap-3 border-b border-gray-200 pb-3">
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
onCheck={toggleAll}
/>
<span className="text-sm font-medium text-gray-700">Select All</span>
</div>
<div className="flex flex-col gap-2 pl-7">
{items.map(item => (
<div key={item.id} className="flex items-center gap-3">
<Checkbox
id={item.id}
checked={item.checked}
onCheck={() => toggleItem(item.id)}
/>
<label
htmlFor={item.id}
className="cursor-pointer text-sm text-gray-600"
onClick={() => toggleItem(item.id)}
>
{item.label}
</label>
</div>
))}
</div>
</div>
)
}
export const SelectAllExample: Story = {
render: () => <SelectAllExampleDemo />,
}
// Form example
const FormExampleDemo = () => {
const [formData, setFormData] = useState({
terms: false,
newsletter: false,
privacy: false,
})
return (
<div className="w-96 rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Account Settings</h3>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Checkbox
id="terms"
checked={formData.terms}
onCheck={() => setFormData({ ...formData, terms: !formData.terms })}
/>
<div>
<label htmlFor="terms" className="cursor-pointer text-sm font-medium text-gray-700">
I agree to the terms and conditions
</label>
<p className="mt-1 text-xs text-gray-500">
Required to continue
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="newsletter"
checked={formData.newsletter}
onCheck={() => setFormData({ ...formData, newsletter: !formData.newsletter })}
/>
<div>
<label htmlFor="newsletter" className="cursor-pointer text-sm font-medium text-gray-700">
Subscribe to newsletter
</label>
<p className="mt-1 text-xs text-gray-500">
Get updates about new features
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="privacy"
checked={formData.privacy}
onCheck={() => setFormData({ ...formData, privacy: !formData.privacy })}
/>
<div>
<label htmlFor="privacy" className="cursor-pointer text-sm font-medium text-gray-700">
I have read the privacy policy
</label>
<p className="mt-1 text-xs text-gray-500">
Required to continue
</p>
</div>
</div>
</div>
</div>
)
}
export const FormExample: Story = {
render: () => <FormExampleDemo />,
}
// Task list example
const TaskListExampleDemo = () => {
const [tasks, setTasks] = useState([
{ id: '1', title: 'Review pull request', completed: true },
{ id: '2', title: 'Update documentation', completed: true },
{ id: '3', title: 'Fix navigation bug', completed: false },
{ id: '4', title: 'Deploy to staging', completed: false },
])
const toggleTask = (id: string) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task,
))
}
const completedCount = tasks.filter(t => t.completed).length
return (
<div className="w-96 rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Today's Tasks</h3>
<span className="text-xs text-gray-500">
{completedCount} of {tasks.length} completed
</span>
</div>
<div className="flex flex-col gap-2">
{tasks.map(task => (
<div
key={task.id}
className="flex items-center gap-3 rounded p-2 hover:bg-gray-50"
>
<Checkbox
id={task.id}
checked={task.completed}
onCheck={() => toggleTask(task.id)}
/>
<span
className={`cursor-pointer text-sm ${
task.completed ? 'text-gray-400 line-through' : 'text-gray-700'
}`}
onClick={() => toggleTask(task.id)}
>
{task.title}
</span>
</div>
))}
</div>
</div>
)
}
export const TaskListExample: Story = {
render: () => <TaskListExampleDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
indeterminate: false,
disabled: false,
id: 'playground-checkbox',
},
}
@@ -15,11 +15,12 @@ const ContentDialog = ({
onClose,
children,
}: ContentDialogProps) => {
// z-[70]: Ensures dialog appears above workflow operators (z-[60]) and other UI elements
return (
<Transition
show={show}
as='div'
className='absolute left-0 top-0 z-30 box-border h-full w-full p-2'
className='absolute left-0 top-0 z-[70] box-border h-full w-full p-2'
>
<TransitionChild>
<div
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z" fill="white" fill-opacity="0.12"/>
<path d="M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none">
<path d="M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -0,0 +1,36 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z",
"fill": "white",
"fill-opacity": "0.12"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z",
"fill": "white"
},
"children": []
}
]
},
"name": "EnterKey"
}
@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './EnterKey.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'EnterKey'
export default Icon
@@ -1,6 +1,7 @@
export { default as D } from './D'
export { default as DiagonalDividingLine } from './DiagonalDividingLine'
export { default as Dify } from './Dify'
export { default as EnterKey } from './EnterKey'
export { default as Gdpr } from './Gdpr'
export { default as Github } from './Github'
export { default as Highlight } from './Highlight'
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -0,0 +1,26 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"xmlns": "http://www.w3.org/2000/svg",
"width": "14",
"height": "12",
"viewBox": "0 0 14 12",
"fill": "none"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "Comment"
}
@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './Comment.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Comment'
export default Icon
@@ -1,4 +1,5 @@
export { default as Icon3Dots } from './Icon3Dots'
export { default as Comment } from './Comment'
export { default as DefaultToolIcon } from './DefaultToolIcon'
export { default as Message3Fill } from './Message3Fill'
export { default as RowStruct } from './RowStruct'
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
@@ -0,0 +1,438 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { InputNumber } from '.'
const meta = {
title: 'Base/InputNumber',
component: InputNumber,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current value',
},
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
min: {
control: 'number',
description: 'Minimum value',
},
max: {
control: 'number',
description: 'Maximum value',
},
amount: {
control: 'number',
description: 'Step amount for increment/decrement',
},
unit: {
control: 'text',
description: 'Unit text displayed (e.g., "px", "ms")',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
defaultValue: {
control: 'number',
description: 'Default value when undefined',
},
},
} satisfies Meta<typeof InputNumber>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputNumberDemo = (args: any) => {
const [value, setValue] = useState(args.value ?? 0)
return (
<div style={{ width: '300px' }}>
<InputNumber
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue)
console.log('Value changed:', newValue)
}}
/>
<div className="mt-3 text-sm text-gray-600">
Current value: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
size: 'regular',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'large',
},
}
// With min/max constraints
export const WithMinMax: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 5,
min: 0,
max: 10,
size: 'regular',
},
}
// With custom step amount
export const CustomStepAmount: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 50,
amount: 5,
min: 0,
max: 100,
size: 'regular',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 100,
unit: 'px',
min: 0,
max: 1000,
amount: 10,
size: 'regular',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 42,
disabled: true,
size: 'regular',
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 2.5,
amount: 0.5,
min: 0,
max: 10,
size: 'regular',
},
}
// Negative values allowed
export const NegativeValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
min: -100,
max: 100,
amount: 10,
size: 'regular',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState(10)
const [largeValue, setLargeValue] = useState(20)
return (
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<InputNumber
size="regular"
value={regularValue}
onChange={setRegularValue}
min={0}
max={100}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<InputNumber
size="large"
value={largeValue}
onChange={setLargeValue}
min={0}
max={100}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// Real-world example - Font size picker
const FontSizePickerDemo = () => {
const [fontSize, setFontSize] = useState(16)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Font Size</label>
<InputNumber
value={fontSize}
onChange={setFontSize}
min={8}
max={72}
amount={2}
unit="px"
/>
</div>
<div className="rounded-lg bg-gray-50 p-4">
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
Preview Text
</p>
</div>
</div>
</div>
)
}
export const FontSizePicker: Story = {
render: () => <FontSizePickerDemo />,
}
// Real-world example - Quantity selector
const QuantitySelectorDemo = () => {
const [quantity, setQuantity] = useState(1)
const pricePerItem = 29.99
const total = (quantity * pricePerItem).toFixed(2)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
<p className="text-sm text-gray-500">${pricePerItem} each</p>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Quantity</label>
<InputNumber
value={quantity}
onChange={setQuantity}
min={1}
max={99}
amount={1}
/>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Total</span>
<span className="text-lg font-semibold text-gray-900">${total}</span>
</div>
</div>
</div>
</div>
)
}
export const QuantitySelector: Story = {
render: () => <QuantitySelectorDemo />,
}
// Real-world example - Timer settings
const TimerSettingsDemo = () => {
const [hours, setHours] = useState(0)
const [minutes, setMinutes] = useState(15)
const [seconds, setSeconds] = useState(30)
const totalSeconds = hours * 3600 + minutes * 60 + seconds
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Hours</label>
<InputNumber
value={hours}
onChange={setHours}
min={0}
max={23}
unit="h"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Minutes</label>
<InputNumber
value={minutes}
onChange={setMinutes}
min={0}
max={59}
unit="m"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Seconds</label>
<InputNumber
value={seconds}
onChange={setSeconds}
min={0}
max={59}
unit="s"
/>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-3">
<div className="text-sm text-gray-600">
Total duration: <span className="font-semibold">{totalSeconds} seconds</span>
</div>
</div>
</div>
</div>
)
}
export const TimerSettings: Story = {
render: () => <TimerSettingsDemo />,
}
// Real-world example - Animation settings
const AnimationSettingsDemo = () => {
const [duration, setDuration] = useState(300)
const [delay, setDelay] = useState(0)
const [iterations, setIterations] = useState(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Duration</label>
<InputNumber
value={duration}
onChange={setDuration}
min={0}
max={5000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Delay</label>
<InputNumber
value={delay}
onChange={setDelay}
min={0}
max={2000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Iterations</label>
<InputNumber
value={iterations}
onChange={setIterations}
min={1}
max={10}
amount={1}
/>
</div>
<div className="mt-2 rounded-lg bg-gray-50 p-4">
<div className="font-mono text-xs text-gray-700">
animation: {duration}ms {delay}ms {iterations}
</div>
</div>
</div>
</div>
)
}
export const AnimationSettings: Story = {
render: () => <AnimationSettingsDemo />,
}
// Real-world example - Temperature control
const TemperatureControlDemo = () => {
const [temperature, setTemperature] = useState(20)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
<InputNumber
size="large"
value={temperature}
onChange={setTemperature}
min={16}
max={30}
amount={0.5}
unit="°C"
/>
</div>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
<div>
<div className="text-xs text-gray-500">Celsius</div>
<div className="text-2xl font-semibold text-gray-900">{temperature}°C</div>
</div>
<div>
<div className="text-xs text-gray-500">Fahrenheit</div>
<div className="text-2xl font-semibold text-gray-900">{fahrenheit}°F</div>
</div>
</div>
</div>
</div>
)
}
export const TemperatureControl: Story = {
render: () => <TemperatureControlDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'regular',
min: 0,
max: 100,
amount: 1,
unit: '',
disabled: false,
defaultValue: 0,
},
}
@@ -0,0 +1,424 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Input from '.'
const meta = {
title: 'Base/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
type: {
control: 'select',
options: ['text', 'number', 'email', 'password', 'url', 'tel'],
description: 'Input type',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
showLeftIcon: {
control: 'boolean',
description: 'Show search icon on left',
},
showClearIcon: {
control: 'boolean',
description: 'Show clear button when input has value',
},
unit: {
control: 'text',
description: 'Unit text displayed on right (e.g., "px", "ms")',
},
},
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '400px' }}>
<Input
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Input changed:', e.target.value)
}}
onClear={() => {
setValue('')
console.log('Input cleared')
}}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
type: 'text',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'large',
placeholder: 'Enter text...',
type: 'text',
},
}
// With search icon
export const WithSearchIcon: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
placeholder: 'Search...',
type: 'text',
},
}
// With clear button
export const WithClearButton: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showClearIcon: true,
value: 'Some text to clear',
placeholder: 'Type something...',
type: 'text',
},
}
// Search input (icon + clear)
export const SearchInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
showClearIcon: true,
value: '',
placeholder: 'Search...',
type: 'text',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'Disabled input',
disabled: true,
type: 'text',
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'invalid@email',
destructive: true,
placeholder: 'Enter email...',
type: 'email',
},
}
// Number input
export const NumberInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
placeholder: 'Enter a number...',
value: '0',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
value: '100',
unit: 'px',
placeholder: 'Enter value...',
},
}
// Email input
export const EmailInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'email',
placeholder: 'Enter your email...',
showClearIcon: true,
},
}
// Password input
export const PasswordInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'password',
placeholder: 'Enter password...',
value: 'secret123',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState('')
const [largeValue, setLargeValue] = useState('')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<Input
size="regular"
value={regularValue}
onChange={e => setRegularValue(e.target.value)}
placeholder="Regular input..."
showClearIcon
onClear={() => setRegularValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<Input
size="large"
value={largeValue}
onChange={e => setLargeValue(e.target.value)}
placeholder="Large input..."
showClearIcon
onClear={() => setLargeValue('')}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// State comparison
const StateComparisonDemo = () => {
const [normalValue, setNormalValue] = useState('Normal state')
const [errorValue, setErrorValue] = useState('Error state')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Normal</label>
<Input
value={normalValue}
onChange={e => setNormalValue(e.target.value)}
showClearIcon
onClear={() => setNormalValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Destructive</label>
<Input
value={errorValue}
onChange={e => setErrorValue(e.target.value)}
destructive
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Disabled</label>
<Input
value="Disabled input"
onChange={() => undefined}
disabled
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
}
// Form example
const FormExampleDemo = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
age: '',
website: '',
})
const [errors, setErrors] = useState({
email: false,
age: false,
})
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">User Profile</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Name</label>
<Input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter your name..."
showClearIcon
onClear={() => setFormData({ ...formData, name: '' })}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Email</label>
<Input
type="email"
value={formData.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value })
setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false })
}}
placeholder="Enter your email..."
destructive={errors.email}
showClearIcon
onClear={() => {
setFormData({ ...formData, email: '' })
setErrors({ ...errors, email: false })
}}
/>
{errors.email && (
<span className="text-xs text-red-600">Please enter a valid email address</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Age</label>
<Input
type="number"
value={formData.age}
onChange={(e) => {
setFormData({ ...formData, age: e.target.value })
setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false })
}}
placeholder="Enter your age..."
destructive={errors.age}
unit="years"
/>
{errors.age && (
<span className="text-xs text-red-600">Must be 18 or older</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Website</label>
<Input
type="url"
value={formData.website}
onChange={e => setFormData({ ...formData, website: e.target.value })}
placeholder="https://example.com"
showClearIcon
onClear={() => setFormData({ ...formData, website: '' })}
/>
</div>
</div>
</div>
)
}
export const FormExample: Story = {
render: () => <FormExampleDemo />,
}
// Search example
const SearchExampleDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '400px' }} className="flex flex-col gap-4">
<Input
size="large"
showLeftIcon
showClearIcon
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search fruits..."
/>
{searchQuery && (
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs text-gray-500">
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
<div key={item} className="text-sm text-gray-700">
{item}
</div>
))}
</div>
</div>
)}
</div>
)
}
export const SearchExample: Story = {
render: () => <SearchExampleDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'text',
placeholder: 'Type something...',
disabled: false,
destructive: false,
showLeftIcon: false,
showClearIcon: true,
unit: '',
},
}
@@ -0,0 +1,360 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
// Mock component to avoid complex initialization issues
const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => {
const [content, setContent] = useState(value || '')
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
onChange?.(e.target.value)
}
return (
<div className={wrapperClassName}>
<textarea
className={`w-full resize-none outline-none ${compact ? 'text-[13px] leading-5' : 'text-sm leading-6'} ${className}`}
value={content}
onChange={handleChange}
placeholder={placeholder}
disabled={!editable}
style={{ minHeight: '120px' }}
/>
</div>
)
}
const meta = {
title: 'Base/PromptEditor',
component: PromptEditorMock,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Editor content',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
editable: {
control: 'boolean',
description: 'Whether the editor is editable',
},
compact: {
control: 'boolean',
description: 'Compact mode with smaller text',
},
className: {
control: 'text',
description: 'CSS class for editor content',
},
wrapperClassName: {
control: 'text',
description: 'CSS class for editor wrapper',
},
},
} satisfies Meta<typeof PromptEditorMock>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const PromptEditorDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '600px' }}>
<div className="min-h-[120px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
{...args}
value={value}
onChange={(text: string) => {
setValue(text)
console.log('Content changed:', text)
}}
/>
</div>
{value && (
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<div className="mb-2 text-xs font-medium text-gray-600">Current Value:</div>
<div className="whitespace-pre-wrap font-mono text-sm text-gray-800">
{value}
</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
placeholder: 'Type / for commands...',
editable: true,
compact: false,
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'Write a summary about the following topic:\n\nPlease include key points and examples.',
placeholder: 'Type / for commands...',
editable: true,
},
}
// Compact mode
export const CompactMode: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'This is a compact editor with smaller text size.',
placeholder: 'Type / for commands...',
editable: true,
compact: true,
},
}
// Read-only mode
export const ReadOnlyMode: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.',
editable: false,
},
}
// With variables example
export const WithVariablesExample: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'Hello, please analyze the following data and provide insights.',
placeholder: 'Type / to insert variables...',
editable: true,
},
}
// Long content example
export const LongContent: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: `You are a helpful AI assistant. Your task is to provide accurate, helpful, and friendly responses.
Guidelines:
1. Be clear and concise
2. Provide examples when helpful
3. Ask clarifying questions if needed
4. Maintain a professional yet friendly tone
Please analyze the user's request and provide a comprehensive response.`,
placeholder: 'Enter your prompt...',
editable: true,
},
}
// Custom placeholder
export const CustomPlaceholder: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
placeholder: 'Describe the task you want the AI to perform... (Press / for variables)',
editable: true,
},
}
// Multiple editors
const MultipleEditorsDemo = () => {
const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.')
const [userPrompt, setUserPrompt] = useState('')
return (
<div style={{ width: '700px' }} className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">System Prompt</label>
<div className="min-h-[100px] rounded-lg border border-gray-300 bg-blue-50 p-4">
<PromptEditorMock
value={systemPrompt}
onChange={setSystemPrompt}
placeholder="Enter system instructions..."
editable={true}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">User Prompt</label>
<div className="min-h-[100px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
value={userPrompt}
onChange={setUserPrompt}
placeholder="Enter user message template..."
editable={true}
/>
</div>
</div>
{(systemPrompt || userPrompt) && (
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Combined Output:</div>
<div className="whitespace-pre-wrap text-sm text-gray-800">
{systemPrompt && (
<>
<strong>System:</strong> {systemPrompt}
{userPrompt && '\n\n'}
</>
)}
{userPrompt && (
<>
<strong>User:</strong> {userPrompt}
</>
)}
</div>
</div>
)}
</div>
)
}
export const MultipleEditors: Story = {
render: () => <MultipleEditorsDemo />,
}
// Real-world example - Email template
const EmailTemplateDemo = () => {
const [subject, setSubject] = useState('Welcome to our platform!')
const [body, setBody] = useState(`Hi,
Thank you for signing up! We're excited to have you on board.
To get started, please verify your email address by clicking the button below.
Best regards,
The Team`)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Email Template Editor</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Subject Line</label>
<div className="rounded-lg border border-gray-300 p-3">
<PromptEditorMock
value={subject}
onChange={setSubject}
placeholder="Enter email subject..."
compact={true}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Email Body</label>
<div className="min-h-[200px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
value={body}
onChange={setBody}
placeholder="Type your email content... Use / to insert variables"
/>
</div>
</div>
</div>
</div>
)
}
export const EmailTemplate: Story = {
render: () => <EmailTemplateDemo />,
}
// Real-world example - Chat prompt builder
const ChatPromptBuilderDemo = () => {
const [prompt, setPrompt] = useState(`Analyze the following conversation and provide insights:
1. Identify the main topics discussed
2. Detect the sentiment and tone
3. Summarize key points
4. Suggest follow-up questions`)
const [characterCount, setCharacterCount] = useState(prompt.length)
const handleChange = (text: string) => {
setPrompt(text)
setCharacterCount(text.length)
}
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Chat Prompt Builder</h3>
<span className="text-xs text-gray-500">{characterCount} characters</span>
</div>
<div className="min-h-[200px] rounded-lg border border-gray-300 bg-gray-50 p-4">
<PromptEditorMock
value={prompt}
onChange={handleChange}
placeholder="Design your chat prompt... Use / for templates"
/>
</div>
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
💡 <strong>Tip:</strong> Type <code className="rounded bg-blue-100 px-1 py-0.5">/</code> to insert variables or templates
</div>
</div>
)
}
export const ChatPromptBuilder: Story = {
render: () => <ChatPromptBuilderDemo />,
}
// Real-world example - API instruction editor
const APIInstructionEditorDemo = () => {
const [instructions, setInstructions] = useState(`Process the incoming API request and:
1. Validate all required fields are present
2. Transform the data according to the schema
3. Apply business logic rules
4. Return the formatted response`)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Processing Instructions</h3>
<div className="min-h-[180px] rounded-lg border-2 border-indigo-300 bg-indigo-50 p-4">
<PromptEditorMock
value={instructions}
onChange={setInstructions}
placeholder="Enter processing instructions..."
/>
</div>
<div className="mt-4 flex items-center gap-2">
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
Save Instructions
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Test
</button>
</div>
</div>
)
}
export const APIInstructionEditor: Story = {
render: () => <APIInstructionEditorDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: '',
placeholder: 'Type / for commands...',
editable: true,
compact: false,
},
}
@@ -2,6 +2,7 @@
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type {
EditorState,
} from 'lexical'
@@ -80,6 +81,29 @@ import {
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (value === undefined)
return
const incomingValue = value ?? ''
const shouldUpdate = editor.getEditorState().read(() => {
const currentText = $getRoot().getChildren().map(node => node.getTextContent()).join('\n')
return currentText !== incomingValue
})
if (!shouldUpdate)
return
const editorState = editor.parseEditorState(textToEditorState(incomingValue))
editor.setEditorState(editorState)
}, [editor, value])
return null
}
export type PromptEditorProps = {
instanceId?: string
compact?: boolean
@@ -293,6 +317,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
<VariableValueBlock />
)
}
<ValueSyncPlugin value={value} />
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
@@ -0,0 +1,504 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
import RadioCard from '.'
const meta = {
title: 'Base/RadioCard',
component: RadioCard,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
},
},
},
tags: ['autodocs'],
argTypes: {
icon: {
description: 'Icon element to display',
},
iconBgClassName: {
control: 'text',
description: 'Background color class for icon container',
},
title: {
control: 'text',
description: 'Card title',
},
description: {
control: 'text',
description: 'Card description',
},
isChosen: {
control: 'boolean',
description: 'Whether the card is selected',
},
noRadio: {
control: 'boolean',
description: 'Hide the radio button indicator',
},
},
} satisfies Meta<typeof RadioCard>
export default meta
type Story = StoryObj<typeof meta>
// Single card demo
const RadioCardDemo = (args: any) => {
const [isChosen, setIsChosen] = useState(args.isChosen || false)
return (
<div style={{ width: '400px' }}>
<RadioCard
{...args}
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: false,
noRadio: false,
},
}
// Selected state
export const Selected: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: true,
noRadio: false,
},
}
// Without radio indicator
export const NoRadio: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Information Card',
description: 'Card without radio indicator',
noRadio: true,
},
}
// With configuration panel
const WithConfigurationDemo = () => {
const [isChosen, setIsChosen] = useState(true)
return (
<div style={{ width: '400px' }}>
<RadioCard
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Database Storage"
description="Store data in a managed database"
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
chosenConfig={
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Region:</label>
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
<option>US East</option>
<option>EU West</option>
<option>Asia Pacific</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Size:</label>
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
<option>Small (10GB)</option>
<option>Medium (50GB)</option>
<option>Large (100GB)</option>
</select>
</div>
</div>
}
/>
</div>
)
}
export const WithConfiguration: Story = {
render: () => <WithConfigurationDemo />,
}
// Multiple cards selection
const MultipleCardsDemo = () => {
const [selected, setSelected] = useState('standard')
const options = [
{
value: 'standard',
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBg: 'bg-purple-100',
title: 'Standard',
description: 'Perfect for most use cases',
},
{
value: 'advanced',
icon: <RiCpuLine className="h-5 w-5 text-blue-600" />,
iconBg: 'bg-blue-100',
title: 'Advanced',
description: 'More features and customization',
},
{
value: 'enterprise',
icon: <RiShieldLine className="h-5 w-5 text-green-600" />,
iconBg: 'bg-green-100',
title: 'Enterprise',
description: 'Full features with premium support',
},
]
return (
<div style={{ width: '450px' }} className="space-y-3">
{options.map(option => (
<RadioCard
key={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
title={option.title}
description={option.description}
isChosen={selected === option.value}
onChosen={() => setSelected(option.value)}
/>
))}
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const MultipleCards: Story = {
render: () => <MultipleCardsDemo />,
}
// Real-world example - Cloud provider selection
const CloudProviderSelectionDemo = () => {
const [provider, setProvider] = useState('aws')
const [region, setRegion] = useState('us-east-1')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
<div className="space-y-3">
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-orange-600" />}
iconBgClassName="bg-orange-100"
title="Amazon Web Services"
description="Industry-leading cloud infrastructure"
isChosen={provider === 'aws'}
onChosen={() => setProvider('aws')}
chosenConfig={
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Region</label>
<select
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={region}
onChange={e => setRegion(e.target.value)}
>
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
</select>
</div>
}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Microsoft Azure"
description="Enterprise-grade cloud platform"
isChosen={provider === 'azure'}
onChosen={() => setProvider('azure')}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-red-600" />}
iconBgClassName="bg-red-100"
title="Google Cloud Platform"
description="Scalable and reliable infrastructure"
isChosen={provider === 'gcp'}
onChosen={() => setProvider('gcp')}
/>
</div>
</div>
)
}
export const CloudProviderSelection: Story = {
render: () => <CloudProviderSelectionDemo />,
}
// Real-world example - Deployment strategy
const DeploymentStrategyDemo = () => {
const [strategy, setStrategy] = useState('rolling')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
<div className="space-y-3">
<RadioCard
icon={<RiRocketLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Rolling Deployment"
description="Gradually replace instances with zero downtime"
isChosen={strategy === 'rolling'}
onChosen={() => setStrategy('rolling')}
chosenConfig={
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
Recommended for production environments<br />
Minimal risk with automatic rollback<br />
Takes 5-10 minutes
</div>
}
/>
<RadioCard
icon={<RiCpuLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Blue-Green Deployment"
description="Switch between two identical environments"
isChosen={strategy === 'blue-green'}
onChosen={() => setStrategy('blue-green')}
chosenConfig={
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
Instant rollback capability<br />
Requires double the resources<br />
Takes 2-5 minutes
</div>
}
/>
<RadioCard
icon={<RiLightbulbLine className="h-5 w-5 text-yellow-600" />}
iconBgClassName="bg-yellow-100"
title="Canary Deployment"
description="Test with a small subset of users first"
isChosen={strategy === 'canary'}
onChosen={() => setStrategy('canary')}
chosenConfig={
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
Test changes with real traffic<br />
Gradual rollout reduces risk<br />
Takes 15-30 minutes
</div>
}
/>
</div>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Deploy with {strategy} strategy
</button>
</div>
)
}
export const DeploymentStrategy: Story = {
render: () => <DeploymentStrategyDemo />,
}
// Real-world example - Storage options
const StorageOptionsDemo = () => {
const [storage, setStorage] = useState('ssd')
const storageOptions = [
{
value: 'ssd',
icon: <RiDatabase2Line className="h-5 w-5 text-purple-600" />,
iconBg: 'bg-purple-100',
title: 'SSD Storage',
description: 'Fast and reliable solid state drives',
price: '$0.10/GB/month',
speed: 'Up to 3000 IOPS',
},
{
value: 'hdd',
icon: <RiDatabase2Line className="h-5 w-5 text-gray-600" />,
iconBg: 'bg-gray-100',
title: 'HDD Storage',
description: 'Cost-effective magnetic disk storage',
price: '$0.05/GB/month',
speed: 'Up to 500 IOPS',
},
{
value: 'nvme',
icon: <RiDatabase2Line className="h-5 w-5 text-red-600" />,
iconBg: 'bg-red-100',
title: 'NVMe Storage',
description: 'Ultra-fast PCIe-based storage',
price: '$0.20/GB/month',
speed: 'Up to 10000 IOPS',
},
]
const selectedOption = storageOptions.find(opt => opt.value === storage)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
<div className="space-y-3">
{storageOptions.map(option => (
<RadioCard
key={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
title={
<div className="flex items-center justify-between">
<span>{option.title}</span>
<span className="text-xs font-normal text-gray-500">{option.price}</span>
</div>
}
description={`${option.description} - ${option.speed}`}
isChosen={storage === option.value}
onChosen={() => setStorage(option.value)}
/>
))}
</div>
{selectedOption && (
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="text-sm text-gray-700">
<strong>Selected:</strong> {selectedOption.title}
</div>
<div className="mt-1 text-xs text-gray-500">
{selectedOption.price} {selectedOption.speed}
</div>
</div>
)}
</div>
)
}
export const StorageOptions: Story = {
render: () => <StorageOptionsDemo />,
}
// Real-world example - API authentication method
const APIAuthMethodDemo = () => {
const [authMethod, setAuthMethod] = useState('api_key')
const [apiKey, setApiKey] = useState('')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
<div className="space-y-3">
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="API Key"
description="Simple authentication using a secret key"
isChosen={authMethod === 'api_key'}
onChosen={() => setAuthMethod('api_key')}
chosenConfig={
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Your API Key</label>
<input
type="password"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="sk-..."
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
</div>
}
/>
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="OAuth 2.0"
description="Industry-standard authorization protocol"
isChosen={authMethod === 'oauth'}
onChosen={() => setAuthMethod('oauth')}
chosenConfig={
<div className="rounded-lg bg-green-50 p-3">
<p className="mb-2 text-xs text-gray-700">
Configure OAuth 2.0 authentication for secure access
</p>
<button className="text-xs font-medium text-green-600 hover:underline">
Configure OAuth Settings
</button>
</div>
}
/>
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="JWT Token"
description="JSON Web Token based authentication"
isChosen={authMethod === 'jwt'}
onChosen={() => setAuthMethod('jwt')}
chosenConfig={
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
JWT tokens provide stateless authentication with expiration and refresh capabilities
</div>
}
/>
</div>
</div>
)
}
export const APIAuthMethod: Story = {
render: () => <APIAuthMethodDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [selected, setSelected] = useState('option1')
return (
<div style={{ width: '450px' }} className="space-y-3">
<RadioCard
icon={<RiRocketLine className="h-5 w-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="Option 1"
description="First option with icon and description"
isChosen={selected === 'option1'}
onChosen={() => setSelected('option1')}
/>
<RadioCard
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Option 2"
description="Second option with different styling"
isChosen={selected === 'option2'}
onChosen={() => setSelected('option2')}
chosenConfig={
<div className="rounded bg-blue-50 p-2 text-xs text-gray-600">
Additional configuration appears when selected
</div>
}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Option 3"
description="Third option to demonstrate selection"
isChosen={selected === 'option3'}
onChosen={() => setSelected('option3')}
/>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}
@@ -0,0 +1,421 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Radio from '.'
const meta = {
title: 'Base/Radio',
component: Radio,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
},
},
},
tags: ['autodocs'],
argTypes: {
checked: {
control: 'boolean',
description: 'Checked state (for standalone radio)',
},
value: {
control: 'text',
description: 'Value of the radio option',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
children: {
control: 'text',
description: 'Label content',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
// Single radio demo
const SingleRadioDemo = (args: any) => {
const [checked, setChecked] = useState(args.checked || false)
return (
<div style={{ width: '300px' }}>
<Radio
{...args}
checked={checked}
onChange={() => setChecked(!checked)}
>
{args.children || 'Radio option'}
</Radio>
</div>
)
}
// Default single radio
export const Default: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: false,
children: 'Single radio option',
},
}
// Checked state
export const Checked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: false,
children: 'Selected option',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: true,
children: 'Disabled option',
},
}
// Disabled and checked
export const DisabledChecked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: true,
children: 'Disabled selected option',
},
}
// Radio Group - Basic
const RadioGroupDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const RadioGroup: Story = {
render: () => <RadioGroupDemo />,
}
// Radio Group - With descriptions
const RadioGroupWithDescriptionsDemo = () => {
const [value, setValue] = useState('basic')
return (
<div style={{ width: '500px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
<Radio.Group value={value} onChange={setValue}>
<Radio value="basic">
<div>
<div className="font-medium">Basic Plan</div>
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
</div>
</Radio>
<Radio value="pro">
<div>
<div className="font-medium">Pro Plan</div>
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
</div>
</Radio>
<Radio value="enterprise">
<div>
<div className="font-medium">Enterprise Plan</div>
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
</div>
</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDescriptions: Story = {
render: () => <RadioGroupWithDescriptionsDemo />,
}
// Radio Group - With disabled option
const RadioGroupWithDisabledDemo = () => {
const [value, setValue] = useState('available')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="available">Available option</Radio>
<Radio value="disabled" disabled>Disabled option</Radio>
<Radio value="another">Another available option</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDisabled: Story = {
render: () => <RadioGroupWithDisabledDemo />,
}
// Radio Group - Vertical layout
const VerticalLayoutDemo = () => {
const [value, setValue] = useState('email')
return (
<div style={{ width: '400px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
<Radio value="email">Email notifications</Radio>
<Radio value="sms">SMS notifications</Radio>
<Radio value="push">Push notifications</Radio>
<Radio value="none">No notifications</Radio>
</Radio.Group>
</div>
)
}
export const VerticalLayout: Story = {
render: () => <VerticalLayoutDemo />,
}
// Real-world example - Settings panel
const SettingsPanelDemo = () => {
const [theme, setTheme] = useState('light')
const [language, setLanguage] = useState('en')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
<Radio value="light">Light mode</Radio>
<Radio value="dark">Dark mode</Radio>
<Radio value="auto">Auto (system preference)</Radio>
</Radio.Group>
</div>
<div className="border-t border-gray-200 pt-6">
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
<Radio value="en">English</Radio>
<Radio value="zh"> (Chinese)</Radio>
<Radio value="es">Español (Spanish)</Radio>
<Radio value="fr">Français (French)</Radio>
</Radio.Group>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-3">
<div className="text-xs text-gray-600">
<strong>Current settings:</strong> Theme: {theme}, Language: {language}
</div>
</div>
</div>
)
}
export const SettingsPanel: Story = {
render: () => <SettingsPanelDemo />,
}
// Real-world example - Payment method selector
const PaymentMethodSelectorDemo = () => {
const [paymentMethod, setPaymentMethod] = useState('credit_card')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
<Radio value="credit_card">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Credit Card</div>
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
</div>
<div className="text-xs text-gray-400">💳</div>
</div>
</Radio>
<Radio value="paypal">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">PayPal</div>
<div className="text-xs text-gray-500">Fast and secure</div>
</div>
<div className="text-xs text-gray-400">🅿</div>
</div>
</Radio>
<Radio value="bank_transfer">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Bank Transfer</div>
<div className="text-xs text-gray-500">1-3 business days</div>
</div>
<div className="text-xs text-gray-400">🏦</div>
</div>
</Radio>
</Radio.Group>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Continue with {paymentMethod.replace('_', ' ')}
</button>
</div>
)
}
export const PaymentMethodSelector: Story = {
render: () => <PaymentMethodSelectorDemo />,
}
// Real-world example - Shipping options
const ShippingOptionsDemo = () => {
const [shipping, setShipping] = useState('standard')
const shippingCosts = {
standard: 5.99,
express: 14.99,
overnight: 29.99,
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
<Radio value="standard">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Standard Shipping</div>
<div className="text-xs text-gray-500">5-7 business days</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.standard}</div>
</div>
</Radio>
<Radio value="express">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Express Shipping</div>
<div className="text-xs text-gray-500">2-3 business days</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.express}</div>
</div>
</Radio>
<Radio value="overnight">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Overnight Shipping</div>
<div className="text-xs text-gray-500">Next business day</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.overnight}</div>
</div>
</Radio>
</Radio.Group>
<div className="mt-6 border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Shipping cost:</span>
<span className="text-lg font-semibold text-gray-900">
${shippingCosts[shipping as keyof typeof shippingCosts]}
</span>
</div>
</div>
</div>
)
}
export const ShippingOptions: Story = {
render: () => <ShippingOptionsDemo />,
}
// Real-world example - Survey question
const SurveyQuestionDemo = () => {
const [satisfaction, setSatisfaction] = useState('')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
<Radio value="very_satisfied">
<div className="flex items-center gap-2">
<span>😄</span>
<span>Very satisfied</span>
</div>
</Radio>
<Radio value="satisfied">
<div className="flex items-center gap-2">
<span>🙂</span>
<span>Satisfied</span>
</div>
</Radio>
<Radio value="neutral">
<div className="flex items-center gap-2">
<span>😐</span>
<span>Neutral</span>
</div>
</Radio>
<Radio value="dissatisfied">
<div className="flex items-center gap-2">
<span>😟</span>
<span>Dissatisfied</span>
</div>
</Radio>
<Radio value="very_dissatisfied">
<div className="flex items-center gap-2">
<span>😢</span>
<span>Very dissatisfied</span>
</div>
</Radio>
</Radio.Group>
<button
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!satisfaction}
>
Submit Feedback
</button>
</div>
)
}
export const SurveyQuestion: Story = {
render: () => <SurveyQuestionDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
<Radio value="option4" disabled>Disabled option</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}
@@ -0,0 +1,435 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import SearchInput from '.'
const meta = {
title: 'Base/SearchInput',
component: SearchInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Search input component with search icon, clear button, and IME composition support for Asian languages.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Search input value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
white: {
control: 'boolean',
description: 'White background variant',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
},
} satisfies Meta<typeof SearchInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SearchInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '400px' }}>
<SearchInput
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Search value changed:', v)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Searching for: <span className="font-semibold">{value}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search...',
white: false,
},
}
// White variant
export const WhiteBackground: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search...',
white: true,
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
value: 'Initial search query',
placeholder: 'Search...',
white: false,
},
}
// Custom placeholder
export const CustomPlaceholder: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search documents, files, and more...',
white: false,
},
}
// Real-world example - User list search
const UserListSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const users = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'User' },
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Editor' },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'User' },
]
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchQuery.toLowerCase())
|| user.email.toLowerCase().includes(searchQuery.toLowerCase())
|| user.role.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Team Members</h3>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by name, email, or role..."
/>
<div className="mt-4 space-y-2">
{filteredUsers.length > 0 ? (
filteredUsers.map(user => (
<div
key={user.id}
className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{user.name}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700">
{user.role}
</span>
</div>
</div>
))
) : (
<div className="py-8 text-center text-sm text-gray-500">
No users found matching "{searchQuery}"
</div>
)}
</div>
<div className="mt-4 text-xs text-gray-500">
Showing {filteredUsers.length} of {users.length} members
</div>
</div>
)
}
export const UserListSearch: Story = {
render: () => <UserListSearchDemo />,
}
// Real-world example - Product search
const ProductSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const products = [
{ id: 1, name: 'Laptop Pro 15"', category: 'Electronics', price: 1299 },
{ id: 2, name: 'Wireless Mouse', category: 'Accessories', price: 29 },
{ id: 3, name: 'Mechanical Keyboard', category: 'Accessories', price: 89 },
{ id: 4, name: 'Monitor 27" 4K', category: 'Electronics', price: 499 },
{ id: 5, name: 'USB-C Hub', category: 'Accessories', price: 49 },
{ id: 6, name: 'Laptop Stand', category: 'Accessories', price: 39 },
]
const filteredProducts = products.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|| product.category.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search products..."
white
/>
<div className="mt-4 grid grid-cols-2 gap-3">
{filteredProducts.length > 0 ? (
filteredProducts.map(product => (
<div
key={product.id}
className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md"
>
<div className="mb-1 text-sm font-medium">{product.name}</div>
<div className="mb-2 text-xs text-gray-500">{product.category}</div>
<div className="text-lg font-semibold text-blue-600">${product.price}</div>
</div>
))
) : (
<div className="col-span-2 py-8 text-center text-sm text-gray-500">
No products found
</div>
)}
</div>
</div>
)
}
export const ProductSearch: Story = {
render: () => <ProductSearchDemo />,
}
// Real-world example - Documentation search
const DocumentationSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const docs = [
{ id: 1, title: 'Getting Started', category: 'Introduction', excerpt: 'Learn the basics of our platform' },
{ id: 2, title: 'API Reference', category: 'Developers', excerpt: 'Complete API documentation and examples' },
{ id: 3, title: 'Authentication Guide', category: 'Security', excerpt: 'Set up OAuth and API key authentication' },
{ id: 4, title: 'Best Practices', category: 'Guides', excerpt: 'Tips for optimal performance and security' },
{ id: 5, title: 'Troubleshooting', category: 'Support', excerpt: 'Common issues and their solutions' },
]
const filteredDocs = docs.filter(doc =>
doc.title.toLowerCase().includes(searchQuery.toLowerCase())
|| doc.category.toLowerCase().includes(searchQuery.toLowerCase())
|| doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '700px' }} className="rounded-lg bg-gray-50 p-6">
<h3 className="mb-2 text-xl font-bold">Documentation</h3>
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search documentation..."
white
className="!h-10"
/>
<div className="mt-4 space-y-3">
{filteredDocs.length > 0 ? (
filteredDocs.map(doc => (
<div
key={doc.id}
className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
>
<div className="mb-2 flex items-start justify-between">
<h4 className="text-base font-semibold">{doc.title}</h4>
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600">
{doc.category}
</span>
</div>
<p className="text-sm text-gray-600">{doc.excerpt}</p>
</div>
))
) : (
<div className="py-12 text-center">
<div className="mb-2 text-4xl">🔍</div>
<div className="text-sm text-gray-500">
No documentation found for "{searchQuery}"
</div>
</div>
)}
</div>
</div>
)
}
export const DocumentationSearch: Story = {
render: () => <DocumentationSearchDemo />,
}
// Real-world example - Command palette
const CommandPaletteDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const commands = [
{ id: 1, name: 'Create new document', icon: '📄', shortcut: '⌘N' },
{ id: 2, name: 'Open settings', icon: '⚙️', shortcut: '⌘,' },
{ id: 3, name: 'Search everywhere', icon: '🔍', shortcut: '⌘K' },
{ id: 4, name: 'Toggle sidebar', icon: '📁', shortcut: '⌘B' },
{ id: 5, name: 'Save changes', icon: '💾', shortcut: '⌘S' },
{ id: 6, name: 'Undo last action', icon: '↩️', shortcut: '⌘Z' },
{ id: 7, name: 'Redo last action', icon: '↪️', shortcut: '⌘⇧Z' },
]
const filteredCommands = commands.filter(cmd =>
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '600px' }} className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg">
<div className="border-b border-gray-200 p-4">
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Type a command or search..."
white
className="!h-10"
/>
</div>
<div className="max-h-[400px] overflow-y-auto">
{filteredCommands.length > 0 ? (
filteredCommands.map(cmd => (
<div
key={cmd.id}
className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center gap-3">
<span className="text-xl">{cmd.icon}</span>
<span className="text-sm">{cmd.name}</span>
</div>
<kbd className="rounded bg-gray-200 px-2 py-1 font-mono text-xs">
{cmd.shortcut}
</kbd>
</div>
))
) : (
<div className="py-8 text-center text-sm text-gray-500">
No commands found
</div>
)}
</div>
</div>
)
}
export const CommandPalette: Story = {
render: () => <CommandPaletteDemo />,
}
// Real-world example - Live search with results count
const LiveSearchWithCountDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const items = [
'React Documentation',
'React Hooks',
'React Router',
'Redux Toolkit',
'TypeScript Guide',
'JavaScript Basics',
'CSS Grid Layout',
'Flexbox Tutorial',
'Node.js Express',
'MongoDB Guide',
]
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Learning Resources</h3>
{searchQuery && (
<span className="text-sm text-gray-500">
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
</span>
)}
</div>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search resources..."
/>
<div className="mt-4 space-y-2">
{filteredItems.map((item, index) => (
<div
key={index}
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
>
<div className="text-sm font-medium">{item}</div>
</div>
))}
</div>
</div>
)
}
export const LiveSearchWithCount: Story = {
render: () => <LiveSearchWithCountDemo />,
}
// Size variations
const SizeVariationsDemo = () => {
const [value1, setValue1] = useState('')
const [value2, setValue2] = useState('')
const [value3, setValue3] = useState('')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." />
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
<SearchInput
value={value2}
onChange={setValue2}
placeholder="Search..."
className="!h-10"
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
<SearchInput
value={value3}
onChange={setValue3}
placeholder="Search..."
className="!h-12"
/>
</div>
</div>
)
}
export const SizeVariations: Story = {
render: () => <SizeVariationsDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
value: '',
placeholder: 'Search...',
white: false,
},
}
@@ -0,0 +1,527 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Select, { PortalSelect, SimpleSelect } from '.'
import type { Item } from '.'
const meta = {
title: 'Base/Select',
component: SimpleSelect,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
notClearable: {
control: 'boolean',
description: 'Hide clear button',
},
hideChecked: {
control: 'boolean',
description: 'Hide check icon on selected item',
},
},
} satisfies Meta<typeof SimpleSelect>
export default meta
type Story = StoryObj<typeof meta>
const fruits: Item[] = [
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana' },
{ value: 'cherry', name: 'Cherry' },
{ value: 'date', name: 'Date' },
{ value: 'elderberry', name: 'Elderberry' },
]
const countries: Item[] = [
{ value: 'us', name: 'United States' },
{ value: 'uk', name: 'United Kingdom' },
{ value: 'ca', name: 'Canada' },
{ value: 'au', name: 'Australia' },
{ value: 'de', name: 'Germany' },
{ value: 'fr', name: 'France' },
{ value: 'jp', name: 'Japan' },
{ value: 'cn', name: 'China' },
]
// SimpleSelect Demo
const SimpleSelectDemo = (args: any) => {
const [selected, setSelected] = useState(args.defaultValue || '')
return (
<div style={{ width: '300px' }}>
<SimpleSelect
{...args}
items={fruits}
defaultValue={selected}
onSelect={(item) => {
setSelected(item.value)
console.log('Selected:', item)
}}
/>
{selected && (
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
)}
</div>
)
}
// Default SimpleSelect
export const Default: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
},
}
// With placeholder (no selection)
export const WithPlaceholder: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Choose an option...',
defaultValue: '',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'banana',
disabled: true,
},
}
// Not clearable
export const NotClearable: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'cherry',
notClearable: true,
},
}
// Hide checked icon
export const HideChecked: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
hideChecked: true,
},
}
// Select with search
const WithSearchDemo = () => {
const [selected, setSelected] = useState('us')
return (
<div style={{ width: '300px' }}>
<Select
items={countries}
defaultValue={selected}
onSelect={(item) => {
setSelected(item.value as string)
console.log('Selected:', item)
}}
allowSearch={true}
/>
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const WithSearch: Story = {
render: () => <WithSearchDemo />,
}
// PortalSelect
const PortalSelectVariantDemo = () => {
const [selected, setSelected] = useState('apple')
return (
<div style={{ width: '300px' }}>
<PortalSelect
value={selected}
items={fruits}
onSelect={(item) => {
setSelected(item.value as string)
console.log('Selected:', item)
}}
placeholder="Select a fruit..."
/>
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const PortalSelectVariant: Story = {
render: () => <PortalSelectVariantDemo />,
}
// Custom render option
const CustomRenderOptionDemo = () => {
const [selected, setSelected] = useState('us')
const countriesWithFlags = [
{ value: 'us', name: 'United States', flag: '🇺🇸' },
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
]
return (
<div style={{ width: '300px' }}>
<SimpleSelect
items={countriesWithFlags}
defaultValue={selected}
onSelect={item => setSelected(item.value as string)}
renderOption={({ item, selected }) => (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{item.flag}</span>
<span>{item.name}</span>
</div>
{selected && <span className="text-blue-600"></span>}
</div>
)}
/>
</div>
)
}
export const CustomRenderOption: Story = {
render: () => <CustomRenderOptionDemo />,
}
// Loading state
export const LoadingState: Story = {
render: () => {
return (
<div style={{ width: '300px' }}>
<SimpleSelect
items={[]}
defaultValue=""
onSelect={() => undefined}
placeholder="Loading options..."
isLoading={true}
/>
</div>
)
},
}
// Real-world example - Form field
const FormFieldDemo = () => {
const [formData, setFormData] = useState({
country: 'us',
language: 'en',
timezone: 'pst',
})
const languages = [
{ value: 'en', name: 'English' },
{ value: 'es', name: 'Spanish' },
{ value: 'fr', name: 'French' },
{ value: 'de', name: 'German' },
{ value: 'zh', name: 'Chinese' },
]
const timezones = [
{ value: 'pst', name: 'Pacific Time (PST)' },
{ value: 'mst', name: 'Mountain Time (MST)' },
{ value: 'cst', name: 'Central Time (CST)' },
{ value: 'est', name: 'Eastern Time (EST)' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
<SimpleSelect
items={countries}
defaultValue={formData.country}
onSelect={item => setFormData({ ...formData, country: item.value as string })}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
<SimpleSelect
items={languages}
defaultValue={formData.language}
onSelect={item => setFormData({ ...formData, language: item.value as string })}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
<SimpleSelect
items={timezones}
defaultValue={formData.timezone}
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
/>
</div>
</div>
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
<div><strong>Country:</strong> {formData.country}</div>
<div><strong>Language:</strong> {formData.language}</div>
<div><strong>Timezone:</strong> {formData.timezone}</div>
</div>
</div>
)
}
export const FormField: Story = {
render: () => <FormFieldDemo />,
}
// Real-world example - Filter selector
const FilterSelectorDemo = () => {
const [status, setStatus] = useState('all')
const [priority, setPriority] = useState('all')
const statusOptions = [
{ value: 'all', name: 'All Status' },
{ value: 'active', name: 'Active' },
{ value: 'pending', name: 'Pending' },
{ value: 'completed', name: 'Completed' },
{ value: 'cancelled', name: 'Cancelled' },
]
const priorityOptions = [
{ value: 'all', name: 'All Priorities' },
{ value: 'high', name: 'High Priority' },
{ value: 'medium', name: 'Medium Priority' },
{ value: 'low', name: 'Low Priority' },
]
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
<div className="mb-6 flex gap-4">
<div className="flex-1">
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
<SimpleSelect
items={statusOptions}
defaultValue={status}
onSelect={item => setStatus(item.value as string)}
notClearable
/>
</div>
<div className="flex-1">
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
<SimpleSelect
items={priorityOptions}
defaultValue={priority}
onSelect={item => setPriority(item.value as string)}
notClearable
/>
</div>
</div>
<div className="rounded-lg bg-blue-50 p-4 text-sm">
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
<div className="flex gap-2">
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
Status: {status}
</span>
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
Priority: {priority}
</span>
</div>
</div>
</div>
)
}
export const FilterSelector: Story = {
render: () => <FilterSelectorDemo />,
}
// Real-world example - Version selector with badge
const VersionSelectorDemo = () => {
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
const versions = [
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
{ value: '2.0.5', name: 'v2.0.5' },
{ value: '2.0.4', name: 'v2.0.4' },
{ value: '1.9.8', name: 'v1.9.8' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
<PortalSelect
value={selectedVersion}
items={versions}
onSelect={item => setSelectedVersion(item.value as string)}
installedValue="2.0.5"
placeholder="Choose version..."
/>
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
{selectedVersion !== '2.0.5' && (
<div className="mb-2 text-yellow-600">
Version change detected
</div>
)}
<div>Current: <strong>{selectedVersion}</strong></div>
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
</div>
</div>
)
}
export const VersionSelector: Story = {
render: () => <VersionSelectorDemo />,
}
// Real-world example - Settings dropdown
const SettingsDropdownDemo = () => {
const [theme, setTheme] = useState('light')
const [fontSize, setFontSize] = useState('medium')
const themeOptions = [
{ value: 'light', name: '☀️ Light Mode' },
{ value: 'dark', name: '🌙 Dark Mode' },
{ value: 'auto', name: '🔄 Auto (System)' },
]
const fontSizeOptions = [
{ value: 'small', name: 'Small (12px)' },
{ value: 'medium', name: 'Medium (14px)' },
{ value: 'large', name: 'Large (16px)' },
{ value: 'xlarge', name: 'Extra Large (18px)' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
<SimpleSelect
items={themeOptions}
defaultValue={theme}
onSelect={item => setTheme(item.value as string)}
notClearable
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
<SimpleSelect
items={fontSizeOptions}
defaultValue={fontSize}
onSelect={item => setFontSize(item.value as string)}
notClearable
/>
</div>
</div>
</div>
)
}
export const SettingsDropdown: Story = {
render: () => <SettingsDropdownDemo />,
}
// Comparison of variants
const VariantComparisonDemo = () => {
const [simple, setSimple] = useState('apple')
const [withSearch, setWithSearch] = useState('us')
const [portal, setPortal] = useState('banana')
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
<div className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
<div style={{ width: '300px' }}>
<SimpleSelect
items={fruits}
defaultValue={simple}
onSelect={item => setSimple(item.value as string)}
placeholder="Choose a fruit..."
/>
</div>
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
<div style={{ width: '300px' }}>
<Select
items={countries}
defaultValue={withSearch}
onSelect={item => setWithSearch(item.value as string)}
allowSearch={true}
/>
</div>
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
<div style={{ width: '300px' }}>
<PortalSelect
value={portal}
items={fruits}
onSelect={item => setPortal(item.value as string)}
placeholder="Choose a fruit..."
/>
</div>
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
</div>
</div>
</div>
)
}
export const VariantComparison: Story = {
render: () => <VariantComparisonDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [selected, setSelected] = useState('apple')
return (
<div style={{ width: '350px' }}>
<SimpleSelect
items={fruits}
defaultValue={selected}
onSelect={item => setSelected(item.value as string)}
placeholder="Select an option..."
/>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}
@@ -0,0 +1,560 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current slider value',
},
min: {
control: 'number',
description: 'Minimum value (default: 0)',
},
max: {
control: 'number',
description: 'Maximum value (default: 100)',
},
step: {
control: 'number',
description: 'Step increment (default: 1)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SliderDemo = (args: any) => {
const [value, setValue] = useState(args.value || 50)
return (
<div style={{ width: '400px' }}>
<Slider
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Slider value:', v)
}}
/>
<div className="mt-4 text-center text-sm text-gray-600">
Value: <span className="text-lg font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}
// With custom range
export const CustomRange: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 25,
min: 0,
max: 50,
step: 1,
disabled: false,
},
}
// With step increment
export const WithStepIncrement: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 10,
disabled: false,
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 2.5,
min: 0,
max: 5,
step: 0.5,
disabled: false,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}
// Real-world example - Volume control
const VolumeControlDemo = () => {
const [volume, setVolume] = useState(70)
const getVolumeIcon = (vol: number) => {
if (vol === 0) return '🔇'
if (vol < 33) return '🔈'
if (vol < 66) return '🔉'
return '🔊'
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Volume Control</h3>
<span className="text-2xl">{getVolumeIcon(volume)}</span>
</div>
<Slider
value={volume}
min={0}
max={100}
step={1}
onChange={setVolume}
/>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>Mute</span>
<span className="text-lg font-semibold">{volume}%</span>
<span>Max</span>
</div>
</div>
)
}
export const VolumeControl: Story = {
render: () => <VolumeControlDemo />,
}
// Real-world example - Brightness control
const BrightnessControlDemo = () => {
const [brightness, setBrightness] = useState(80)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Screen Brightness</h3>
<span className="text-2xl"></span>
</div>
<Slider
value={brightness}
min={0}
max={100}
step={5}
onChange={setBrightness}
/>
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
<div className="text-sm text-gray-700">
Preview at {brightness}% brightness
</div>
</div>
</div>
)
}
export const BrightnessControl: Story = {
render: () => <BrightnessControlDemo />,
}
// Real-world example - Price range filter
const PriceRangeFilterDemo = () => {
const [maxPrice, setMaxPrice] = useState(500)
const minPrice = 0
const products = [
{ name: 'Product A', price: 150 },
{ name: 'Product B', price: 350 },
{ name: 'Product C', price: 600 },
{ name: 'Product D', price: 250 },
{ name: 'Product E', price: 450 },
]
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
<div className="mb-2">
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
<span>Maximum Price</span>
<span className="font-semibold text-gray-900">${maxPrice}</span>
</div>
<Slider
value={maxPrice}
min={0}
max={1000}
step={50}
onChange={setMaxPrice}
/>
</div>
<div className="mt-6">
<div className="mb-3 text-sm font-medium text-gray-700">
Showing {filteredProducts.length} of {products.length} products
</div>
<div className="space-y-2">
{filteredProducts.map(product => (
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<span className="text-sm">{product.name}</span>
<span className="font-semibold text-gray-900">${product.price}</span>
</div>
))}
</div>
</div>
</div>
)
}
export const PriceRangeFilter: Story = {
render: () => <PriceRangeFilterDemo />,
}
// Real-world example - Temperature selector
const TemperatureSelectorDemo = () => {
const [temperature, setTemperature] = useState(22)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
<div className="mb-6">
<Slider
value={temperature}
min={16}
max={30}
step={0.5}
onChange={setTemperature}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-blue-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Celsius</div>
<div className="text-3xl font-bold text-blue-600">{temperature}°C</div>
</div>
<div className="rounded-lg bg-orange-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
<div className="text-3xl font-bold text-orange-600">{fahrenheit}°F</div>
</div>
</div>
<div className="mt-4 text-center text-xs text-gray-500">
{temperature < 18 && '🥶 Too cold'}
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
{temperature > 24 && '🥵 Too warm'}
</div>
</div>
)
}
export const TemperatureSelector: Story = {
render: () => <TemperatureSelectorDemo />,
}
// Real-world example - Progress/completion slider
const ProgressSliderDemo = () => {
const [progress, setProgress] = useState(65)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
<Slider
value={progress}
min={0}
max={100}
step={5}
onChange={setProgress}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-lg font-bold text-blue-600">{progress}%</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
<span className="text-xs text-gray-500">25%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
<span className="text-xs text-gray-500">50%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
<span className="text-xs text-gray-500">75%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
<span className="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
</div>
)
}
export const ProgressSlider: Story = {
render: () => <ProgressSliderDemo />,
}
// Real-world example - Zoom control
const ZoomControlDemo = () => {
const [zoom, setZoom] = useState(100)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
<div className="flex items-center gap-4">
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.max(50, zoom - 10))}
>
-
</button>
<div className="flex-1">
<Slider
value={zoom}
min={50}
max={200}
step={10}
onChange={setZoom}
/>
</div>
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.min(200, zoom + 10))}
>
+
</button>
</div>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>50%</span>
<span className="text-lg font-semibold">{zoom}%</span>
<span>200%</span>
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
<div className="text-sm">Preview content</div>
</div>
</div>
)
}
export const ZoomControl: Story = {
render: () => <ZoomControlDemo />,
}
// Real-world example - AI model parameters
const AIModelParametersDemo = () => {
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [topP, setTopP] = useState(0.9)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
<div className="space-y-6">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Temperature</label>
<span className="text-sm font-semibold">{temperature}</span>
</div>
<Slider
value={temperature}
min={0}
max={2}
step={0.1}
onChange={setTemperature}
/>
<p className="mt-1 text-xs text-gray-500">
Controls randomness. Lower is more focused, higher is more creative.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
<span className="text-sm font-semibold">{maxTokens}</span>
</div>
<Slider
value={maxTokens}
min={100}
max={4000}
step={100}
onChange={setMaxTokens}
/>
<p className="mt-1 text-xs text-gray-500">
Maximum length of generated response.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Top P</label>
<span className="text-sm font-semibold">{topP}</span>
</div>
<Slider
value={topP}
min={0}
max={1}
step={0.05}
onChange={setTopP}
/>
<p className="mt-1 text-xs text-gray-500">
Nucleus sampling threshold.
</p>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
<div><strong>Temperature:</strong> {temperature}</div>
<div><strong>Max Tokens:</strong> {maxTokens}</div>
<div><strong>Top P:</strong> {topP}</div>
</div>
</div>
)
}
export const AIModelParameters: Story = {
render: () => <AIModelParametersDemo />,
}
// Real-world example - Image quality selector
const ImageQualitySelectorDemo = () => {
const [quality, setQuality] = useState(80)
const getQualityLabel = (q: number) => {
if (q < 50) return 'Low'
if (q < 70) return 'Medium'
if (q < 90) return 'High'
return 'Maximum'
}
const estimatedSize = Math.round((quality / 100) * 5)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
<Slider
value={quality}
min={10}
max={100}
step={10}
onChange={setQuality}
/>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">Quality</div>
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
<div className="text-xs text-gray-500">{quality}%</div>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">File Size</div>
<div className="text-lg font-semibold">~{estimatedSize} MB</div>
<div className="text-xs text-gray-500">Estimated</div>
</div>
</div>
</div>
)
}
export const ImageQualitySelector: Story = {
render: () => <ImageQualitySelectorDemo />,
}
// Multiple sliders
const MultipleSlidersDemo = () => {
const [red, setRed] = useState(128)
const [green, setGreen] = useState(128)
const [blue, setBlue] = useState(128)
const rgbColor = `rgb(${red}, ${green}, ${blue})`
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-red-600">Red</label>
<span className="text-sm font-semibold">{red}</span>
</div>
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-green-600">Green</label>
<span className="text-sm font-semibold">{green}</span>
</div>
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-blue-600">Blue</label>
<span className="text-sm font-semibold">{blue}</span>
</div>
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div
className="h-24 w-24 rounded-lg border-2 border-gray-300"
style={{ backgroundColor: rgbColor }}
/>
<div className="text-right">
<div className="mb-1 text-xs text-gray-600">Color Value</div>
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
<div className="mt-1 font-mono text-xs text-gray-500">
#{red.toString(16).padStart(2, '0')}
{green.toString(16).padStart(2, '0')}
{blue.toString(16).padStart(2, '0')}
</div>
</div>
</div>
</div>
)
}
export const MultipleSliders: Story = {
render: () => <MultipleSlidersDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}
@@ -0,0 +1,626 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Switch from '.'
const meta = {
title: 'Base/Switch',
component: Switch,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch component with multiple sizes (xs, sm, md, lg, l). Built on Headless UI Switch with smooth animations.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'l'],
description: 'Switch size',
},
defaultValue: {
control: 'boolean',
description: 'Default checked state',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
} satisfies Meta<typeof Switch>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SwitchDemo = (args: any) => {
const [enabled, setEnabled] = useState(args.defaultValue || false)
return (
<div style={{ width: '300px' }}>
<div className="flex items-center gap-3">
<Switch
{...args}
defaultValue={enabled}
onChange={(value) => {
setEnabled(value)
console.log('Switch toggled:', value)
}}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
</div>
)
}
// Default state (off)
export const Default: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: false,
},
}
// Default on
export const DefaultOn: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: true,
disabled: false,
},
}
// Disabled off
export const DisabledOff: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: true,
},
}
// Disabled on
export const DisabledOn: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: true,
disabled: true,
},
}
// Size variations
const SizeComparisonDemo = () => {
const [states, setStates] = useState({
xs: false,
sm: false,
md: true,
lg: true,
l: false,
})
return (
<div style={{ width: '400px' }} className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="xs" defaultValue={states.xs} onChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="sm" defaultValue={states.sm} onChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="md" defaultValue={states.md} onChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Medium (md)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="l" defaultValue={states.l} onChange={v => setStates({ ...states, l: v })} />
<span className="text-sm text-gray-700">Large (l)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="lg" defaultValue={states.lg} onChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Extra Large (lg)</span>
</div>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// With labels
const WithLabelsDemo = () => {
const [enabled, setEnabled] = useState(true)
return (
<div style={{ width: '400px' }}>
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
<div>
<div className="text-sm font-medium text-gray-900">Email Notifications</div>
<div className="text-xs text-gray-500">Receive email updates about your account</div>
</div>
<Switch
size="md"
defaultValue={enabled}
onChange={setEnabled}
/>
</div>
</div>
)
}
export const WithLabels: Story = {
render: () => <WithLabelsDemo />,
}
// Real-world example - Settings panel
const SettingsPanelDemo = () => {
const [settings, setSettings] = useState({
notifications: true,
autoSave: true,
darkMode: false,
analytics: false,
emailUpdates: true,
})
const updateSetting = (key: string, value: boolean) => {
setSettings({ ...settings, [key]: value })
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Application Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
<div className="text-xs text-gray-500">Receive push notifications on your device</div>
</div>
<Switch
size="md"
defaultValue={settings.notifications}
onChange={v => updateSetting('notifications', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Auto-Save</div>
<div className="text-xs text-gray-500">Automatically save changes as you work</div>
</div>
<Switch
size="md"
defaultValue={settings.autoSave}
onChange={v => updateSetting('autoSave', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Dark Mode</div>
<div className="text-xs text-gray-500">Use dark theme for the interface</div>
</div>
<Switch
size="md"
defaultValue={settings.darkMode}
onChange={v => updateSetting('darkMode', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Analytics</div>
<div className="text-xs text-gray-500">Help us improve by sharing usage data</div>
</div>
<Switch
size="md"
defaultValue={settings.analytics}
onChange={v => updateSetting('analytics', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Email Updates</div>
<div className="text-xs text-gray-500">Receive product updates via email</div>
</div>
<Switch
size="md"
defaultValue={settings.emailUpdates}
onChange={v => updateSetting('emailUpdates', v)}
/>
</div>
</div>
</div>
)
}
export const SettingsPanel: Story = {
render: () => <SettingsPanelDemo />,
}
// Real-world example - Privacy controls
const PrivacyControlsDemo = () => {
const [privacy, setPrivacy] = useState({
profilePublic: false,
showEmail: false,
allowMessages: true,
shareActivity: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Privacy Settings</h3>
<p className="mb-4 text-sm text-gray-600">Control who can see your information</p>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Public Profile</div>
<div className="text-xs text-gray-500">Make your profile visible to everyone</div>
</div>
<Switch
size="md"
defaultValue={privacy.profilePublic}
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Show Email Address</div>
<div className="text-xs text-gray-500">Display your email on your profile</div>
</div>
<Switch
size="md"
defaultValue={privacy.showEmail}
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Allow Direct Messages</div>
<div className="text-xs text-gray-500">Let others send you private messages</div>
</div>
<Switch
size="md"
defaultValue={privacy.allowMessages}
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Share Activity</div>
<div className="text-xs text-gray-500">Show your recent activity to connections</div>
</div>
<Switch
size="md"
defaultValue={privacy.shareActivity}
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
/>
</div>
</div>
</div>
)
}
export const PrivacyControls: Story = {
render: () => <PrivacyControlsDemo />,
}
// Real-world example - Feature toggles
const FeatureTogglesDemo = () => {
const [features, setFeatures] = useState({
betaFeatures: false,
experimentalUI: false,
advancedMode: true,
developerTools: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Feature Flags</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🧪</span>
<div>
<div className="text-sm font-medium text-gray-900">Beta Features</div>
<div className="text-xs text-gray-500">Access experimental functionality</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.betaFeatures}
onChange={v => setFeatures({ ...features, betaFeatures: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🎨</span>
<div>
<div className="text-sm font-medium text-gray-900">Experimental UI</div>
<div className="text-xs text-gray-500">Try the new interface design</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.experimentalUI}
onChange={v => setFeatures({ ...features, experimentalUI: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<div>
<div className="text-sm font-medium text-gray-900">Advanced Mode</div>
<div className="text-xs text-gray-500">Show advanced configuration options</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.advancedMode}
onChange={v => setFeatures({ ...features, advancedMode: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🔧</span>
<div>
<div className="text-sm font-medium text-gray-900">Developer Tools</div>
<div className="text-xs text-gray-500">Enable debugging and inspection tools</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.developerTools}
onChange={v => setFeatures({ ...features, developerTools: v })}
/>
</div>
</div>
</div>
)
}
export const FeatureToggles: Story = {
render: () => <FeatureTogglesDemo />,
}
// Real-world example - Notification preferences
const NotificationPreferencesDemo = () => {
const [notifications, setNotifications] = useState({
email: true,
push: true,
sms: false,
desktop: true,
})
const allEnabled = Object.values(notifications).every(v => v)
const someEnabled = Object.values(notifications).some(v => v)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Notification Channels</h3>
<div className="text-xs text-gray-500">
{allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">📧</span>
<div>
<div className="text-sm font-medium text-gray-900">Email</div>
<div className="text-xs text-gray-500">Receive notifications via email</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.email}
onChange={v => setNotifications({ ...notifications, email: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">🔔</span>
<div>
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
<div className="text-xs text-gray-500">Mobile and browser push notifications</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.push}
onChange={v => setNotifications({ ...notifications, push: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">💬</span>
<div>
<div className="text-sm font-medium text-gray-900">SMS Messages</div>
<div className="text-xs text-gray-500">Receive text message notifications</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.sms}
onChange={v => setNotifications({ ...notifications, sms: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">💻</span>
<div>
<div className="text-sm font-medium text-gray-900">Desktop Alerts</div>
<div className="text-xs text-gray-500">Show desktop notification popups</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.desktop}
onChange={v => setNotifications({ ...notifications, desktop: v })}
/>
</div>
</div>
</div>
)
}
export const NotificationPreferences: Story = {
render: () => <NotificationPreferencesDemo />,
}
// Real-world example - API access control
const APIAccessControlDemo = () => {
const [access, setAccess] = useState({
readAccess: true,
writeAccess: true,
deleteAccess: false,
adminAccess: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">API Permissions</h3>
<p className="mb-4 text-sm text-gray-600">Configure access levels for API key</p>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg bg-green-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-green-600"></span> Read Access
</div>
<div className="text-xs text-gray-500">View resources and data</div>
</div>
<Switch
size="md"
defaultValue={access.readAccess}
onChange={v => setAccess({ ...access, readAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-blue-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-blue-600"></span> Write Access
</div>
<div className="text-xs text-gray-500">Create and update resources</div>
</div>
<Switch
size="md"
defaultValue={access.writeAccess}
onChange={v => setAccess({ ...access, writeAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-red-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-red-600">🗑</span> Delete Access
</div>
<div className="text-xs text-gray-500">Remove resources permanently</div>
</div>
<Switch
size="md"
defaultValue={access.deleteAccess}
onChange={v => setAccess({ ...access, deleteAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-purple-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-purple-600"></span> Admin Access
</div>
<div className="text-xs text-gray-500">Full administrative privileges</div>
</div>
<Switch
size="md"
defaultValue={access.adminAccess}
onChange={v => setAccess({ ...access, adminAccess: v })}
/>
</div>
</div>
</div>
)
}
export const APIAccessControl: Story = {
render: () => <APIAccessControlDemo />,
}
// Compact list with switches
const CompactListDemo = () => {
const [items, setItems] = useState([
{ id: 1, name: 'Feature A', enabled: true },
{ id: 2, name: 'Feature B', enabled: false },
{ id: 3, name: 'Feature C', enabled: true },
{ id: 4, name: 'Feature D', enabled: false },
{ id: 5, name: 'Feature E', enabled: true },
])
const toggleItem = (id: number) => {
setItems(items.map(item =>
item.id === id ? { ...item, enabled: !item.enabled } : item,
))
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold">Quick Toggles</h3>
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className="flex items-center justify-between py-2">
<span className="text-sm text-gray-700">{item.name}</span>
<Switch
size="sm"
defaultValue={item.enabled}
onChange={() => toggleItem(item.id)}
/>
</div>
))}
</div>
</div>
)
}
export const CompactList: Story = {
render: () => <CompactListDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: false,
},
}

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