makersclaw 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -1
- package/THIRD-PARTY-NOTICES.md +180 -0
- package/package.json +15 -8
- package/r/annotate.json +62 -0
- package/r/approval-request.json +52 -0
- package/r/audio-player.json +74 -0
- package/r/auth-form.json +55 -0
- package/r/board.json +55 -0
- package/r/calendar-view.json +105 -0
- package/r/cart.json +62 -0
- package/r/chart.json +83 -0
- package/r/chat.json +95 -0
- package/r/code-editor.json +94 -0
- package/r/comment-box.json +64 -0
- package/r/contact-section.json +53 -0
- package/r/core.json +341 -0
- package/r/csv-import.json +63 -0
- package/r/data-table.json +69 -0
- package/r/diagram.json +74 -0
- package/r/doc-editor.json +82 -0
- package/r/doc-page.json +61 -0
- package/r/email-render.json +73 -0
- package/r/faq.json +50 -0
- package/r/file-upload.json +54 -0
- package/r/flow-canvas.json +93 -0
- package/r/footer.json +52 -0
- package/r/gantt.json +70 -0
- package/r/job-panel.json +56 -0
- package/r/json-editor.json +66 -0
- package/r/map.json +89 -0
- package/r/menubar.json +52 -0
- package/r/mind-map.json +67 -0
- package/r/navigation-menu.json +52 -0
- package/r/order-detail.json +54 -0
- package/r/org-chart.json +66 -0
- package/r/page-builder.json +105 -0
- package/r/payment-methods.json +51 -0
- package/r/pdf-document.json +79 -0
- package/r/pdf-viewer.json +88 -0
- package/r/product-detail.json +76 -0
- package/r/product-grid.json +65 -0
- package/r/record-form.json +67 -0
- package/r/record-notes.json +55 -0
- package/r/registry.json +3612 -0
- package/r/search-dialog.json +51 -0
- package/r/settings-list.json +66 -0
- package/r/setup-checklist.json +51 -0
- package/r/share-dialog.json +52 -0
- package/r/sidebar.json +69 -0
- package/r/signature-pad.json +72 -0
- package/r/sketch-canvas.json +99 -0
- package/r/spreadsheet.json +90 -0
- package/r/stat-cards.json +66 -0
- package/r/task-list.json +63 -0
- package/r/task-model.json +98 -0
- package/r/testimonials.json +52 -0
- package/r/transaction-list.json +53 -0
- package/r/whiteboard.json +66 -0
- package/registry/blocks/annotate/README.md +179 -0
- package/registry/blocks/annotate/annotate-data.js +250 -0
- package/registry/blocks/annotate/annotate.jsx +646 -0
- package/registry/blocks/annotate/block.json +10 -0
- package/registry/blocks/annotate/demo.jsx +135 -0
- package/registry/blocks/annotate/index.js +15 -0
- package/registry/blocks/approval-request/README.md +116 -0
- package/registry/blocks/approval-request/approval-request.jsx +386 -0
- package/registry/blocks/approval-request/block.json +10 -0
- package/registry/blocks/approval-request/demo.jsx +127 -0
- package/registry/blocks/approval-request/index.js +4 -0
- package/registry/blocks/audio-player/README.md +167 -0
- package/registry/blocks/audio-player/audio-player.jsx +623 -0
- package/registry/blocks/audio-player/block.json +14 -0
- package/registry/blocks/audio-player/colors.js +109 -0
- package/registry/blocks/audio-player/demo.jsx +209 -0
- package/registry/blocks/audio-player/index.js +6 -0
- package/registry/blocks/audio-player/time.js +67 -0
- package/registry/blocks/audio-player/wavesurfer.js +9 -0
- package/registry/blocks/auth-form/README.md +126 -0
- package/registry/blocks/auth-form/auth-form.jsx +568 -0
- package/registry/blocks/auth-form/block.json +10 -0
- package/registry/blocks/auth-form/demo.jsx +131 -0
- package/registry/blocks/auth-form/index.js +3 -0
- package/registry/blocks/board/README.md +64 -13
- package/registry/blocks/board/block.json +2 -2
- package/registry/blocks/board/board.jsx +237 -94
- package/registry/blocks/board/demo.jsx +103 -24
- package/registry/blocks/calendar-view/README.md +180 -0
- package/registry/blocks/calendar-view/agenda.jsx +147 -0
- package/registry/blocks/calendar-view/block.json +76 -0
- package/registry/blocks/calendar-view/calendar-model.js +442 -0
- package/registry/blocks/calendar-view/calendar.jsx +627 -0
- package/registry/blocks/calendar-view/demo.jsx +195 -0
- package/registry/blocks/calendar-view/draft.jsx +49 -0
- package/registry/blocks/calendar-view/index.js +39 -0
- package/registry/blocks/calendar-view/month.jsx +346 -0
- package/registry/blocks/calendar-view/paint.js +69 -0
- package/registry/blocks/calendar-view/time-grid.jsx +527 -0
- package/registry/blocks/calendar-view/tray.jsx +87 -0
- package/registry/blocks/cart/README.md +146 -0
- package/registry/blocks/cart/block.json +10 -0
- package/registry/blocks/cart/cart.jsx +597 -0
- package/registry/blocks/cart/demo.jsx +98 -0
- package/registry/blocks/cart/index.js +4 -0
- package/registry/blocks/cart/money.js +28 -0
- package/registry/blocks/chart/README.md +316 -0
- package/registry/blocks/chart/block.json +10 -0
- package/registry/blocks/chart/cartesian.jsx +642 -0
- package/registry/blocks/chart/core.jsx +262 -0
- package/registry/blocks/chart/demo.jsx +357 -0
- package/registry/blocks/chart/heatmap.jsx +336 -0
- package/registry/blocks/chart/index.js +8 -0
- package/registry/blocks/chart/pie.jsx +245 -0
- package/registry/blocks/chart/sparkline.jsx +88 -0
- package/registry/blocks/chat/README.md +273 -0
- package/registry/blocks/chat/attachment.jsx +95 -0
- package/registry/blocks/chat/block.json +10 -0
- package/registry/blocks/chat/chat.jsx +132 -0
- package/registry/blocks/chat/composer.jsx +364 -0
- package/registry/blocks/chat/demo.jsx +578 -0
- package/registry/blocks/chat/header.jsx +34 -0
- package/registry/blocks/chat/index.js +9 -0
- package/registry/blocks/chat/message.jsx +450 -0
- package/registry/blocks/chat/scroller.jsx +247 -0
- package/registry/blocks/chat/text.jsx +104 -0
- package/registry/blocks/code-editor/README.md +210 -0
- package/registry/blocks/code-editor/block.json +22 -0
- package/registry/blocks/code-editor/code-editor.jsx +534 -0
- package/registry/blocks/code-editor/codemirror.js +38 -0
- package/registry/blocks/code-editor/demo.jsx +205 -0
- package/registry/blocks/code-editor/index.js +8 -0
- package/registry/blocks/code-editor/json.js +317 -0
- package/registry/blocks/code-editor/sql.js +136 -0
- package/registry/blocks/code-editor/text.js +101 -0
- package/registry/blocks/code-editor/theme.js +186 -0
- package/registry/blocks/comment-box/README.md +123 -0
- package/registry/blocks/comment-box/block.json +10 -0
- package/registry/blocks/comment-box/comment-body.jsx +217 -0
- package/registry/blocks/comment-box/comment-box.jsx +198 -0
- package/registry/blocks/comment-box/comment-time.jsx +77 -0
- package/registry/blocks/comment-box/demo.jsx +141 -0
- package/registry/blocks/comment-box/index.js +8 -0
- package/registry/blocks/contact-section/README.md +152 -0
- package/registry/blocks/contact-section/block.json +11 -0
- package/registry/blocks/contact-section/contact-section.jsx +509 -0
- package/registry/blocks/contact-section/demo.jsx +93 -0
- package/registry/blocks/contact-section/index.js +4 -0
- package/registry/blocks/csv-import/README.md +133 -0
- package/registry/blocks/csv-import/block.json +13 -0
- package/registry/blocks/csv-import/csv-import.jsx +479 -0
- package/registry/blocks/csv-import/demo.jsx +106 -0
- package/registry/blocks/csv-import/import.js +200 -0
- package/registry/blocks/csv-import/index.js +7 -0
- package/registry/blocks/data-table/README.md +386 -0
- package/registry/blocks/data-table/block.json +10 -0
- package/registry/blocks/data-table/data-table.jsx +1164 -0
- package/registry/blocks/data-table/demo.jsx +588 -0
- package/registry/blocks/data-table/index.js +5 -0
- package/registry/blocks/data-table/model.js +169 -0
- package/registry/blocks/diagram/README.md +108 -0
- package/registry/blocks/diagram/block.json +13 -0
- package/registry/blocks/diagram/demo.jsx +113 -0
- package/registry/blocks/diagram/diagram.jsx +481 -0
- package/registry/blocks/diagram/index.js +7 -0
- package/registry/blocks/diagram/mermaid.js +52 -0
- package/registry/blocks/diagram/palette.js +313 -0
- package/registry/blocks/diagram/source.js +98 -0
- package/registry/blocks/doc-editor/README.md +215 -0
- package/registry/blocks/doc-editor/block.json +15 -0
- package/registry/blocks/doc-editor/demo.jsx +225 -0
- package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
- package/registry/blocks/doc-editor/doc.js +309 -0
- package/registry/blocks/doc-editor/index.js +7 -0
- package/registry/blocks/doc-editor/schema.jsx +56 -0
- package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
- package/registry/blocks/doc-editor/theme.js +82 -0
- package/registry/blocks/doc-page/README.md +119 -0
- package/registry/blocks/doc-page/autosave.js +104 -0
- package/registry/blocks/doc-page/block.json +10 -0
- package/registry/blocks/doc-page/demo.jsx +137 -0
- package/registry/blocks/doc-page/doc-page.jsx +276 -0
- package/registry/blocks/doc-page/index.js +4 -0
- package/registry/blocks/email-render/README.md +151 -0
- package/registry/blocks/email-render/block.json +14 -0
- package/registry/blocks/email-render/demo.jsx +153 -0
- package/registry/blocks/email-render/doc-email.jsx +210 -0
- package/registry/blocks/email-render/email.jsx +221 -0
- package/registry/blocks/email-render/index.js +10 -0
- package/registry/blocks/email-render/render.js +50 -0
- package/registry/blocks/email-render/theme.js +215 -0
- package/registry/blocks/faq/README.md +101 -0
- package/registry/blocks/faq/block.json +11 -0
- package/registry/blocks/faq/demo.jsx +89 -0
- package/registry/blocks/faq/faq.jsx +220 -0
- package/registry/blocks/faq/index.js +4 -0
- package/registry/blocks/file-upload/README.md +138 -0
- package/registry/blocks/file-upload/block.json +10 -0
- package/registry/blocks/file-upload/demo.jsx +204 -0
- package/registry/blocks/file-upload/file-upload.jsx +635 -0
- package/registry/blocks/file-upload/index.js +4 -0
- package/registry/blocks/flow-canvas/README.md +420 -0
- package/registry/blocks/flow-canvas/block.json +13 -0
- package/registry/blocks/flow-canvas/board.js +819 -0
- package/registry/blocks/flow-canvas/chrome.jsx +649 -0
- package/registry/blocks/flow-canvas/context.jsx +35 -0
- package/registry/blocks/flow-canvas/demo.jsx +249 -0
- package/registry/blocks/flow-canvas/edges.jsx +107 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
- package/registry/blocks/flow-canvas/index.js +41 -0
- package/registry/blocks/flow-canvas/nodes.jsx +392 -0
- package/registry/blocks/flow-canvas/theme.js +174 -0
- package/registry/blocks/footer/README.md +133 -0
- package/registry/blocks/footer/block.json +11 -0
- package/registry/blocks/footer/demo.jsx +119 -0
- package/registry/blocks/footer/footer.jsx +406 -0
- package/registry/blocks/footer/index.js +4 -0
- package/registry/blocks/gantt/README.md +220 -0
- package/registry/blocks/gantt/block.json +10 -0
- package/registry/blocks/gantt/demo.jsx +148 -0
- package/registry/blocks/gantt/gantt.jsx +923 -0
- package/registry/blocks/gantt/index.js +7 -0
- package/registry/blocks/gantt/rows.jsx +468 -0
- package/registry/blocks/gantt/schedule.js +278 -0
- package/registry/blocks/job-panel/README.md +86 -2
- package/registry/blocks/job-panel/block.json +3 -3
- package/registry/blocks/job-panel/demo.jsx +230 -3
- package/registry/blocks/job-panel/job-panel.jsx +169 -3
- package/registry/blocks/json-editor/README.md +133 -0
- package/registry/blocks/json-editor/block.json +10 -0
- package/registry/blocks/json-editor/demo.jsx +92 -0
- package/registry/blocks/json-editor/index.js +7 -0
- package/registry/blocks/json-editor/json-editor.jsx +305 -0
- package/registry/blocks/json-editor/json-tree.jsx +393 -0
- package/registry/blocks/json-editor/tree.js +225 -0
- package/registry/blocks/map/README.md +221 -0
- package/registry/blocks/map/block.json +14 -0
- package/registry/blocks/map/demo.jsx +201 -0
- package/registry/blocks/map/geo.js +198 -0
- package/registry/blocks/map/index.js +8 -0
- package/registry/blocks/map/list.jsx +128 -0
- package/registry/blocks/map/map-view.jsx +715 -0
- package/registry/blocks/map/maplibre.js +30 -0
- package/registry/blocks/map/marks.jsx +146 -0
- package/registry/blocks/map/theme.js +142 -0
- package/registry/blocks/menubar/README.md +109 -0
- package/registry/blocks/menubar/block.json +10 -0
- package/registry/blocks/menubar/demo.jsx +128 -0
- package/registry/blocks/menubar/index.js +4 -0
- package/registry/blocks/menubar/menubar.jsx +199 -0
- package/registry/blocks/mind-map/README.md +206 -0
- package/registry/blocks/mind-map/block.json +10 -0
- package/registry/blocks/mind-map/demo.jsx +103 -0
- package/registry/blocks/mind-map/index.js +31 -0
- package/registry/blocks/mind-map/mind-map.jsx +1125 -0
- package/registry/blocks/mind-map/topic.jsx +143 -0
- package/registry/blocks/mind-map/tree.js +645 -0
- package/registry/blocks/navigation-menu/README.md +124 -0
- package/registry/blocks/navigation-menu/block.json +10 -0
- package/registry/blocks/navigation-menu/demo.jsx +84 -0
- package/registry/blocks/navigation-menu/index.js +4 -0
- package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
- package/registry/blocks/order-detail/README.md +109 -0
- package/registry/blocks/order-detail/block.json +10 -0
- package/registry/blocks/order-detail/demo.jsx +88 -0
- package/registry/blocks/order-detail/index.js +4 -0
- package/registry/blocks/order-detail/order-detail.jsx +354 -0
- package/registry/blocks/org-chart/README.md +145 -0
- package/registry/blocks/org-chart/block.json +10 -0
- package/registry/blocks/org-chart/demo.jsx +94 -0
- package/registry/blocks/org-chart/index.js +7 -0
- package/registry/blocks/org-chart/org-chart.jsx +631 -0
- package/registry/blocks/org-chart/org.js +245 -0
- package/registry/blocks/org-chart/person.jsx +96 -0
- package/registry/blocks/page-builder/README.md +257 -0
- package/registry/blocks/page-builder/block.json +14 -0
- package/registry/blocks/page-builder/demo.jsx +145 -0
- package/registry/blocks/page-builder/grapes.js +18 -0
- package/registry/blocks/page-builder/index.js +11 -0
- package/registry/blocks/page-builder/page-builder.jsx +704 -0
- package/registry/blocks/page-builder/page-view.jsx +33 -0
- package/registry/blocks/page-builder/page.js +118 -0
- package/registry/blocks/page-builder/panels.jsx +588 -0
- package/registry/blocks/page-builder/sections.js +209 -0
- package/registry/blocks/page-builder/theme.js +87 -0
- package/registry/blocks/page-builder/thumbs.jsx +189 -0
- package/registry/blocks/page-builder/tokens.js +329 -0
- package/registry/blocks/payment-methods/README.md +104 -0
- package/registry/blocks/payment-methods/block.json +10 -0
- package/registry/blocks/payment-methods/demo.jsx +71 -0
- package/registry/blocks/payment-methods/index.js +4 -0
- package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
- package/registry/blocks/pdf-document/README.md +213 -0
- package/registry/blocks/pdf-document/block.json +13 -0
- package/registry/blocks/pdf-document/demo.jsx +136 -0
- package/registry/blocks/pdf-document/index.js +33 -0
- package/registry/blocks/pdf-document/money.js +83 -0
- package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
- package/registry/blocks/pdf-document/primitives.jsx +271 -0
- package/registry/blocks/pdf-document/quote.jsx +179 -0
- package/registry/blocks/pdf-document/theme.js +193 -0
- package/registry/blocks/pdf-viewer/README.md +191 -0
- package/registry/blocks/pdf-viewer/block.json +14 -0
- package/registry/blocks/pdf-viewer/demo.jsx +252 -0
- package/registry/blocks/pdf-viewer/helpers.js +270 -0
- package/registry/blocks/pdf-viewer/index.js +8 -0
- package/registry/blocks/pdf-viewer/notes.jsx +195 -0
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
- package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
- package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
- package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
- package/registry/blocks/product-detail/README.md +217 -0
- package/registry/blocks/product-detail/block.json +10 -0
- package/registry/blocks/product-detail/demo.jsx +233 -0
- package/registry/blocks/product-detail/index.js +7 -0
- package/registry/blocks/product-detail/price.jsx +115 -0
- package/registry/blocks/product-detail/product-detail.jsx +348 -0
- package/registry/blocks/product-detail/product-media.jsx +102 -0
- package/registry/blocks/product-detail/product-options.jsx +144 -0
- package/registry/blocks/product-grid/README.md +162 -0
- package/registry/blocks/product-grid/block.json +10 -0
- package/registry/blocks/product-grid/demo.jsx +255 -0
- package/registry/blocks/product-grid/index.js +5 -0
- package/registry/blocks/product-grid/product-card.jsx +232 -0
- package/registry/blocks/product-grid/product-grid.jsx +235 -0
- package/registry/blocks/record-form/README.md +196 -0
- package/registry/blocks/record-form/block.json +10 -0
- package/registry/blocks/record-form/demo.jsx +525 -0
- package/registry/blocks/record-form/index.js +4 -0
- package/registry/blocks/record-form/record-fields.jsx +457 -0
- package/registry/blocks/record-form/record-form.jsx +415 -0
- package/registry/blocks/record-form/rules.js +81 -0
- package/registry/blocks/record-notes/README.md +139 -0
- package/registry/blocks/record-notes/block.json +10 -0
- package/registry/blocks/record-notes/demo.jsx +179 -0
- package/registry/blocks/record-notes/index.js +4 -0
- package/registry/blocks/record-notes/record-notes.jsx +266 -0
- package/registry/blocks/search-dialog/README.md +108 -0
- package/registry/blocks/search-dialog/block.json +10 -0
- package/registry/blocks/search-dialog/demo.jsx +116 -0
- package/registry/blocks/search-dialog/index.js +3 -0
- package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
- package/registry/blocks/settings-list/README.md +171 -0
- package/registry/blocks/settings-list/block.json +10 -0
- package/registry/blocks/settings-list/demo.jsx +333 -0
- package/registry/blocks/settings-list/index.js +4 -0
- package/registry/blocks/settings-list/row-state.jsx +93 -0
- package/registry/blocks/settings-list/settings-list.jsx +349 -0
- package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
- package/registry/blocks/setup-checklist/README.md +142 -0
- package/registry/blocks/setup-checklist/block.json +10 -0
- package/registry/blocks/setup-checklist/demo.jsx +159 -0
- package/registry/blocks/setup-checklist/index.js +4 -0
- package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
- package/registry/blocks/share-dialog/README.md +113 -0
- package/registry/blocks/share-dialog/block.json +10 -0
- package/registry/blocks/share-dialog/demo.jsx +140 -0
- package/registry/blocks/share-dialog/index.js +3 -0
- package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
- package/registry/blocks/sidebar/README.md +190 -0
- package/registry/blocks/sidebar/block.json +43 -0
- package/registry/blocks/sidebar/demo.jsx +577 -0
- package/registry/blocks/sidebar/index.js +4 -0
- package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
- package/registry/blocks/sidebar/sidebar.jsx +864 -0
- package/registry/blocks/signature-pad/README.md +137 -0
- package/registry/blocks/signature-pad/block.json +13 -0
- package/registry/blocks/signature-pad/colors.js +115 -0
- package/registry/blocks/signature-pad/demo.jsx +94 -0
- package/registry/blocks/signature-pad/index.js +6 -0
- package/registry/blocks/signature-pad/pad.js +7 -0
- package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
- package/registry/blocks/signature-pad/signature.js +68 -0
- package/registry/blocks/sketch-canvas/README.md +280 -0
- package/registry/blocks/sketch-canvas/block.json +13 -0
- package/registry/blocks/sketch-canvas/demo.jsx +159 -0
- package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
- package/registry/blocks/sketch-canvas/index.js +9 -0
- package/registry/blocks/sketch-canvas/palette.js +226 -0
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
- package/registry/blocks/sketch-canvas/sketch.js +179 -0
- package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
- package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
- package/registry/blocks/sketch-canvas/stamps.js +104 -0
- package/registry/blocks/sketch-canvas/theme.js +131 -0
- package/registry/blocks/spreadsheet/README.md +307 -0
- package/registry/blocks/spreadsheet/block.json +13 -0
- package/registry/blocks/spreadsheet/cells.jsx +455 -0
- package/registry/blocks/spreadsheet/chrome.jsx +325 -0
- package/registry/blocks/spreadsheet/demo.jsx +177 -0
- package/registry/blocks/spreadsheet/grid.js +387 -0
- package/registry/blocks/spreadsheet/index.js +34 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
- package/registry/blocks/spreadsheet/types.js +473 -0
- package/registry/blocks/stat-cards/README.md +173 -0
- package/registry/blocks/stat-cards/block.json +10 -0
- package/registry/blocks/stat-cards/demo.jsx +257 -0
- package/registry/blocks/stat-cards/index.js +4 -0
- package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
- package/registry/blocks/stat-cards/usage.jsx +243 -0
- package/registry/blocks/task-list/README.md +176 -0
- package/registry/blocks/task-list/block.json +10 -0
- package/registry/blocks/task-list/demo.jsx +134 -0
- package/registry/blocks/task-list/index.js +5 -0
- package/registry/blocks/task-list/task-list.jsx +907 -0
- package/registry/blocks/task-model/README.md +455 -0
- package/registry/blocks/task-model/block.json +167 -0
- package/registry/blocks/task-model/changes.js +89 -0
- package/registry/blocks/task-model/dates.js +371 -0
- package/registry/blocks/task-model/demo.jsx +215 -0
- package/registry/blocks/task-model/dnd.jsx +240 -0
- package/registry/blocks/task-model/index.js +97 -0
- package/registry/blocks/task-model/model.js +919 -0
- package/registry/blocks/task-model/parts.jsx +449 -0
- package/registry/blocks/task-model/toolbar.jsx +271 -0
- package/registry/blocks/testimonials/README.md +139 -0
- package/registry/blocks/testimonials/block.json +11 -0
- package/registry/blocks/testimonials/demo.jsx +137 -0
- package/registry/blocks/testimonials/index.js +4 -0
- package/registry/blocks/testimonials/testimonials.jsx +430 -0
- package/registry/blocks/transaction-list/README.md +126 -0
- package/registry/blocks/transaction-list/block.json +10 -0
- package/registry/blocks/transaction-list/demo.jsx +79 -0
- package/registry/blocks/transaction-list/index.js +4 -0
- package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
- package/registry/blocks/whiteboard/README.md +179 -0
- package/registry/blocks/whiteboard/block.json +10 -0
- package/registry/blocks/whiteboard/demo.jsx +169 -0
- package/registry/blocks/whiteboard/index.js +6 -0
- package/registry/blocks/whiteboard/templates.js +150 -0
- package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
- package/src/install.mjs +18 -0
- package/src/shadcn.mjs +171 -0
- package/templates/next/files/README.md +145 -11
- package/templates/next/files/app/globals.css +55 -4
- package/templates/next/files/check/run.mjs +3 -3
- package/templates/next/files/components/parts/fixture.jsx +1 -1
- package/templates/next/files/components/parts/sample.js +15 -0
- package/templates/next/files/components/ui/avatar.jsx +141 -0
- package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
- package/templates/next/files/components/ui/button-group.jsx +70 -0
- package/templates/next/files/components/ui/button.jsx +132 -12
- package/templates/next/files/components/ui/calendar.jsx +609 -0
- package/templates/next/files/components/ui/carousel.jsx +281 -0
- package/templates/next/files/components/ui/collapsible.jsx +208 -0
- package/templates/next/files/components/ui/combobox.jsx +608 -0
- package/templates/next/files/components/ui/command.jsx +577 -0
- package/templates/next/files/components/ui/context-menu.jsx +191 -0
- package/templates/next/files/components/ui/date-picker.jsx +360 -0
- package/templates/next/files/components/ui/distribution.jsx +117 -0
- package/templates/next/files/components/ui/feedback.jsx +143 -0
- package/templates/next/files/components/ui/field.jsx +115 -3
- package/templates/next/files/components/ui/file-input.jsx +234 -0
- package/templates/next/files/components/ui/icon.jsx +130 -8
- package/templates/next/files/components/ui/image.jsx +143 -0
- package/templates/next/files/components/ui/index.js +47 -2
- package/templates/next/files/components/ui/input-group.jsx +213 -0
- package/templates/next/files/components/ui/input-otp.jsx +170 -0
- package/templates/next/files/components/ui/item.jsx +206 -0
- package/templates/next/files/components/ui/menu.jsx +454 -82
- package/templates/next/files/components/ui/notice.jsx +126 -14
- package/templates/next/files/components/ui/pagination.jsx +156 -0
- package/templates/next/files/components/ui/popover.jsx +284 -0
- package/templates/next/files/components/ui/radio.jsx +193 -0
- package/templates/next/files/components/ui/rating.jsx +131 -0
- package/templates/next/files/components/ui/resizable.jsx +338 -0
- package/templates/next/files/components/ui/scroll-area.jsx +123 -0
- package/templates/next/files/components/ui/separator.jsx +119 -0
- package/templates/next/files/components/ui/sheet.jsx +96 -11
- package/templates/next/files/components/ui/slider.jsx +174 -0
- package/templates/next/files/components/ui/stat.jsx +574 -0
- package/templates/next/files/components/ui/steps.jsx +338 -0
- package/templates/next/files/components/ui/swatch.jsx +33 -0
- package/templates/next/files/components/ui/table.jsx +130 -0
- package/templates/next/files/components/ui/toast.jsx +440 -0
- package/templates/next/files/components/ui/toggle.jsx +195 -0
- package/templates/next/files/components/ui/tooltip.jsx +270 -0
- package/templates/next/files/lib/sample.js +4 -12
- package/templates/next/template.json +264 -8
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "task-model",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Task model",
|
|
6
|
+
"description": "The shared work-item model every task view reads: one task shape (status, assignees, start/due, estimate, progress, priority, parent, dependencies, location, tags, project, extra fields) with statuses, people and projects; pure helpers to search, filter, group by any field, sort, nest subtasks, do date math (week and month grids, working days, overlaps, time scales), sum capacity per person, follow dependencies and the critical path, and apply, undo and persist changes; the shared parts (StatusPill, AssigneeStack, DueLabel, PriorityFlag, TaskChip, CompleteToggle), the ViewToolbar (search, filter chips, group by, sort, show fields), drag primitives on @dnd-kit, a hook for optimistic saves with rollback and undo, and adapters that drive the Board and DataTable blocks from tasks.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@dnd-kit/core@6.3.1",
|
|
12
|
+
"@dnd-kit/sortable@10.0.0",
|
|
13
|
+
"@dnd-kit/utilities@3.2.2"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "registry/blocks/task-model/index.js",
|
|
18
|
+
"type": "registry:component",
|
|
19
|
+
"target": "@components/makersclaw/blocks/task-model/index.js",
|
|
20
|
+
"content": "// The task-model block — `makersclaw add task-model`. The work-item shape\n// every task view reads (list, board, table, calendar, gantt, workload…),\n// its pure helpers, the small parts those views share, the toolbar, and\n// the drag primitives on @dnd-kit. Fixed: compose it through its exports\n// (README.md beside this file). Views built on it import it, never\n// @dnd-kit directly. The helpers in model.js and dates.js are pure and\n// run anywhere, the server included.\nexport * from \"./dates\";\nexport {\n NONE,\n PRIORITIES,\n DEFAULT_STATUSES,\n TASK_FIELDS,\n DEFAULT_VIEW,\n normalizeTask,\n normalizeTasks,\n createTaskContext,\n fieldOf,\n isDone,\n doneStatusId,\n openStatusId,\n toggleDoneChange,\n newTaskId,\n draftTask,\n valuesOf,\n optionsOf,\n labelOf,\n searchText,\n searchTasks,\n filterTasks,\n orderOf,\n sortTasks,\n sortOptions,\n groupTasks,\n groupOptions,\n movePatch,\n applyView,\n activeFilterCount,\n rankBetween,\n placeChange,\n childrenMap,\n buildTree,\n flattenTree,\n descendantIds,\n ancestorIds,\n subtaskCounts,\n rollup,\n progressOf,\n dependencyEdges,\n blockersOf,\n wouldCycle,\n dateConflicts,\n criticalPath,\n hoursPerDay,\n workload,\n sumOf,\n applyChange,\n invertChange,\n changeWrites,\n isNewId,\n changedIds,\n deleteChange,\n toBoard,\n toTableRows,\n taskFacets,\n} from \"./model\";\nexport { useTaskChanges } from \"./changes\";\nexport {\n StatusPill,\n CompleteToggle,\n AssigneeStack,\n PersonLabel,\n DueLabel,\n PriorityFlag,\n TagList,\n SubtaskCount,\n ProgressLine,\n TaskChip,\n GroupLabel,\n taskColumns,\n TONE_TEXT,\n HUE_EDGE,\n HUE_BG,\n} from \"./parts\";\nexport { ViewToolbar } from \"./toolbar\";\nexport {\n TaskDragProvider,\n SortableGroup,\n useSortableTask,\n useDraggableTask,\n useDropTarget,\n DragHandle,\n DropLine,\n LIFTED,\n useReducedMotion,\n arrayMove,\n} from \"./dnd\";\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "registry/blocks/task-model/model.js",
|
|
24
|
+
"type": "registry:component",
|
|
25
|
+
"target": "@components/makersclaw/blocks/task-model/model.js",
|
|
26
|
+
"content": "// The work-item model every view reads — pure, no React.\n//\n// One shape (a task), one context (its statuses, people, projects, custom\n// fields and today), and the arithmetic every view repeats: search,\n// filter, group by any field, sort, subtask trees, capacity, dependencies,\n// and the Change a view sends out when the founder moves something. List,\n// board, table, calendar, gantt, workload, portfolio and roadmap all run\n// through these, so a task filtered out of one is filtered out of all.\n//\n// README.md beside this file is the contract; this file is the proof.\n\nimport {\n DUE_BUCKETS,\n addDays,\n dayForBucket,\n dayOf,\n daysBetween,\n diffDays,\n dueBucket,\n isWorkingDay,\n minutesOf,\n shiftDates,\n spanOf,\n startOfWeek,\n todayDay,\n withTime,\n} from \"./dates\";\n\n/** The group / filter value of \"no value\": Unassigned, No date, … */\nexport const NONE = \"__none\";\n\nexport const PRIORITIES = [\n { id: \"urgent\", label: \"Urgent\", tone: \"danger\" },\n { id: \"high\", label: \"High\", tone: \"warning\" },\n { id: \"medium\", label: \"Medium\", tone: \"info\" },\n { id: \"low\", label: \"Low\", tone: \"neutral\" },\n];\n\n/** A sensible set when the surface has none of its own. */\nexport const DEFAULT_STATUSES = [\n { id: \"todo\", label: \"To do\", tone: \"neutral\" },\n { id: \"doing\", label: \"In progress\", tone: \"info\" },\n { id: \"review\", label: \"In review\", tone: \"warning\" },\n { id: \"done\", label: \"Done\", tone: \"success\", done: true },\n];\n\n/** The fields every task has. Custom fields join through `fields`. */\nexport const TASK_FIELDS = [\n { key: \"title\", label: \"Name\", kind: \"text\", groupable: false },\n { key: \"status\", label: \"Status\", kind: \"status\" },\n { key: \"assignees\", label: \"Assignee\", kind: \"people\", multi: true },\n { key: \"due\", label: \"Due date\", kind: \"date\" },\n { key: \"start\", label: \"Start date\", kind: \"date\" },\n { key: \"priority\", label: \"Priority\", kind: \"priority\" },\n { key: \"project\", label: \"Project\", kind: \"project\" },\n { key: \"tags\", label: \"Tags\", kind: \"tags\", multi: true },\n { key: \"estimate\", label: \"Estimate\", kind: \"number\", unit: \"h\", groupable: false },\n { key: \"progress\", label: \"Progress\", kind: \"progress\", groupable: false },\n];\n\nconst EMPTY_WORDS = {\n status: \"No status\",\n people: \"Unassigned\",\n priority: \"No priority\",\n project: \"No project\",\n tags: \"No tags\",\n date: \"No date\",\n select: \"None\",\n text: \"None\",\n number: \"None\",\n checkbox: \"No\",\n};\n\nconst isBlank = (v) => v === null || v === undefined || v === \"\";\nconst asList = (v) => (Array.isArray(v) ? v.filter((x) => !isBlank(x)).map(String) : isBlank(v) ? [] : [String(v)]);\n\n// ---- the shape ---------------------------------------------------------\n\n/** A task with its lists always lists and its id a string: what every\n * helper assumes. Unknown keys are kept (`…extra`). */\nexport function normalizeTask(t) {\n return {\n ...t,\n id: String(t?.id ?? \"\"),\n title: String(t?.title ?? t?.name ?? \"\"),\n assignees: asList(t?.assignees ?? t?.assignee),\n tags: asList(t?.tags),\n dependsOn: asList(t?.dependsOn),\n parentId: isBlank(t?.parentId) ? null : String(t.parentId),\n };\n}\n\nexport function normalizeTasks(list) {\n return (list ?? []).map(normalizeTask);\n}\n\n/** Everything a helper needs beside the tasks, built once per render\n * (useMemo it). `statuses` in order ({ id, label, tone?: neutral · info\n * · warning · success · danger · primary, hue?: 1–5, done? }); `people`\n * ({ id, name, src?, capacity? hours a day }); `projects` ({ id, name,\n * hue? }); `fields` (custom: { key, label, kind: text · number · select\n * · date · checkbox, options? }); `today` (\"YYYY-MM-DD\", fixed in a\n * demo); `weekStartsOn` (0–6, default Monday). */\nexport function createTaskContext({ statuses, people, projects, fields, today, weekStartsOn = 1, weekend, holidays } = {}) {\n const st = (statuses?.length ? statuses : DEFAULT_STATUSES).map((s) => ({ ...s, id: String(s.id), label: s.label ?? String(s.id) }));\n const pp = (people ?? []).map((p) => ({ ...p, id: String(p.id), name: p.name ?? String(p.id) }));\n const pj = (projects ?? []).map((p) => ({ ...p, id: String(p.id), name: p.name ?? String(p.id) }));\n const custom = (fields ?? []).map((f) => ({ ...f, label: f.label ?? f.key, custom: true }));\n const known = new Set(TASK_FIELDS.map((f) => f.key));\n return {\n statuses: st,\n people: pp,\n projects: pj,\n fields: [...TASK_FIELDS, ...custom.filter((f) => !known.has(f.key))],\n statusById: new Map(st.map((s, i) => [s.id, { ...s, index: i }])),\n personById: new Map(pp.map((p) => [p.id, p])),\n projectById: new Map(pj.map((p) => [p.id, p])),\n doneIds: new Set(st.filter((s) => s.done).map((s) => s.id)),\n today: dayOf(today) ?? todayDay(),\n weekStartsOn,\n weekend,\n holidays,\n };\n}\n\nconst ctxOf = (ctx) => (ctx && ctx.statusById ? ctx : createTaskContext(ctx ?? {}));\n\nexport function fieldOf(key, ctx) {\n return ctxOf(ctx).fields.find((f) => f.key === key) ?? { key, label: key, kind: \"text\", custom: true };\n}\n\nexport function isDone(task, ctx) {\n const c = ctxOf(ctx);\n return c.doneIds.has(String(task?.status)) || task?.done === true;\n}\n\n/** The first \"done\" status, and the first that is not. */\nexport function doneStatusId(ctx) {\n return ctxOf(ctx).statuses.find((s) => s.done)?.id ?? null;\n}\nexport function openStatusId(ctx) {\n return ctxOf(ctx).statuses.find((s) => !s.done)?.id ?? null;\n}\n\n/** The change a completion checkbox saves: to the first done status, or\n * back to the first open one. */\nexport function toggleDoneChange(task, ctx) {\n const done = isDone(task, ctx);\n return { type: \"update\", id: task.id, patch: { status: done ? openStatusId(ctx) : doneStatusId(ctx) } };\n}\n\nlet seq = 0;\n/** A client id for a task being created; the page may swap it for the\n * database's (answer `{ ok: true, id }`). */\nexport function newTaskId() {\n seq += 1;\n return `new-${Date.now().toString(36)}-${seq}`;\n}\n\n/** A new task with the defaults a quick-add needs: first open status, an\n * `order` after `after` (a list's last task), and the patch of the group\n * it was added in. */\nexport function draftTask(title, ctx, patch = {}, { after } = {}) {\n return normalizeTask({\n id: newTaskId(),\n title: String(title ?? \"\").trim(),\n status: openStatusId(ctx),\n order: rankBetween(orderOf(after), null),\n ...patch,\n });\n}\n\n// ---- values of a field -------------------------------------------------\n\n/** The keys a task is filed under for a field (one, or several for\n * assignees and tags; `NONE` when it has none). Dates are due buckets. */\nexport function valuesOf(task, key, ctx) {\n const c = ctxOf(ctx);\n const field = fieldOf(key, c);\n const v = task?.[field.key];\n switch (field.kind) {\n case \"people\":\n case \"tags\": {\n const list = asList(v);\n return list.length ? list : [NONE];\n }\n case \"date\":\n return [dueBucket(v, c.today, { done: isDone(task, c), weekStartsOn: c.weekStartsOn })];\n case \"checkbox\":\n return [v ? \"yes\" : \"no\"];\n default:\n return [isBlank(v) ? NONE : String(v)];\n }\n}\n\n/** A field's choices in order, as { id, label, tone?, hue?, person?,\n * project? } — the groups of \"group by\" and the chips of a filter. The\n * \"none\" choice comes last. Tags and plain values are read from `tasks`. */\nexport function optionsOf(key, ctx, tasks = []) {\n const c = ctxOf(ctx);\n const field = fieldOf(key, c);\n let out;\n switch (field.kind) {\n case \"status\":\n out = c.statuses.map((s) => ({ id: s.id, label: s.label, tone: s.tone, hue: s.hue, done: Boolean(s.done) }));\n break;\n case \"people\":\n out = c.people.map((p) => ({ id: p.id, label: p.name, person: p }));\n break;\n case \"priority\":\n out = PRIORITIES.map((p) => ({ ...p }));\n break;\n case \"project\":\n out = c.projects.map((p) => ({ id: p.id, label: p.name, hue: p.hue, project: p }));\n break;\n case \"date\":\n out = DUE_BUCKETS.filter((b) => b.id !== \"none\").map((b) => ({ ...b }));\n break;\n case \"checkbox\":\n return [\n { id: \"yes\", label: \"Yes\" },\n { id: \"no\", label: \"No\" },\n ];\n case \"select\":\n out = (field.options ?? []).map((o) => (typeof o === \"object\" ? { ...o, id: String(o.id ?? o.value), label: o.label ?? String(o.id ?? o.value) } : { id: String(o), label: String(o) }));\n break;\n default: {\n const seen = new Set();\n for (const t of tasks) for (const v of valuesOf(t, key, c)) if (v !== NONE) seen.add(v);\n out = [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).map((v) => ({ id: v, label: v }));\n }\n }\n return [...out, { id: NONE, label: EMPTY_WORDS[field.kind] ?? \"None\", none: true }];\n}\n\n/** The words for one value of a field: labelOf(\"status\", \"doing\") → \"In progress\". */\nexport function labelOf(key, value, ctx, tasks) {\n const o = optionsOf(key, ctx, tasks).find((x) => x.id === String(value ?? NONE));\n return o?.label ?? String(value ?? \"\");\n}\n\n// ---- search, filter, sort ---------------------------------------------\n\n/** The words a task can be found by: its title, tags, people, project,\n * status, and any string field. */\nexport function searchText(task, ctx) {\n const c = ctxOf(ctx);\n const parts = [task.title, task.description, ...(task.tags ?? [])];\n for (const id of task.assignees ?? []) parts.push(c.personById.get(id)?.name);\n parts.push(c.projectById.get(String(task.project))?.name, c.statusById.get(String(task.status))?.label);\n for (const f of c.fields) if (f.custom && typeof task[f.key] === \"string\") parts.push(task[f.key]);\n return parts.filter(Boolean).join(\" \\u0001 \").toLowerCase();\n}\n\n/** Tasks that hold every word of `query`. */\nexport function searchTasks(tasks, query, ctx) {\n const words = String(query ?? \"\").trim().toLowerCase().split(/\\s+/).filter(Boolean);\n if (!words.length) return tasks;\n const c = ctxOf(ctx);\n return tasks.filter((t) => {\n const text = searchText(t, c);\n return words.every((w) => text.includes(w));\n });\n}\n\n/** Tasks that match `filters` — { status: [\"todo\", \"doing\"], assignees:\n * [\"maya\", NONE], due: [\"overdue\"] }: any value within a field, every\n * field. An empty list is no filter. */\nexport function filterTasks(tasks, filters, ctx) {\n const c = ctxOf(ctx);\n const active = Object.entries(filters ?? {}).filter(([, v]) => Array.isArray(v) && v.length);\n if (!active.length) return tasks;\n return tasks.filter((t) => active.every(([key, want]) => valuesOf(t, key, c).some((v) => want.includes(v))));\n}\n\n/** A task's place in a hand-made order (`order`), or null. */\nexport function orderOf(task) {\n return typeof task?.order === \"number\" && Number.isFinite(task.order) ? task.order : null;\n}\n\nfunction sortValue(task, key, c) {\n if (key === \"manual\") return orderOf(task);\n const field = fieldOf(key, c);\n const v = task[field.key];\n switch (field.kind) {\n case \"status\":\n return c.statusById.get(String(v))?.index ?? null;\n case \"priority\": {\n const i = PRIORITIES.findIndex((p) => p.id === v);\n return i < 0 ? null : i;\n }\n case \"people\":\n return c.personById.get(asList(v)[0])?.name ?? (asList(v)[0] || null);\n case \"project\":\n return c.projectById.get(String(v))?.name ?? (isBlank(v) ? null : String(v));\n case \"tags\":\n return asList(v)[0] ?? null;\n case \"date\": {\n const d = dayOf(v);\n return d ? `${d}T${String(minutesOf(v) ?? 0).padStart(4, \"0\")}` : null;\n }\n case \"select\": {\n const i = (field.options ?? []).findIndex((o) => String(typeof o === \"object\" ? o.id ?? o.value : o) === String(v));\n return i < 0 ? (isBlank(v) ? null : String(v)) : i;\n }\n default:\n return isBlank(v) ? null : v;\n }\n}\n\nfunction compare(a, b) {\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") return Number(a) - Number(b);\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\n\n/** Tasks by `sort` ([{ key, dir: \"asc\" | \"desc\" }], first wins; key\n * \"manual\" is the hand-made `order`). Blanks last either way; stable. An\n * empty sort is the manual order. */\nexport function sortTasks(tasks, sort, ctx) {\n const c = ctxOf(ctx);\n const keys = sort?.length ? sort : [{ key: \"manual\", dir: \"asc\" }];\n return tasks\n .map((task, i) => ({ task, i }))\n .sort((x, y) => {\n for (const { key, dir } of keys) {\n const a = sortValue(x.task, key, c);\n const b = sortValue(y.task, key, c);\n if (a === null || b === null) {\n if (a === null && b === null) continue;\n return a === null ? 1 : -1;\n }\n const r = compare(a, b);\n if (r !== 0) return dir === \"desc\" ? -r : r;\n }\n return x.i - y.i;\n })\n .map((x) => x.task);\n}\n\n/** The sorts a view offers: \"manual\" and every sortable field. */\nexport function sortOptions(ctx) {\n return [{ key: \"manual\", label: \"Manual\" }, ...ctxOf(ctx).fields.filter((f) => f.sortable !== false).map((f) => ({ key: f.key, label: f.label }))];\n}\n\n// ---- group -------------------------------------------------------------\n\n/** Tasks filed under a field's values, in the field's order:\n * [{ key, label, tone?, hue?, option, tasks }]. A task with several\n * assignees (or tags) is in each of their groups. `by` null / \"none\" →\n * one group of everything. Empty groups show for status and priority\n * (somewhere to drop), or always with `showEmpty: true`; the \"none\" group\n * only when it holds something. */\nexport function groupTasks(tasks, by, ctx, { showEmpty } = {}) {\n const c = ctxOf(ctx);\n if (!by || by === \"none\") return [{ key: \"all\", label: \"All tasks\", option: null, tasks: [...tasks] }];\n const field = fieldOf(by, c);\n const keepEmpty = showEmpty ?? (field.kind === \"status\" || field.kind === \"priority\");\n const options = optionsOf(by, c, tasks);\n const map = new Map(options.map((o) => [o.id, []]));\n for (const t of tasks) {\n for (const v of valuesOf(t, by, c)) {\n if (!map.has(v)) {\n map.set(v, []);\n options.splice(options.length - 1, 0, { id: v, label: v });\n }\n map.get(v).push(t);\n }\n }\n return options\n .map((o) => ({ key: o.id, label: o.label, tone: o.tone, hue: o.hue, option: o, tasks: map.get(o.id) ?? [] }))\n .filter((g) => g.tasks.length > 0 || (keepEmpty && g.key !== NONE && g.key !== \"past\"));\n}\n\n/** The fields a view can group by. */\nexport function groupOptions(ctx) {\n return [{ key: \"none\", label: \"None\" }, ...ctxOf(ctx).fields.filter((f) => f.groupable !== false).map((f) => ({ key: f.key, label: f.label }))];\n}\n\n/** The patch that moves a task from one group to another when grouped by\n * `by`: a new status, an assignee swapped for another (the others stay),\n * a due date for a due bucket (a start moves with it). null when the\n * group is not a place to drop (Overdue). */\nexport function movePatch(task, by, fromKey, toKey, ctx) {\n const c = ctxOf(ctx);\n if (!by || by === \"none\" || fromKey === toKey) return {};\n const field = fieldOf(by, c);\n const to = toKey === NONE ? null : toKey;\n switch (field.kind) {\n case \"people\":\n case \"tags\": {\n const list = asList(task[field.key]).filter((v) => v !== fromKey);\n return { [field.key]: to && !list.includes(to) ? [...list, to] : list };\n }\n case \"date\": {\n const day = dayForBucket(toKey, c.today, { weekStartsOn: c.weekStartsOn });\n if (day === undefined) return null;\n if (day === null) return { [field.key]: null };\n const from = dayOf(task[field.key]);\n if (field.key === \"due\" && from && dayOf(task.start)) return shiftDates(task, diffDays(from, day));\n return { [field.key]: withTime(day, minutesOf(task[field.key])) };\n }\n case \"checkbox\":\n return { [field.key]: toKey === \"yes\" };\n case \"number\":\n case \"progress\":\n return { [field.key]: to === null ? null : Number(to) };\n default:\n return { [field.key]: to };\n }\n}\n\n// ---- views -------------------------------------------------------------\n\n/** What a view remembers: work-views saves one per tab. */\nexport const DEFAULT_VIEW = {\n search: \"\",\n filters: {},\n groupBy: \"status\",\n sort: [],\n fields: [\"assignees\", \"due\", \"priority\"],\n hideDone: false,\n};\n\n/** A view's answer: the tasks it shows (searched, filtered, sorted) and\n * their groups. `hideDone` drops done tasks. */\nexport function applyView(tasks, view, ctx) {\n const c = ctxOf(ctx);\n const v = { ...DEFAULT_VIEW, ...view };\n let list = normalizeTasks(tasks);\n if (v.hideDone) list = list.filter((t) => !isDone(t, c));\n list = filterTasks(searchTasks(list, v.search, c), v.filters, c);\n list = sortTasks(list, v.sort, c);\n return { tasks: list, groups: groupTasks(list, v.groupBy, c), total: (tasks ?? []).length };\n}\n\n/** How many filters are on (for a \"Filter · 2\" button). */\nexport function activeFilterCount(view) {\n return Object.values(view?.filters ?? {}).filter((v) => Array.isArray(v) && v.length).length + (view?.hideDone ? 1 : 0);\n}\n\n// ---- manual order ------------------------------------------------------\n\nconst GAP = 1024;\n\n/** A number between two orders (either may be null: the list's end). */\nexport function rankBetween(a, b) {\n if (a == null && b == null) return GAP;\n if (a == null) return b - GAP;\n if (b == null) return a + GAP;\n return (a + b) / 2;\n}\n\n/** The change that puts `task` at `index` of `list` (the target group's\n * tasks as shown, without the task itself), with `patch` (a new group)\n * merged in. A midpoint `order` when the neighbours have room; else the\n * list is renumbered in one batch, so the order always holds. */\nexport function placeChange(list, task, index, patch = {}) {\n const others = list.filter((t) => t.id !== task.id);\n const i = Math.max(0, Math.min(index, others.length));\n const orders = others.map(orderOf);\n const increasing = orders.every((o, k) => o !== null && (k === 0 || o > orders[k - 1]));\n const prev = i > 0 ? orders[i - 1] : null;\n const next = i < others.length ? orders[i] : null;\n if (increasing && (prev === null || next === null || next - prev > 1e-6)) {\n return { type: \"update\", id: task.id, patch: { ...patch, order: rankBetween(prev, next) } };\n }\n const seqd = [...others.slice(0, i), task, ...others.slice(i)];\n return {\n type: \"batch\",\n changes: seqd.map((t, k) =>\n t.id === task.id ? { type: \"update\", id: t.id, patch: { ...patch, order: (k + 1) * GAP } } : { type: \"update\", id: t.id, patch: { order: (k + 1) * GAP } },\n ),\n };\n}\n\n// ---- trees (subtasks) --------------------------------------------------\n\n/** parentId → its children, in the order given. */\nexport function childrenMap(tasks) {\n const ids = new Set(tasks.map((t) => String(t.id)));\n const map = new Map();\n for (const t of tasks) {\n const p = t.parentId == null ? null : String(t.parentId);\n const key = p && ids.has(p) && p !== String(t.id) ? p : null;\n if (!map.has(key)) map.set(key, []);\n map.get(key).push(t);\n }\n return map;\n}\n\n/** Tasks as a tree: [{ task, children: [node…], depth }]. A task whose\n * parent is not in `tasks` (filtered out) is a root. Cycles are cut. */\nexport function buildTree(tasks) {\n const map = childrenMap(tasks);\n const seen = new Set();\n const walk = (list, depth) =>\n (list ?? [])\n .filter((t) => !seen.has(t.id) && seen.add(t.id))\n .map((task) => ({ task, depth, children: walk(map.get(String(task.id)), depth + 1) }));\n return walk(map.get(null), 0);\n}\n\n/** The tree as rows in reading order: [{ task, depth, childCount,\n * hasChildren, collapsed }]; the children of an id in `collapsed` are\n * left out. */\nexport function flattenTree(tasks, { collapsed } = {}) {\n const shut = collapsed instanceof Set ? collapsed : new Set(collapsed ?? []);\n const out = [];\n const walk = (nodes) => {\n for (const n of nodes) {\n const closed = shut.has(n.task.id);\n out.push({ task: n.task, depth: n.depth, childCount: n.children.length, hasChildren: n.children.length > 0, collapsed: closed });\n if (!closed) walk(n.children);\n }\n };\n walk(buildTree(tasks));\n return out;\n}\n\n/** Every id under `id` (children, their children…). */\nexport function descendantIds(tasks, id) {\n const map = childrenMap(tasks);\n const out = [];\n const stack = [...(map.get(String(id)) ?? [])];\n const seen = new Set([String(id)]);\n while (stack.length) {\n const t = stack.pop();\n if (seen.has(t.id)) continue;\n seen.add(t.id);\n out.push(t.id);\n stack.push(...(map.get(String(t.id)) ?? []));\n }\n return out;\n}\n\n/** The ids above `id`, nearest first. */\nexport function ancestorIds(tasks, id) {\n const byId = new Map(tasks.map((t) => [String(t.id), t]));\n const out = [];\n let cur = byId.get(String(id));\n while (cur?.parentId && byId.has(cur.parentId) && !out.includes(cur.parentId) && cur.parentId !== String(id)) {\n out.push(cur.parentId);\n cur = byId.get(cur.parentId);\n }\n return out;\n}\n\n/** id → { total, done } of its direct subtasks (\"2/5\" on a row). */\nexport function subtaskCounts(tasks, ctx) {\n const c = ctxOf(ctx);\n const out = new Map();\n for (const [parent, kids] of childrenMap(tasks)) {\n if (parent === null) continue;\n out.set(parent, { total: kids.length, done: kids.filter((k) => isDone(k, c)).length });\n }\n return out;\n}\n\n/** A parent's figures from everything under it: count, done, estimate\n * (hours), and progress (0–100: its own, else the share done, weighted\n * by estimate when there are estimates). */\nexport function rollup(tasks, id, ctx) {\n const c = ctxOf(ctx);\n const ids = new Set(descendantIds(tasks, id));\n const under = tasks.filter((t) => ids.has(t.id));\n const done = under.filter((t) => isDone(t, c));\n const estimate = under.reduce((s, t) => s + (Number(t.estimate) || 0), 0);\n const doneEstimate = done.reduce((s, t) => s + (Number(t.estimate) || 0), 0);\n const progress = under.length === 0 ? null : estimate > 0 ? Math.round((doneEstimate / estimate) * 100) : Math.round((done.length / under.length) * 100);\n return { count: under.length, done: done.length, estimate, progress };\n}\n\n/** A task's progress, 0–100: `progress`, else 100 when done, else from\n * its subtasks, else 0. */\nexport function progressOf(task, tasks, ctx) {\n if (typeof task?.progress === \"number\") return Math.max(0, Math.min(100, task.progress));\n if (isDone(task, ctx)) return 100;\n return rollup(tasks ?? [], task.id, ctx).progress ?? 0;\n}\n\n// ---- dependencies ------------------------------------------------------\n\n/** [{ from, to }]: `to` depends on `from` (from must finish first). Ids\n * not in `tasks` are skipped. */\nexport function dependencyEdges(tasks) {\n const ids = new Set(tasks.map((t) => t.id));\n const out = [];\n for (const t of tasks) for (const d of t.dependsOn ?? []) if (ids.has(d) && d !== t.id) out.push({ from: d, to: t.id });\n return out;\n}\n\n/** The tasks `task` waits on that are not done. */\nexport function blockersOf(task, tasks, ctx) {\n const want = new Set(task?.dependsOn ?? []);\n return tasks.filter((t) => want.has(t.id) && !isDone(t, ctx));\n}\n\n/** Whether \"to depends on from\" would close a loop. */\nexport function wouldCycle(tasks, fromId, toId) {\n if (fromId === toId) return true;\n const deps = new Map(tasks.map((t) => [t.id, t.dependsOn ?? []]));\n const stack = [fromId];\n const seen = new Set();\n while (stack.length) {\n const id = stack.pop();\n if (id === toId) return true;\n if (seen.has(id)) continue;\n seen.add(id);\n stack.push(...(deps.get(id) ?? []));\n }\n return false;\n}\n\n/** Dependency edges whose dates disagree: `to` starts on or before\n * `from` ends. A gantt draws these arrows in the danger tone. */\nexport function dateConflicts(tasks) {\n const byId = new Map(tasks.map((t) => [t.id, t]));\n return dependencyEdges(tasks).filter(({ from, to }) => {\n const a = spanOf(byId.get(from));\n const b = spanOf(byId.get(to));\n return a && b && b.start <= a.end;\n });\n}\n\n/** The ids on the longest chain of dependent, scheduled work (length in\n * days, span to span): the critical path a gantt highlights. */\nexport function criticalPath(tasks) {\n const scheduled = tasks.filter((t) => spanOf(t));\n const byId = new Map(scheduled.map((t) => [t.id, t]));\n const memo = new Map();\n const visiting = new Set();\n const best = (id) => {\n if (memo.has(id)) return memo.get(id);\n if (visiting.has(id)) return { len: 0, path: [] };\n visiting.add(id);\n const t = byId.get(id);\n const s = spanOf(t);\n const own = diffDays(s.start, s.end) + 1;\n let top = { len: 0, path: [] };\n for (const d of t.dependsOn ?? []) {\n if (!byId.has(d)) continue;\n const r = best(d);\n if (r.len > top.len) top = r;\n }\n visiting.delete(id);\n const r = { len: top.len + own, path: [...top.path, id] };\n memo.set(id, r);\n return r;\n };\n let winner = { len: 0, path: [] };\n for (const t of scheduled) {\n const r = best(t.id);\n if (r.path.length > 1 && r.len > winner.len) winner = r;\n }\n return new Set(winner.path);\n}\n\n// ---- capacity ----------------------------------------------------------\n\n/** A task's hours on each working day of its span: estimate ÷ working\n * days, split evenly between its assignees. { day → hours } per person\n * is what workload() adds up. */\nexport function hoursPerDay(task, ctx) {\n const c = ctxOf(ctx);\n const s = spanOf(task);\n const est = Number(task?.estimate) || 0;\n if (!s || est <= 0) return 0;\n const opts = { weekend: c.weekend, holidays: c.holidays };\n const days = daysBetween(s.start, s.end).filter((d) => isWorkingDay(d, opts)).length || 1;\n return est / days / Math.max(1, (task.assignees ?? []).length);\n}\n\n/** Load against capacity per person over [from, to], by day or week:\n * { rows: [{ person, total, capacity, buckets: [{ key, start, end, hours,\n * capacity, over, tasks: [{ task, hours }] }] }], unassigned, unscheduled }.\n * `capacity` is the person's hours a day (default `defaultCapacity`, 8)\n * times the bucket's working days. Done tasks are left out unless\n * `includeDone`. */\nexport function workload(tasks, ctx, { from, to, unit = \"day\", defaultCapacity = 8, includeDone = false } = {}) {\n const c = ctxOf(ctx);\n const opts = { weekend: c.weekend, holidays: c.holidays };\n const start = dayOf(from) ?? c.today;\n const end = dayOf(to) ?? addDays(start, 13);\n const days = daysBetween(start, end);\n const bucketOf = (d) => (unit === \"week\" ? startOfWeek(d, c.weekStartsOn) : d);\n const bucketKeys = [...new Set(days.map(bucketOf))];\n const list = normalizeTasks(tasks).filter((t) => includeDone || !isDone(t, c));\n const rows = c.people.map((person) => {\n const cap = Number(person.capacity ?? defaultCapacity);\n const buckets = bucketKeys.map((key) => {\n const inside = days.filter((d) => bucketOf(d) === key);\n return { key, start: inside[0], end: inside[inside.length - 1], hours: 0, capacity: inside.filter((d) => isWorkingDay(d, opts)).length * cap, tasks: [] };\n });\n const byKey = new Map(buckets.map((b) => [b.key, b]));\n for (const t of list) {\n if (!t.assignees.includes(person.id)) continue;\n const s = spanOf(t);\n const per = hoursPerDay(t, c);\n if (!s || per === 0) continue;\n for (const d of daysBetween(s.start > start ? s.start : start, s.end < end ? s.end : end)) {\n if (!isWorkingDay(d, opts)) continue;\n const b = byKey.get(bucketOf(d));\n if (!b) continue;\n b.hours += per;\n const hit = b.tasks.find((x) => x.task.id === t.id);\n if (hit) hit.hours += per;\n else b.tasks.push({ task: t, hours: per });\n }\n }\n for (const b of buckets) {\n b.hours = Math.round(b.hours * 100) / 100;\n b.over = b.hours > b.capacity + 1e-9;\n }\n const total = buckets.reduce((s, b) => s + b.hours, 0);\n return { person, total: Math.round(total * 100) / 100, capacity: buckets.reduce((s, b) => s + b.capacity, 0), buckets };\n });\n return {\n rows,\n buckets: bucketKeys,\n unassigned: list.filter((t) => t.assignees.length === 0 && spanOf(t)),\n unscheduled: list.filter((t) => !spanOf(t)),\n };\n}\n\n/** The sum of a number field over tasks (estimate by default). */\nexport function sumOf(tasks, key = \"estimate\") {\n return (tasks ?? []).reduce((s, t) => s + (Number(t?.[key]) || 0), 0);\n}\n\n// ---- changes -----------------------------------------------------------\n//\n// A view never edits tasks. It builds a Change and hands it to\n// `onChange(change)` (async, answering { ok } — see useTaskChanges):\n//\n// { type: \"create\", task } or { type: \"create\", tasks: [...] }\n// { type: \"update\", id, patch } or { type: \"update\", ids: [...], patch }\n// { type: \"delete\", id } or { type: \"delete\", ids: [...] }\n// { type: \"batch\", changes: [...] } several at once (a renumber)\n\n/** The tasks after a change — what the screen shows before the save\n * answers. A create whose id exists is skipped (the page already has it). */\nexport function applyChange(tasks, change) {\n if (!change) return tasks;\n switch (change.type) {\n case \"create\": {\n const add = (change.tasks ?? [change.task]).filter(Boolean).map(normalizeTask);\n const ids = new Set(tasks.map((t) => String(t.id)));\n const fresh = add.filter((t) => !ids.has(t.id));\n return fresh.length ? [...tasks, ...fresh] : tasks;\n }\n case \"update\": {\n const ids = new Set((change.ids ?? [change.id]).map(String));\n return tasks.map((t) => (ids.has(String(t.id)) ? normalizeTask({ ...t, ...change.patch }) : t));\n }\n case \"delete\": {\n const ids = new Set((change.ids ?? [change.id]).map(String));\n return tasks.filter((t) => !ids.has(String(t.id)));\n }\n case \"batch\":\n return (change.changes ?? []).reduce(applyChange, tasks);\n default:\n return tasks;\n }\n}\n\n/** The change that undoes `change`, read against the tasks BEFORE it:\n * what an Undo in a toast commits. */\nexport function invertChange(tasks, change) {\n if (!change) return null;\n switch (change.type) {\n case \"create\":\n return { type: \"delete\", ids: (change.tasks ?? [change.task]).filter(Boolean).map((t) => String(t.id)) };\n case \"update\": {\n const ids = new Set((change.ids ?? [change.id]).map(String));\n const keys = Object.keys(change.patch ?? {});\n const changes = tasks\n .filter((t) => ids.has(String(t.id)))\n .map((t) => ({ type: \"update\", id: t.id, patch: Object.fromEntries(keys.map((k) => [k, t[k] ?? null])) }));\n return changes.length === 1 ? changes[0] : { type: \"batch\", changes };\n }\n case \"delete\": {\n const ids = new Set((change.ids ?? [change.id]).map(String));\n return { type: \"create\", tasks: tasks.filter((t) => ids.has(String(t.id))) };\n }\n case \"batch\": {\n const inverses = [];\n let state = tasks;\n for (const c of change.changes ?? []) {\n inverses.unshift(invertChange(state, c));\n state = applyChange(state, c);\n }\n return { type: \"batch\", changes: inverses.filter(Boolean) };\n }\n default:\n return null;\n }\n}\n\n/** A change as plain writes, one per row, in order — the loop a page runs\n * over insertRow / updateRow / deleteRow:\n * [{ op: \"insert\", values } | { op: \"update\", id, patch } | { op: \"delete\", id }].\n * `columns` keeps only the keys your table has (a new task carries\n * empty lists — tags, dependsOn — your table may not); `keepNewIds:\n * false` drops a created task's client id (`new-…`) so the table makes\n * its own. */\nexport function changeWrites(change, { columns, keepNewIds = true } = {}) {\n if (!change) return [];\n const pick = (obj) => (columns ? Object.fromEntries(Object.entries(obj ?? {}).filter(([k]) => columns.includes(k))) : obj);\n switch (change.type) {\n case \"create\":\n return (change.tasks ?? [change.task]).filter(Boolean).map((t) => {\n const { _sample, ...rest } = t;\n const values = pick(rest);\n if (!keepNewIds && isNewId(values.id)) delete values.id;\n return { op: \"insert\", values };\n });\n case \"update\":\n return (change.ids ?? [change.id]).map((id) => ({ op: \"update\", id, patch: pick(change.patch) }));\n case \"delete\":\n return (change.ids ?? [change.id]).map((id) => ({ op: \"delete\", id }));\n case \"batch\":\n return (change.changes ?? []).flatMap((c) => changeWrites(c, { columns, keepNewIds }));\n default:\n return [];\n }\n}\n\n/** Whether an id is one newTaskId() made (not yet the database's). */\nexport function isNewId(id) {\n return typeof id === \"string\" && id.startsWith(\"new-\");\n}\n\n/** The ids a change touches. */\nexport function changedIds(change) {\n return [...new Set(changeWrites(change).map((w) => String(w.id ?? w.values?.id)))];\n}\n\n/** A delete that takes the subtasks with it. */\nexport function deleteChange(tasks, ids) {\n const all = new Set();\n for (const id of ids) {\n all.add(String(id));\n for (const d of descendantIds(tasks, id)) all.add(d);\n }\n return { type: \"delete\", ids: [...all] };\n}\n\n// ---- adapters for the existing blocks ----------------------------------\n\n/** Board props from tasks: `toBoard(tasks, { groupBy: \"status\", ctx })` →\n * { rows, groupBy, columns, otherLabel, moveChange(row, to, from) }.\n * One card per task, under its first value (a task with two assignees\n * sits under the first). Spread rows / groupBy / columns / otherLabel\n * onto <Board> and send `moveChange` through your onChange:\n * onMove={(row, to, from) => commit(board.moveChange(row, to, from))}. */\nexport function toBoard(tasks, { groupBy = \"status\", ctx } = {}) {\n const c = ctxOf(ctx);\n const list = normalizeTasks(tasks);\n const options = optionsOf(groupBy, c, list);\n const none = options.find((o) => o.id === NONE);\n const date = fieldOf(groupBy, c).kind === \"date\";\n const columns = options.filter((o) => o.id !== NONE && !(date && (o.id === \"past\" || o.id === \"overdue\"))).map((o) => ({ key: o.id, label: o.label }));\n const rows = list.map((t) => ({ ...t, __group: valuesOf(t, groupBy, c)[0] }));\n return {\n rows,\n groupBy: \"__group\",\n columns,\n otherLabel: none?.label ?? \"None\",\n moveChange(row, to, from) {\n const task = list.find((t) => t.id === String(row.id)) ?? row;\n const patch = movePatch(task, groupBy, from ?? NONE, to, c);\n return patch ? { type: \"update\", id: task.id, patch } : null;\n },\n };\n}\n\n/** Flat rows for DataTable (or a CSV): the task plus its words —\n * statusLabel, statusIndex, assigneeNames, projectName, priorityLabel,\n * priorityRank, dueDay, dueBucket, parentTitle, subtasks (\"2/5\"), done.\n * DataTable sorts and searches these without knowing the model. */\nexport function toTableRows(tasks, ctx) {\n const c = ctxOf(ctx);\n const list = normalizeTasks(tasks);\n const byId = new Map(list.map((t) => [t.id, t]));\n const counts = subtaskCounts(list, c);\n return list.map((t) => {\n const st = c.statusById.get(String(t.status));\n const pr = PRIORITIES.findIndex((p) => p.id === t.priority);\n const sub = counts.get(t.id);\n return {\n ...t,\n statusLabel: st?.label ?? \"\",\n statusIndex: st?.index ?? null,\n assigneeNames: t.assignees.map((id) => c.personById.get(id)?.name ?? id).join(\", \"),\n projectName: c.projectById.get(String(t.project))?.name ?? \"\",\n priorityLabel: pr < 0 ? \"\" : PRIORITIES[pr].label,\n priorityRank: pr < 0 ? null : pr,\n dueDay: dayOf(t.due),\n dueBucket: valuesOf(t, \"due\", c)[0],\n parentTitle: t.parentId ? byId.get(t.parentId)?.title ?? \"\" : \"\",\n subtasks: sub ? `${sub.done}/${sub.total}` : \"\",\n done: isDone(t, c),\n };\n });\n}\n\n/** DataTable facets for toTableRows rows: status, assignee (first),\n * priority, project — each with the model's options and words. */\nexport function taskFacets(ctx, { keys = [\"status\", \"assignees\", \"priority\", \"project\"] } = {}) {\n const c = ctxOf(ctx);\n const facet = {\n status: { key: \"status\", label: \"Status\", options: c.statuses.map((s) => ({ value: s.id, label: s.label })) },\n assignees: { key: \"assignees\", label: \"Assignee\", value: (r) => r.assignees?.[0] ?? \"\", options: [...c.people.map((p) => ({ value: p.id, label: p.name })), { value: \"\", label: \"Unassigned\" }] },\n priority: { key: \"priority\", label: \"Priority\", options: [...PRIORITIES.map((p) => ({ value: p.id, label: p.label })), { value: \"\", label: \"No priority\" }] },\n project: { key: \"project\", label: \"Project\", options: [...c.projects.map((p) => ({ value: p.id, label: p.name })), { value: \"\", label: \"No project\" }] },\n };\n return keys.map((k) => facet[k]).filter(Boolean);\n}\n"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "registry/blocks/task-model/dates.js",
|
|
30
|
+
"type": "registry:component",
|
|
31
|
+
"target": "@components/makersclaw/blocks/task-model/dates.js",
|
|
32
|
+
"content": "// Date math for work items — pure, no React, no Date objects in or out.\n//\n// A task's `start` and `due` are wall-calendar strings: a day\n// \"2026-09-23\", or a day and a time \"2026-09-23T14:30\" (no zone: a\n// meeting at 14:30 is at 14:30 wherever the founder is). Strings compare\n// in order, survive JSON and a `date` column unchanged, and mean the same\n// day everywhere. Arithmetic runs on day numbers (days since 1970-01-01,\n// counted on UTC midnights), where no daylight-saving shift can add or\n// lose a day. Calendar, gantt, workload and roadmap all read these.\n\nconst DAY_RE = /^(\\d{4})-(\\d{2})-(\\d{2})/;\nconst TIME_RE = /T(\\d{2}):(\\d{2})/;\nconst MS = 86400000;\nconst pad = (n, w = 2) => String(n).padStart(w, \"0\");\n\n/** A real \"YYYY-MM-DD\" (a time after it is allowed). */\nexport function isDay(value) {\n return dayNumber(value) !== null;\n}\n\n/** Days since 1970-01-01 for a day string (or datetime string, or Date);\n * null when it is not a real day. */\nexport function dayNumber(value) {\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null;\n return Math.floor(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS);\n }\n const m = typeof value === \"string\" ? DAY_RE.exec(value) : null;\n if (!m) return null;\n const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);\n const d = new Date(t);\n if (d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) return null;\n return Math.round(t / MS);\n}\n\n/** The day string for a day number. */\nexport function fromDayNumber(n) {\n const d = new Date(n * MS);\n return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;\n}\n\n/** \"2026-09-23T14:30\" → \"2026-09-23\"; a Date → its local day; else null. */\nexport function dayOf(value) {\n const n = dayNumber(value);\n return n === null ? null : fromDayNumber(n);\n}\n\n/** Minutes after midnight for a datetime string (\"…T14:30\" → 870); null\n * for a plain day (an all-day item). */\nexport function minutesOf(value) {\n const m = typeof value === \"string\" ? TIME_RE.exec(value) : null;\n return m ? +m[1] * 60 + +m[2] : null;\n}\n\n/** A day and minutes after midnight → \"YYYY-MM-DDTHH:mm\"; null minutes →\n * the plain day. */\nexport function withTime(day, minutes) {\n const d = dayOf(day);\n if (!d) return null;\n if (minutes == null) return d;\n const m = Math.max(0, Math.min(24 * 60 - 1, Math.round(minutes)));\n return `${d}T${pad(Math.floor(m / 60))}:${pad(m % 60)}`;\n}\n\n/** Today in the browser's zone, or `now`'s. Pass a fixed `today` string to\n * the views where a screen must not change from day to day (a demo). */\nexport function todayDay(now = new Date()) {\n return dayOf(now);\n}\n\nexport function addDays(day, n) {\n const d = dayNumber(day);\n return d === null ? null : fromDayNumber(d + n);\n}\n\n/** b − a in days (\"2026-09-01\", \"2026-09-03\" → 2). */\nexport function diffDays(a, b) {\n const x = dayNumber(a);\n const y = dayNumber(b);\n return x === null || y === null ? null : y - x;\n}\n\n/** 0 = Sunday … 6 = Saturday. */\nexport function weekdayOf(day) {\n const n = dayNumber(day);\n return n === null ? null : (n + 4) % 7; // 1970-01-01 was a Thursday\n}\n\nexport function startOfWeek(day, weekStartsOn = 1) {\n const w = weekdayOf(day);\n return w === null ? null : addDays(day, -((w - weekStartsOn + 7) % 7));\n}\n\nexport function endOfWeek(day, weekStartsOn = 1) {\n const s = startOfWeek(day, weekStartsOn);\n return s && addDays(s, 6);\n}\n\n/** The seven days of `day`'s week. */\nexport function weekDays(day, weekStartsOn = 1) {\n const s = startOfWeek(day, weekStartsOn);\n return s ? Array.from({ length: 7 }, (_, i) => addDays(s, i)) : [];\n}\n\nexport function startOfMonth(day) {\n const d = dayOf(day);\n return d ? `${d.slice(0, 8)}01` : null;\n}\n\nexport function endOfMonth(day) {\n const s = startOfMonth(day);\n return s ? addDays(addMonths(s, 1), -1) : null;\n}\n\n/** The same day n months on, clamped to the month's end (Jan 31 + 1 → Feb 28). */\nexport function addMonths(day, n) {\n const d = dayOf(day);\n if (!d) return null;\n const y = +d.slice(0, 4);\n const m = +d.slice(5, 7) - 1 + n;\n const ty = y + Math.floor(m / 12);\n const tm = ((m % 12) + 12) % 12;\n const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();\n return `${ty}-${pad(tm + 1)}-${pad(Math.min(+d.slice(8, 10), last))}`;\n}\n\n/** Every day from a to b, both included. */\nexport function daysBetween(a, b) {\n const x = dayNumber(a);\n const y = dayNumber(b);\n if (x === null || y === null || y < x) return [];\n return Array.from({ length: y - x + 1 }, (_, i) => fromDayNumber(x + i));\n}\n\n/** A month as weeks of seven days, for a month grid: the weeks that hold\n * any of its days (4–6), or always six with `fixedWeeks` (a grid that\n * never changes height). Days outside the month are included; compare\n * `day.slice(0, 7)` with the month to mute them. */\nexport function monthGrid(monthOrDay, { weekStartsOn = 1, fixedWeeks = false } = {}) {\n const first = startOfMonth(/^\\d{4}-\\d{2}$/.test(monthOrDay ?? \"\") ? `${monthOrDay}-01` : monthOrDay);\n if (!first) return [];\n const last = endOfMonth(first);\n let cursor = startOfWeek(first, weekStartsOn);\n const weeks = [];\n while (weeks.length < 6 && (fixedWeeks || cursor <= last)) {\n weeks.push(Array.from({ length: 7 }, (_, i) => addDays(cursor, i)));\n cursor = addDays(cursor, 7);\n }\n return weeks;\n}\n\n/** Weekend days as weekday numbers; default Saturday and Sunday. */\nexport const WEEKEND = [0, 6];\n\nexport function isWeekend(day, weekend = WEEKEND) {\n return weekend.includes(weekdayOf(day));\n}\n\n/** Not a weekend and not in `holidays` (a list of day strings). */\nexport function isWorkingDay(day, { weekend = WEEKEND, holidays } = {}) {\n const d = dayOf(day);\n if (!d || isWeekend(d, weekend)) return false;\n return !(holidays && (holidays instanceof Set ? holidays.has(d) : holidays.includes(d)));\n}\n\n/** Working days from a to b, both included (0 when b is before a). */\nexport function workingDaysBetween(a, b, options) {\n return daysBetween(a, b).filter((d) => isWorkingDay(d, options)).length;\n}\n\n/** n working days after `day` (n may be negative; 0 → `day` itself, or\n * the next working day when `day` is not one). */\nexport function addWorkingDays(day, n, options) {\n let d = dayOf(day);\n if (!d) return null;\n const step = n < 0 ? -1 : 1;\n while (!isWorkingDay(d, options)) d = addDays(d, step);\n let left = Math.abs(n);\n while (left > 0) {\n d = addDays(d, step);\n if (isWorkingDay(d, options)) left--;\n }\n return d;\n}\n\n/** A task's days on a calendar: { start, end } (day strings, start ≤\n * end). One date → a one-day span; neither → null (unscheduled). */\nexport function spanOf(task) {\n const s = dayOf(task?.start);\n const e = dayOf(task?.due);\n if (!s && !e) return null;\n const a = s ?? e;\n const b = e ?? s;\n return a <= b ? { start: a, end: b } : { start: b, end: a };\n}\n\n/** Whether two spans ({ start, end }, inclusive) share a day. */\nexport function spansOverlap(a, b) {\n return Boolean(a && b && a.start <= b.end && b.start <= a.end);\n}\n\n/** The tasks whose span touches [from, to] (inclusive). */\nexport function tasksInRange(tasks, from, to) {\n const range = { start: dayOf(from), end: dayOf(to) };\n return (tasks ?? []).filter((t) => spansOverlap(spanOf(t), range));\n}\n\n/** The tasks on one day. */\nexport function tasksOnDay(tasks, day) {\n return tasksInRange(tasks, day, day);\n}\n\n/** Moves a task's dates by n days, keeping its length and its times:\n * the patch a drag on a calendar or a gantt saves. */\nexport function shiftDates(task, n) {\n const patch = {};\n for (const key of [\"start\", \"due\"]) {\n const v = task?.[key];\n if (!isDay(v)) continue;\n patch[key] = withTime(addDays(v, n), minutesOf(v));\n }\n return patch;\n}\n\n/** The patch that gives a task the span [start, end] (a resize, a drop on\n * a range), keeping any times it had. A one-day task with only `due`\n * stays that way when start === end. */\nexport function spanPatch(task, start, end) {\n if (start === end && !isDay(task?.start)) return { due: withTime(end, minutesOf(task?.due)) };\n return { start: withTime(start, minutesOf(task?.start)), due: withTime(end, minutesOf(task?.due)) };\n}\n\n/** Where a due date sits from `today`: \"overdue\" · \"today\" · \"tomorrow\" ·\n * \"this-week\" · \"next-week\" · \"later\" · \"none\". A done task is never\n * overdue: `done` turns \"overdue\" into \"past\". */\nexport function dueBucket(due, today, { done = false, weekStartsOn = 1 } = {}) {\n const d = dayOf(due);\n if (!d) return \"none\";\n const n = diffDays(today, d);\n if (n < 0) return done ? \"past\" : \"overdue\";\n if (n === 0) return \"today\";\n if (n === 1) return \"tomorrow\";\n const weekEnd = endOfWeek(today, weekStartsOn);\n if (d <= weekEnd) return \"this-week\";\n if (d <= addDays(weekEnd, 7)) return \"next-week\";\n return \"later\";\n}\n\n/** The due buckets in order, with their words: the groups of \"group by\n * due date\" and the choices of a due filter. */\nexport const DUE_BUCKETS = [\n { id: \"overdue\", label: \"Overdue\", tone: \"danger\" },\n { id: \"today\", label: \"Today\", tone: \"warning\" },\n { id: \"tomorrow\", label: \"Tomorrow\" },\n { id: \"this-week\", label: \"This week\" },\n { id: \"next-week\", label: \"Next week\" },\n { id: \"later\", label: \"Later\" },\n { id: \"past\", label: \"Earlier\" },\n { id: \"none\", label: \"No date\" },\n];\n\n/** The day a task dropped into a due bucket gets (null for \"none\"; the\n * task's own date for a bucket that has no single day). */\nexport function dayForBucket(bucket, today, { weekStartsOn = 1 } = {}) {\n switch (bucket) {\n case \"today\":\n return today;\n case \"tomorrow\":\n return addDays(today, 1);\n case \"this-week\": {\n const end = endOfWeek(today, weekStartsOn);\n return diffDays(today, end) >= 2 ? addDays(today, 2) : end;\n }\n case \"next-week\":\n return addDays(endOfWeek(today, weekStartsOn), 1);\n case \"later\":\n return addDays(endOfWeek(today, weekStartsOn), 8);\n case \"none\":\n return null;\n default:\n return undefined; // overdue / past: not a place to drop\n }\n}\n\nfunction fmt(day, options, locale) {\n const n = dayNumber(day);\n return n === null ? \"\" : new Intl.DateTimeFormat(locale, { ...options, timeZone: \"UTC\" }).format(new Date(n * MS));\n}\n\n/** A day the way a person says it, from `today`: \"Today\", \"Tomorrow\",\n * \"Yesterday\", a weekday within the week either side (\"Fri\"), else\n * \"Sep 30\" (with the year when it is not this year's). */\nexport function formatDay(day, today, { locale, weekday = \"short\" } = {}) {\n const d = dayOf(day);\n if (!d) return \"\";\n const n = today ? diffDays(today, d) : null;\n if (n === 0) return \"Today\";\n if (n === 1) return \"Tomorrow\";\n if (n === -1) return \"Yesterday\";\n if (n !== null && n > 1 && n < 7) return fmt(d, { weekday }, locale);\n const sameYear = today && d.slice(0, 4) === dayOf(today).slice(0, 4);\n return fmt(d, sameYear ? { month: \"short\", day: \"numeric\" } : { month: \"short\", day: \"numeric\", year: \"numeric\" }, locale);\n}\n\n/** A time the locale's way (\"2:30 PM\", \"14:30\"); \"\" for a plain day. */\nexport function formatTime(value, { locale } = {}) {\n const m = minutesOf(value);\n if (m === null) return \"\";\n return new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", timeZone: \"UTC\" }).format(new Date(m * 60000));\n}\n\n/** A day in any Intl shape: formatDate(\"2026-09-23\", { weekday: \"long\" }). */\nexport function formatDate(day, options = { dateStyle: \"medium\" }, { locale } = {}) {\n return fmt(day, options, locale);\n}\n\n/** The time scale of a timeline: columns of `unit` (day · week · month ·\n * quarter) covering [from, to], each { start, end, label, key }. A gantt\n * draws its header from it, a workload its columns. */\nexport function timeScale(from, to, unit = \"day\", { weekStartsOn = 1, locale } = {}) {\n const a = dayOf(from);\n const b = dayOf(to);\n if (!a || !b || b < a) return [];\n const out = [];\n let cursor =\n unit === \"week\" ? startOfWeek(a, weekStartsOn) : unit === \"month\" ? startOfMonth(a) : unit === \"quarter\" ? quarterStart(a) : a;\n while (cursor <= b && out.length < 2000) {\n let end;\n let label;\n if (unit === \"week\") {\n end = addDays(cursor, 6);\n label = fmt(cursor, { month: \"short\", day: \"numeric\" }, locale);\n } else if (unit === \"month\") {\n end = endOfMonth(cursor);\n label = fmt(cursor, { month: \"short\", year: cursor.slice(0, 4) === a.slice(0, 4) ? undefined : \"numeric\" }, locale);\n } else if (unit === \"quarter\") {\n end = addDays(addMonths(cursor, 3), -1);\n label = `Q${Math.floor((+cursor.slice(5, 7) - 1) / 3) + 1} ${cursor.slice(0, 4)}`;\n } else {\n end = cursor;\n label = fmt(cursor, { day: \"numeric\" }, locale);\n }\n out.push({ key: cursor, start: cursor, end, label });\n cursor = addDays(end, 1);\n }\n return out;\n}\n\nfunction quarterStart(day) {\n const m = +day.slice(5, 7);\n return `${day.slice(0, 4)}-${pad(Math.floor((m - 1) / 3) * 3 + 1)}-01`;\n}\n\n/** The first and last day any task touches, padded by `pad` days either\n * side — a timeline's default window. `fallback` (a day) when no task has\n * a date. */\nexport function rangeOfTasks(tasks, { pad: padDays = 0, fallback } = {}) {\n let lo = null;\n let hi = null;\n for (const t of tasks ?? []) {\n const s = spanOf(t);\n if (!s) continue;\n if (lo === null || s.start < lo) lo = s.start;\n if (hi === null || s.end > hi) hi = s.end;\n }\n if (lo === null) {\n const f = dayOf(fallback) ?? todayDay();\n return { start: addDays(f, -padDays), end: addDays(f, padDays) };\n }\n return { start: addDays(lo, -padDays), end: addDays(hi, padDays) };\n}\n"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "registry/blocks/task-model/changes.js",
|
|
36
|
+
"type": "registry:component",
|
|
37
|
+
"target": "@components/makersclaw/blocks/task-model/changes.js",
|
|
38
|
+
"content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { isSample } from \"../../parts\";\nimport { toast } from \"../../ui\";\nimport { applyChange, changeWrites, changedIds, invertChange, normalizeTasks } from \"./model\";\n\n// useTaskChanges — how every view reports a change, and shows it at once.\n//\n// const { tasks, commit, error, clearError } = useTaskChanges(props.tasks, props.onChange);\n// commit({ type: \"update\", id, patch: { status: \"done\" } }, { label: \"“Write brief”\" });\n//\n// `commit` applies the change on screen, calls the page's\n// `onChange(change)` (async, answering { ok } or { ok: false, error }; a\n// throw counts as failed), and on a failure takes the change back and\n// sets `error` — the view shows it in a Notice. The change stays on\n// screen until the page's `tasks` carry it (the page re-read, or set its\n// own state), so nothing flickers back between the save and the re-read.\n//\n// `undo: \"Task deleted\"` also shows a toast with an Undo button, which\n// commits the inverse (the deleted tasks come back with their ids).\n//\n// Example rows (`useFixture`'s, `_sample: true`) change on screen and are\n// never sent: they are not rows.\n\nlet seq = 0;\n\nexport function useTaskChanges(tasks, onChange) {\n const base = useMemo(() => normalizeTasks(tasks), [tasks]);\n const baseRef = useRef(base);\n baseRef.current = base;\n const [pending, setPending] = useState([]);\n const [error, setError] = useState(null);\n\n // A confirmed change has done its job once the page's tasks move on.\n useEffect(() => {\n setPending((list) => {\n const next = list.filter((e) => !(e.confirmed && e.base !== base));\n return next.length === list.length ? list : next;\n });\n }, [base]);\n\n const shown = useMemo(() => pending.reduce((acc, e) => applyChange(acc, e.change), base), [base, pending]);\n const latest = useRef(shown);\n latest.current = shown;\n\n const commit = useCallback(\n async (change, { label, undo } = {}) => {\n // A null change is a drop the model refused (movePatch → null).\n if (!change) return { ok: false, error: \"it cannot go there\" };\n const before = latest.current;\n const key = ++seq;\n const entry = { key, change, base: baseRef.current, confirmed: false };\n setError(null);\n setPending((list) => [...list, entry]);\n const ids = new Set(changedIds(change));\n const touched = before.filter((t) => ids.has(String(t.id)));\n const creates = changeWrites(change).some((w) => w.op === \"insert\");\n const onlySamples = !creates && touched.length > 0 && touched.every(isSample);\n let result;\n if (onlySamples || typeof onChange !== \"function\") result = { ok: true, sample: true };\n else {\n try {\n result = (await onChange(change)) ?? { ok: true };\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n }\n if (result.ok === false) {\n setPending((list) => list.filter((e) => e.key !== key));\n setError(`${label ?? \"The change\"} was not saved: ${result.error ?? \"the write failed\"}. It is back as it was.`);\n return result;\n }\n setPending((list) =>\n list.map((e) => (e.key === key ? { ...e, confirmed: true } : e)).filter((e) => !(e.confirmed && e.base !== baseRef.current)),\n );\n if (undo) {\n const inverse = invertChange(before, change);\n toast(undo, {\n action: { label: \"Undo\", onClick: () => commit(inverse, { label: \"Undo\" }) },\n });\n }\n return result;\n },\n [onChange],\n );\n\n return { tasks: shown, commit, error, clearError: () => setError(null), saving: pending.some((e) => !e.confirmed) };\n}\n"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "registry/blocks/task-model/parts.jsx",
|
|
42
|
+
"type": "registry:component",
|
|
43
|
+
"target": "@components/makersclaw/blocks/task-model/parts.jsx",
|
|
44
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Avatar, Button, Calendar, Icon, Menu, Popover, StatusDot, cx } from \"../../ui\";\nimport { addDays, dayOf, dueBucket, endOfWeek, formatDay, formatTime, minutesOf, withTime } from \"./dates\";\nimport { NONE, PRIORITIES, createTaskContext, isDone } from \"./model\";\n\n// The small pieces every task view draws the same way: a status, the\n// people on it, its due date, its priority, a compact chip. Each is a\n// label by default and a picker when given `onChange` — the view hands\n// the picked value to its commit. Tokens only: a status says its tone,\n// a project its hue (chart-1…5).\n\nconst ctxOf = (ctx) => (ctx && ctx.statusById ? ctx : createTaskContext(ctx ?? {}));\n\nexport const TONE_TEXT = {\n neutral: \"text-muted-foreground\",\n primary: \"text-primary\",\n info: \"text-info\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-destructive\",\n};\n\nexport const HUE_EDGE = {\n 1: \"border-s-chart-1\",\n 2: \"border-s-chart-2\",\n 3: \"border-s-chart-3\",\n 4: \"border-s-chart-4\",\n 5: \"border-s-chart-5\",\n};\n\nexport const HUE_BG = {\n 1: \"bg-chart-1\",\n 2: \"bg-chart-2\",\n 3: \"bg-chart-3\",\n 4: \"bg-chart-4\",\n 5: \"bg-chart-5\",\n};\n\n// A trigger that looks like its label until hovered: the pickers' face.\nconst PICK = \"rounded-md px-1.5 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\";\n\n/** A status as a dot and its words. With `onChange(id)`, a menu of the\n * statuses. `size` sm (a row) · md. `hideLabel` for a dense cell. */\nexport function StatusPill({ status, ctx, onChange, size = \"sm\", hideLabel = false, className }) {\n const c = ctxOf(ctx);\n const s = c.statusById.get(String(status));\n const face = (\n <span className={cx(\"relative inline-flex min-w-0 items-center gap-1.5\", size === \"sm\" ? \"text-xs\" : \"text-sm\")}>\n <StatusDot tone={s ? s.tone ?? (s.hue ? undefined : \"neutral\") : \"neutral\"} hue={s?.hue} size={size === \"sm\" ? \"sm\" : \"md\"} />\n <span className={cx(\"truncate\", hideLabel && \"sr-only\", s ? \"text-foreground\" : \"text-muted-foreground\")}>{s?.label ?? \"No status\"}</span>\n </span>\n );\n if (!onChange) return <span className={cx(\"inline-flex min-w-0\", className)}>{face}</span>;\n return (\n <Menu\n label={`Status: ${s?.label ?? \"none\"}. Change status`}\n trigger={face}\n align=\"start\"\n className={cx(PICK, className)}\n items={[\n {\n type: \"radio\",\n value: s?.id ?? \"\",\n onValueChange: (v) => v !== s?.id && onChange(v),\n options: c.statuses.map((x) => ({ value: x.id, label: x.label })),\n },\n ]}\n />\n );\n}\n\n/** The round check that completes a task (not the square one that\n * selects it). Shows the done state; `onToggle()` when pressed. */\nexport function CompleteToggle({ done, onToggle, label = \"task\", disabled, size = \"md\", className }) {\n const box = size === \"sm\" ? \"size-4\" : \"size-[18px]\";\n return (\n // raw: a completion check is round and toggles a status, not a form\n // value — the kit's Checkbox is the square selection box beside it.\n <button\n type=\"button\"\n role=\"checkbox\"\n aria-checked={Boolean(done)}\n aria-label={done ? `Mark ${label} not done` : `Complete ${label}`}\n disabled={disabled || !onToggle}\n onClick={onToggle}\n className={cx(\n \"group/check inline-flex size-9 shrink-0 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default\",\n className,\n )}\n >\n <span\n className={cx(\n \"inline-flex items-center justify-center rounded-full border transition-colors duration-150\",\n box,\n done ? \"border-success bg-success text-background\" : \"border-muted-foreground/50 text-transparent group-hover/check:border-success group-hover/check:text-success\",\n )}\n >\n <Icon name=\"check\" size={size === \"sm\" ? 10 : 12} />\n </span>\n </button>\n );\n}\n\n/** The people on a task: overlapping faces, or a dashed \"assign\" circle.\n * With `onChange(ids)`, a menu of people to tick (it stays open). */\nexport function AssigneeStack({ ids, ctx, onChange, max = 3, size = \"xs\", className }) {\n const c = ctxOf(ctx);\n const list = (ids ?? []).map((id) => c.personById.get(String(id)) ?? { id, name: String(id) });\n // Spans, not the kit's AvatarGroup (a div): the faces sit inside the\n // picker's button, where a div is invalid HTML.\n const shown = list.slice(0, max);\n const rest = list.length - shown.length;\n const face = list.length ? (\n <span className=\"inline-flex items-center -space-x-1.5\" title={list.map((p) => p.name).join(\", \")}>\n {shown.map((p) => (\n <Avatar key={p.id} name={p.name} src={p.src} size={size} className=\"ring-2 ring-card\" />\n ))}\n {rest > 0 ? (\n <span data-nums className=\"relative inline-flex size-6 items-center justify-center rounded-full bg-muted text-2xs font-medium text-muted-foreground ring-2 ring-card\">\n +{rest}\n </span>\n ) : null}\n </span>\n ) : (\n <span aria-hidden=\"true\" className=\"inline-flex size-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground\">\n <Icon name=\"user\" size={12} />\n </span>\n );\n if (!onChange) {\n return list.length ? (\n <span className={cx(\"inline-flex\", className)}>{face}</span>\n ) : (\n <span className={cx(\"relative inline-flex\", className)}>\n {face}\n <span className=\"sr-only\">Unassigned</span>\n </span>\n );\n }\n const set = new Set((ids ?? []).map(String));\n return (\n <Menu\n label={list.length ? `Assigned to ${list.map((p) => p.name).join(\", \")}. Change` : \"Assign\"}\n trigger={face}\n className={cx(PICK, className)}\n items={\n c.people.length\n ? c.people.map((p) => ({\n type: \"checkbox\",\n label: p.name,\n checked: set.has(p.id),\n onCheckedChange: (on) => onChange(on ? [...set, p.id] : [...set].filter((x) => x !== p.id)),\n }))\n : [{ type: \"label\", label: \"No people to assign yet\" }]\n }\n />\n );\n}\n\n/** One person: a face and a name (a group header, a workload row). */\nexport function PersonLabel({ id, ctx, size = \"xs\", className }) {\n const c = ctxOf(ctx);\n if (!id || id === NONE) {\n return (\n <span className={cx(\"inline-flex items-center gap-2\", className)}>\n <span aria-hidden=\"true\" className=\"inline-flex size-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground\">\n <Icon name=\"user\" size={12} />\n </span>\n <span className=\"truncate\">Unassigned</span>\n </span>\n );\n }\n const p = c.personById.get(String(id)) ?? { name: String(id) };\n return (\n <span className={cx(\"inline-flex min-w-0 items-center gap-2\", className)}>\n <Avatar name={p.name} src={p.src} size={size} />\n <span className=\"truncate\">{p.name}</span>\n </span>\n );\n}\n\nconst DUE_TONE = { overdue: \"text-destructive\", today: \"text-warning\" };\n\n/** A due date the way a person says it (\"Today\", \"Fri\", \"Oct 30\"), red\n * when overdue and amber today — unless the task is done. With\n * `onChange(day | null)`, a calendar with quick picks; an empty one\n * shows only its glyph until the row is hovered (`quiet`, default) —\n * pass quiet={false} to always say `placeholder`. */\nexport function DueLabel({ value, done = false, ctx, onChange, placeholder = \"Due date\", quiet = true, className }) {\n const c = ctxOf(ctx);\n const [open, setOpen] = useState(false);\n const day = dayOf(value);\n const bucket = dueBucket(value, c.today, { done, weekStartsOn: c.weekStartsOn });\n const time = formatTime(value);\n const words = day ? `${formatDay(day, c.today)}${time ? ` ${time}` : \"\"}` : \"\";\n const face = (\n <span className={cx(\"inline-flex items-center gap-1 whitespace-nowrap text-xs\", day ? DUE_TONE[bucket] ?? \"text-muted-foreground\" : \"text-muted-foreground\")}>\n <Icon name=\"calendar\" size={14} className={day ? undefined : \"opacity-60\"} />\n {day ? (\n <span data-nums>{words}</span>\n ) : onChange ? (\n <span className={quiet ? \"sr-only sm:not-sr-only sm:opacity-0 sm:transition-opacity sm:duration-150 sm:group-hover/row:opacity-100 sm:group-focus-within/row:opacity-100\" : undefined}>{placeholder}</span>\n ) : null}\n </span>\n );\n if (!onChange) {\n return day ? (\n <span className={cx(\"inline-flex\", className)} title={bucket === \"overdue\" ? \"Overdue\" : undefined}>\n {face}\n </span>\n ) : null;\n }\n const pick = (d) => {\n setOpen(false);\n onChange(d ? withTime(d, minutesOf(value)) : null);\n };\n const quick = [\n { label: \"Today\", day: c.today },\n { label: \"Tomorrow\", day: addDays(c.today, 1) },\n { label: \"Next week\", day: addDays(endOfWeek(c.today, c.weekStartsOn), 1) },\n ];\n return (\n <Popover\n label={day ? `Due ${words}. Change due date` : \"Set a due date\"}\n open={open}\n onOpenChange={setOpen}\n width=\"auto\"\n align=\"end\"\n className=\"p-2\"\n trigger={(props) => (\n // raw: Popover hands its trigger props (ref, popoverTarget) to a\n // button of ours; the face is a date's words, not a kit Button.\n <button type=\"button\" {...props} aria-label={day ? `Due ${words}. Change due date` : \"Set a due date\"} className={cx(\"inline-flex min-h-9 items-center outline-none\", PICK, className)}>\n {face}\n </button>\n )}\n >\n <div className=\"grid gap-2\">\n <div className=\"flex flex-wrap gap-1\">\n {quick.map((q) => (\n <Button key={q.label} size=\"sm\" variant={q.day === day ? \"secondary\" : \"quiet\"} onClick={() => pick(q.day)}>\n {q.label}\n </Button>\n ))}\n </div>\n <Calendar value={day} onChange={(d) => pick(d)} today={c.today} weekStartsOn={c.weekStartsOn} size=\"sm\" autoFocus={open} label=\"Due date\" />\n {day ? (\n <Button size=\"sm\" variant=\"quiet\" icon=\"x\" align=\"start\" onClick={() => pick(null)}>\n Remove due date\n </Button>\n ) : null}\n </div>\n </Popover>\n );\n}\n\n/** A priority as a flag in its tone. With `onChange(id | null)`, a menu. */\nexport function PriorityFlag({ value, onChange, showLabel = false, className }) {\n const p = PRIORITIES.find((x) => x.id === value);\n const face = (\n <span className={cx(\"relative inline-flex items-center gap-1 text-xs\", p ? TONE_TEXT[p.tone] : \"text-muted-foreground\")}>\n <Icon name=\"flag\" size={14} className={p ? undefined : \"opacity-40\"} />\n {showLabel && p ? <span className=\"text-foreground\">{p.label}</span> : null}\n {!p ? <span className=\"sr-only\">No priority</span> : null}\n </span>\n );\n if (!onChange) {\n if (!p) return null;\n return (\n // relative: its sr-only label is absolute, and without a positioned\n // parent inside a sideways-scrolling row it escaped the scroller and\n // widened the page (task-model at 390px, 2026-09-24).\n <span className={cx(\"relative inline-flex\", className)} title={`${p.label} priority`}>\n {face}\n <span className=\"sr-only\">{p.label} priority</span>\n </span>\n );\n }\n return (\n <Menu\n label={p ? `${p.label} priority. Change priority` : \"Set priority\"}\n trigger={face}\n className={cx(PICK, !p && \"opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 group-focus-within/row:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100\", className)}\n items={[\n {\n type: \"radio\",\n value: p?.id ?? NONE,\n onValueChange: (v) => onChange(v === NONE ? null : v),\n options: [...PRIORITIES.map((x) => ({ value: x.id, label: x.label })), { value: NONE, label: \"No priority\" }],\n },\n ]}\n />\n );\n}\n\n/** Tags as quiet chips; the first `max`, then \"+N\". */\nexport function TagList({ tags, max = 2, className }) {\n const list = tags ?? [];\n if (!list.length) return null;\n const shown = list.slice(0, max);\n const rest = list.length - shown.length;\n return (\n <span className={cx(\"inline-flex min-w-0 items-center gap-1\", className)}>\n {shown.map((t) => (\n <span key={t} className=\"max-w-28 truncate rounded-sm bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground\">\n {t}\n </span>\n ))}\n {rest > 0 ? (\n <span className=\"text-2xs text-muted-foreground\" title={list.slice(max).join(\", \")}>\n +{rest}\n </span>\n ) : null}\n </span>\n );\n}\n\n/** \"2/5\" subtasks done, with the list glyph. */\nexport function SubtaskCount({ done, total, className }) {\n if (!total) return null;\n return (\n <span className={cx(\"inline-flex items-center gap-1 text-xs text-muted-foreground\", className)} title={`${done} of ${total} subtasks done`}>\n <Icon name=\"list\" size={14} />\n <span data-nums>\n {done}/{total}\n </span>\n </span>\n );\n}\n\n/** A thin progress line (0–100) for a row or a bar. */\nexport function ProgressLine({ value, className }) {\n const v = Math.max(0, Math.min(100, Number(value) || 0));\n return (\n <span className={cx(\"relative block h-1 w-full overflow-hidden rounded-full bg-muted\", className)} role=\"img\" aria-label={`${Math.round(v)}% done`}>\n <span className={cx(\"absolute inset-y-0 start-0 rounded-full\", v >= 100 ? \"bg-success\" : \"bg-primary\")} style={{ width: `${v}%` }} />\n </span>\n );\n}\n\n/** A task as one compact line: the chip a calendar day, a gantt label or\n * a workload cell draws. Status dot, title, time (when it has one), the\n * first face; a project's hue on its leading edge. `onOpen(task)` makes\n * it a button. Spread drag props (`dragProps`) onto it for dragging. */\nexport function TaskChip({ task, ctx, onOpen, showTime = true, showPeople = true, dragging = false, lifted = false, dragProps, className }) {\n const c = ctxOf(ctx);\n const s = c.statusById.get(String(task.status));\n const project = c.projectById.get(String(task.project));\n const done = isDone(task, c);\n const time = showTime ? formatTime(task.start ?? task.due) : \"\";\n const first = task.assignees?.[0] ? c.personById.get(task.assignees[0]) : null;\n const Tag = onOpen ? \"button\" : \"div\";\n return (\n // raw: a chip is a draggable, openable card-let; a kit Button would\n // centre and pad it as an action.\n <Tag\n type={onOpen ? \"button\" : undefined}\n onClick={onOpen ? () => onOpen(task) : undefined}\n {...dragProps}\n className={cx(\n \"flex w-full min-w-0 items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-left text-xs text-card-foreground outline-none transition-[box-shadow,opacity] duration-150\",\n project?.hue && HUE_EDGE[project.hue] ? cx(\"border-s-[3px]\", HUE_EDGE[project.hue]) : null,\n onOpen && \"hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\",\n dragging && \"opacity-40\",\n lifted && \"cursor-grabbing shadow-lg ring-1 ring-border\",\n className,\n )}\n >\n <StatusDot tone={s?.tone ?? (s?.hue ? undefined : \"neutral\")} hue={s?.hue} size=\"sm\" />\n {time ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {time}\n </span>\n ) : null}\n <span className={cx(\"min-w-0 flex-1 truncate font-medium\", done && \"text-muted-foreground line-through\")}>{task.title || \"Untitled\"}</span>\n {showPeople && first ? <Avatar name={first.name} src={first.src} size=\"xs\" /> : null}\n </Tag>\n );\n}\n\n/** A group's heading face: the status dot, a person, a flag, a project\n * hue, or the plain words — whatever the view is grouped by. */\nexport function GroupLabel({ group, by, ctx, className }) {\n const c = ctxOf(ctx);\n const kind = by ? c.fields.find((f) => f.key === by)?.kind : null;\n if (kind === \"people\") return <PersonLabel id={group.key} ctx={c} className={cx(\"text-sm font-semibold\", className)} />;\n let lead = null;\n if (kind === \"status\") lead = <StatusDot tone={group.tone ?? (group.hue ? undefined : \"neutral\")} hue={group.hue} />;\n else if (kind === \"priority\") lead = <Icon name=\"flag\" size={14} className={group.tone ? TONE_TEXT[group.tone] : \"text-muted-foreground opacity-50\"} />;\n else if (kind === \"project\" && group.hue && HUE_BG[group.hue]) lead = <span aria-hidden=\"true\" className={cx(\"size-2 shrink-0 rounded-sm\", HUE_BG[group.hue])} />;\n else if (kind === \"date\") lead = <Icon name=\"calendar\" size={14} className={group.tone ? TONE_TEXT[group.tone] : \"text-muted-foreground\"} />;\n return (\n <span className={cx(\"inline-flex min-w-0 items-center gap-2 text-sm font-semibold\", className)}>\n {lead}\n <span className=\"truncate\">{group.label}</span>\n </span>\n );\n}\n\n/** DataTable columns for toTableRows rows: name, status, assignees, due,\n * priority, project, estimate, progress, tags — sortable, drawn with the\n * parts above. `keys` picks and orders them. Pass `onChange(change)` to\n * make status / people / due / priority cells pickers. */\nexport function taskColumns(ctx, { keys = [\"title\", \"status\", \"assignees\", \"due\", \"priority\"], onChange } = {}) {\n const c = ctxOf(ctx);\n const set = (row, patch) => onChange?.({ type: \"update\", id: row.id, patch });\n const edit = typeof onChange === \"function\";\n const all = {\n title: { key: \"title\", header: \"Name\", sortable: true, hideable: false, cell: (r) => <span className={cx(\"font-medium\", r.done && \"text-muted-foreground line-through\")}>{r.title}</span> },\n status: {\n key: \"status\",\n header: \"Status\",\n sortable: true,\n sortValue: (r) => r.statusIndex,\n searchValue: (r) => r.statusLabel,\n cell: (r) => <StatusPill status={r.status} ctx={c} onChange={edit ? (v) => set(r, { status: v }) : undefined} />,\n },\n assignees: {\n key: \"assignees\",\n header: \"Assignee\",\n sortable: true,\n sortValue: (r) => r.assigneeNames || null,\n searchValue: (r) => r.assigneeNames,\n cell: (r) => <AssigneeStack ids={r.assignees} ctx={c} onChange={edit ? (v) => set(r, { assignees: v }) : undefined} />,\n },\n due: {\n key: \"due\",\n header: \"Due\",\n sortable: true,\n sortValue: (r) => r.dueDay,\n cell: (r) => <DueLabel value={r.due} done={r.done} ctx={c} onChange={edit ? (v) => set(r, { due: v }) : undefined} />,\n },\n start: { key: \"start\", header: \"Start\", sortable: true, sortValue: (r) => dayOf(r.start), cell: (r) => (r.start ? formatDay(r.start, c.today) : \"\") },\n priority: {\n key: \"priority\",\n header: \"Priority\",\n sortable: true,\n sortValue: (r) => r.priorityRank,\n searchValue: (r) => r.priorityLabel,\n cell: (r) => <PriorityFlag value={r.priority} showLabel onChange={edit ? (v) => set(r, { priority: v }) : undefined} />,\n },\n project: { key: \"project\", header: \"Project\", sortable: true, sortValue: (r) => r.projectName || null, searchValue: (r) => r.projectName, cell: (r) => r.projectName },\n estimate: { key: \"estimate\", header: \"Estimate\", sortable: true, align: \"end\", cell: (r) => (r.estimate ? `${r.estimate}h` : \"\") },\n progress: { key: \"progress\", header: \"Progress\", sortable: true, width: \"8rem\", cell: (r) => (typeof r.progress === \"number\" ? <ProgressLine value={r.progress} /> : null) },\n tags: { key: \"tags\", header: \"Tags\", searchValue: (r) => (r.tags ?? []).join(\" \"), cell: (r) => <TagList tags={r.tags} /> },\n };\n return keys.map((k) => all[k]).filter(Boolean);\n}\n"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "registry/blocks/task-model/toolbar.jsx",
|
|
48
|
+
"type": "registry:component",
|
|
49
|
+
"target": "@components/makersclaw/blocks/task-model/toolbar.jsx",
|
|
50
|
+
"content": "\"use client\";\n\nimport { useId } from \"react\";\nimport { Button, Checkbox, Icon, IconButton, InputGroup, Menu, Popover, Switch, cx } from \"../../ui\";\nimport { DEFAULT_VIEW, NONE, activeFilterCount, createTaskContext, groupOptions, optionsOf, sortOptions, valuesOf } from \"./model\";\n\n// ViewToolbar — the one row above every task view: search, filter,\n// group by, sort, show fields, and your own button at the end. It edits a\n// view object ({ search, filters, groupBy, sort, fields, hideDone }) and\n// hands the next one to `onViewChange`; the view applies it with\n// applyView(). Active filters show as chips under the row, each with its\n// own ×, and \"Clear\" takes them all off.\n//\n// On a phone the search takes the first line and the controls become\n// icons on the second; nothing scrolls sideways.\n\nconst ctxOf = (ctx) => (ctx && ctx.statusById ? ctx : createTaskContext(ctx ?? {}));\n\nconst FILTER_KINDS = new Set([\"status\", \"people\", \"priority\", \"project\", \"tags\", \"date\", \"select\", \"checkbox\"]);\nconst SHOWABLE = [\"status\", \"assignees\", \"due\", \"start\", \"priority\", \"project\", \"tags\", \"estimate\", \"progress\"];\n\nexport function ViewToolbar({\n view: viewProp,\n onViewChange,\n ctx,\n tasks = [],\n controls = [\"search\", \"filter\", \"group\", \"sort\", \"fields\"],\n filterFields,\n groupFields,\n showableFields,\n searchPlaceholder = \"Search tasks\",\n count,\n extra,\n className,\n}) {\n const c = ctxOf(ctx);\n const uid = useId();\n const view = { ...DEFAULT_VIEW, ...viewProp };\n const set = (patch) => onViewChange?.({ ...view, ...patch });\n const has = (k) => controls.includes(k);\n\n const filterable = c.fields.filter(\n (f) =>\n FILTER_KINDS.has(f.kind) &&\n (filterFields ? filterFields.includes(f.key) : f.key !== \"start\") &&\n optionsOf(f.key, c, tasks).some((o) => !o.none),\n );\n const groups = groupOptions(c).filter((g) => !groupFields || g.key === \"none\" || groupFields.includes(g.key));\n const sorts = sortOptions(c);\n const showable = c.fields.filter((f) => (showableFields ?? [...SHOWABLE, ...c.fields.filter((x) => x.custom).map((x) => x.key)]).includes(f.key));\n const active = activeFilterCount(view);\n const groupLabel = groups.find((g) => g.key === (view.groupBy ?? \"none\"))?.label ?? \"None\";\n const sort = view.sort?.[0];\n const sortLabel = sort ? sorts.find((s) => s.key === sort.key)?.label ?? sort.key : \"Manual\";\n\n const toggleFilter = (key, value, on) => {\n const now = new Set(view.filters?.[key] ?? []);\n if (on) now.add(value);\n else now.delete(value);\n const filters = { ...view.filters, [key]: [...now] };\n if (!filters[key].length) delete filters[key];\n set({ filters });\n };\n\n // How many tasks each filter choice holds, among all tasks.\n const counts = (key) => {\n const m = new Map();\n for (const t of tasks) for (const v of valuesOf(t, key, c)) m.set(v, (m.get(v) ?? 0) + 1);\n return m;\n };\n\n const chips = [];\n for (const [key, values] of Object.entries(view.filters ?? {})) {\n if (!values?.length) continue;\n const field = c.fields.find((f) => f.key === key);\n const opts = optionsOf(key, c, tasks);\n const words = values.map((v) => opts.find((o) => o.id === v)?.label ?? (v === NONE ? \"None\" : v));\n chips.push({ key, label: `${field?.label ?? key}: ${words.join(\", \")}` });\n }\n if (view.hideDone) chips.push({ key: \"__done\", label: \"Done hidden\" });\n\n return (\n <div className={cx(\"grid min-w-0 grid-cols-1 gap-2\", className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n {has(\"search\") ? (\n <div className=\"min-w-0 basis-full sm:max-w-72 sm:flex-1 sm:basis-auto\">\n <label htmlFor={`${uid}-q`} className=\"sr-only\">\n {searchPlaceholder}\n </label>\n <InputGroup\n id={`${uid}-q`}\n icon=\"search\"\n type=\"search\"\n value={view.search ?? \"\"}\n placeholder={searchPlaceholder}\n onChange={(e) => set({ search: e.target.value })}\n onKeyDown={(e) => {\n if (e.key === \"Escape\" && view.search) {\n e.preventDefault();\n set({ search: \"\" });\n }\n }}\n action={view.search ? { label: \"Clear search\", icon: \"x\", onClick: () => set({ search: \"\" }) } : undefined}\n />\n </div>\n ) : null}\n <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n {has(\"filter\") ? (\n <Popover\n label=\"Filter tasks\"\n width=\"md\"\n align=\"start\"\n variant={active ? \"secondary\" : \"quiet\"}\n size=\"sm\"\n className=\"max-h-[min(70vh,32rem)] overflow-y-auto p-0\"\n trigger={\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon name=\"filter\" size={16} />\n <span className=\"sr-only sm:not-sr-only\">Filter</span>\n {active ? (\n <span data-nums className=\"rounded-full bg-primary px-1.5 text-2xs font-semibold text-primary-foreground\">\n <span className=\"sr-only\">, </span>\n {active}\n <span className=\"sr-only\"> on</span>\n </span>\n ) : null}\n </span>\n }\n >\n <div className=\"grid divide-y divide-border\">\n {filterable.map((f) => {\n const opts = optionsOf(f.key, c, tasks).filter((o) => o.id !== \"past\");\n const n = counts(f.key);\n const chosen = new Set(view.filters?.[f.key] ?? []);\n return (\n <fieldset key={f.key} className=\"grid gap-0.5 px-3 py-2\">\n <legend className=\"float-left mb-1 w-full pt-1 text-xs font-medium text-muted-foreground\">{f.label}</legend>\n {opts.map((o) => (\n <div key={o.id} className=\"flex items-center justify-between gap-3\">\n <Checkbox\n id={`${uid}-${f.key}-${o.id}`}\n label={o.label}\n checked={chosen.has(o.id)}\n onChange={(e) => toggleFilter(f.key, o.id, e.target.checked)}\n className=\"min-w-0 flex-1\"\n />\n <span data-nums className=\"text-xs text-muted-foreground\">\n {n.get(o.id) ?? 0}\n </span>\n </div>\n ))}\n </fieldset>\n );\n })}\n <div className=\"px-3 py-1\">\n <Switch id={`${uid}-done`} label=\"Hide done tasks\" checked={Boolean(view.hideDone)} onChange={(e) => set({ hideDone: e.target.checked })} />\n </div>\n </div>\n </Popover>\n ) : null}\n {has(\"group\") ? (\n <Menu\n label={`Group by ${groupLabel}`}\n align=\"start\"\n trigger={\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon name=\"layers\" size={16} />\n <span className=\"hidden sm:inline\">\n Group<span className=\"text-muted-foreground\">: {groupLabel}</span>\n </span>\n </span>\n }\n items={[\n { type: \"label\", label: \"Group by\" },\n { type: \"radio\", value: view.groupBy ?? \"none\", onValueChange: (v) => set({ groupBy: v }), options: groups.map((g) => ({ value: g.key, label: g.label })) },\n ]}\n />\n ) : null}\n {has(\"sort\") ? (\n <Menu\n label={`Sort by ${sortLabel}`}\n align=\"start\"\n trigger={\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon name=\"chevrons-up-down\" size={16} />\n <span className=\"hidden sm:inline\">\n Sort<span className=\"text-muted-foreground\">: {sortLabel}</span>\n </span>\n </span>\n }\n items={[\n { type: \"label\", label: \"Sort by\" },\n {\n type: \"radio\",\n value: sort?.key ?? \"manual\",\n onValueChange: (v) => set({ sort: v === \"manual\" ? [] : [{ key: v, dir: sort?.dir ?? \"asc\" }] }),\n options: sorts.map((s) => ({ value: s.key, label: s.label })),\n },\n sort && { type: \"separator\" },\n sort && {\n type: \"radio\",\n value: sort.dir,\n onValueChange: (dir) => set({ sort: [{ key: sort.key, dir }] }),\n options: [\n { value: \"asc\", label: \"Ascending\" },\n { value: \"desc\", label: \"Descending\" },\n ],\n },\n ]}\n />\n ) : null}\n {has(\"fields\") ? (\n <Menu\n label=\"Show fields\"\n align=\"start\"\n trigger={\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon name=\"eye\" size={16} />\n <span className=\"hidden sm:inline\">Fields</span>\n </span>\n }\n items={[\n { type: \"label\", label: \"Show on each task\" },\n ...showable.map((f) => ({\n type: \"checkbox\",\n label: f.label,\n checked: (view.fields ?? []).includes(f.key),\n onCheckedChange: (on) => set({ fields: on ? [...(view.fields ?? []), f.key] : (view.fields ?? []).filter((k) => k !== f.key) }),\n })),\n ]}\n />\n ) : null}\n {typeof count === \"number\" ? (\n <span data-nums className=\"ms-1 hidden text-xs text-muted-foreground md:inline\">\n {count} {count === 1 ? \"task\" : \"tasks\"}\n </span>\n ) : null}\n {extra ? <div className=\"ms-auto flex shrink-0 items-center gap-2\">{extra}</div> : null}\n </div>\n </div>\n {chips.length ? (\n <ul className=\"flex flex-wrap items-center gap-1.5\" aria-label=\"Filters on\">\n {chips.map((chip) => (\n <li key={chip.key} className=\"inline-flex max-w-full items-center rounded-full border border-border bg-card ps-3 text-xs text-foreground\">\n <span className=\"truncate\">{chip.label}</span>\n <IconButton\n icon=\"x\"\n size=\"sm\"\n label={`Remove filter ${chip.label}`}\n className=\"-my-1 rounded-full\"\n onClick={() => {\n if (chip.key === \"__done\") set({ hideDone: false });\n else {\n const filters = { ...view.filters };\n delete filters[chip.key];\n set({ filters });\n }\n }}\n />\n </li>\n ))}\n <li>\n <Button variant=\"link\" size=\"sm\" tone=\"muted\" onClick={() => set({ filters: {}, hideDone: false })}>\n Clear filters\n </Button>\n </li>\n </ul>\n ) : null}\n </div>\n );\n}\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "registry/blocks/task-model/dnd.jsx",
|
|
54
|
+
"type": "registry:component",
|
|
55
|
+
"target": "@components/makersclaw/blocks/task-model/dnd.jsx",
|
|
56
|
+
"content": "\"use client\";\n\nimport { useEffect, useId, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n DndContext,\n DragOverlay,\n KeyboardSensor,\n MeasuringStrategy,\n PointerSensor,\n TouchSensor,\n closestCenter,\n pointerWithin,\n rectIntersection,\n useDraggable,\n useDroppable,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport { SortableContext, arrayMove, horizontalListSortingStrategy, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { Icon, cx } from \"../../ui\";\n\n// Drag primitives for every task view, on @dnd-kit — one interaction\n// model: a mouse drags after 5px, a finger after a short press (so a list\n// still scrolls under it), the keyboard picks up with Space or Enter,\n// moves with the arrows and drops with Space (Escape puts it back). What\n// is dragged floats as a lifted copy; where it will land is the view's to\n// draw (`isOver`). Views never import @dnd-kit themselves: they use these.\n//\n// <TaskDragProvider onDragEnd={({ active, over }) => …} overlay={(a) => <TaskChip task={a.data.task} lifted />}>\n// <SortableGroup ids={ids}>{rows.map((t) => <Row key={t.id} … />)}</SortableGroup>\n// </TaskDragProvider>\n//\n// function Row({ task }) {\n// const s = useSortableTask(task.id, { task, group: \"todo\" });\n// return <li ref={s.setNodeRef} style={s.style}><DragHandle handle={s.handle} label={task.title} />…</li>;\n// }\n//\n// `data` is yours: whatever you pass comes back on `active.data` and\n// `over.data` in the callbacks (a task, a group key, a day).\n\n/** Whether the viewer asked for less motion. */\nexport function useReducedMotion() {\n const [reduced, setReduced] = useState(false);\n useEffect(() => {\n const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n const on = () => setReduced(mq.matches);\n on();\n mq.addEventListener(\"change\", on);\n return () => mq.removeEventListener(\"change\", on);\n }, []);\n return reduced;\n}\n\n// Lists: the item under the pointer wins over the group around it; the\n// group wins in its gaps (its heading, below its last row, when it is\n// empty or folded); the keyboard, which has no pointer, takes the closest.\nfunction listCollision(args) {\n const within = pointerWithin(args);\n if (!within.length) return closestCenter(args);\n const items = within.filter((c) => args.droppableContainers.find((d) => d.id === c.id)?.data?.current?.task);\n return items.length ? items : within;\n}\n\nconst COLLISIONS = { list: listCollision, closest: closestCenter, pointer: pointerWithin, rect: rectIntersection };\n\n// The shape the callbacks get: plain ids and the data the view attached.\nfunction simplify(e) {\n const a = e.active;\n const o = e.over;\n return {\n active: a ? { id: String(a.id), data: a.data?.current ?? {} } : null,\n over: o ? { id: String(o.id), data: o.data?.current ?? {} } : null,\n delta: e.delta ?? { x: 0, y: 0 },\n };\n}\n\n/** The drag area. Callbacks get { active: { id, data }, over: { id,\n * data } | null, delta }. `overlay(active)` draws the lifted copy under\n * the pointer (default: none — the item itself moves). `collision`:\n * \"list\" (default: the item under the pointer, else the group under it,\n * else — the keyboard — the closest) · \"closest\" · \"pointer\" (grids,\n * calendars: the cell under the pointer) · \"rect\" · your own function\n * (dnd-kit's collision detection signature). `describe(active)` names the item for a\n * screen reader (\"Write brief\"); `place(over)` names where it is\n * (\"To do\", \"Tuesday 23\"). */\nexport function TaskDragProvider({ children, onDragStart, onDragOver, onDragEnd, onDragCancel, overlay, collision = \"list\", describe, place, disabled = false }) {\n const id = useId();\n const reduced = useReducedMotion();\n const [active, setActive] = useState(null);\n const [portal, setPortal] = useState(null);\n useEffect(() => setPortal(document.body), []);\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),\n useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n );\n const nameOf = (a) => describe?.(a) ?? a?.data?.task?.title ?? a?.data?.label ?? \"Item\";\n const whereOf = (o) => (o ? place?.(o) ?? o.data?.label ?? o.data?.group ?? null : null);\n const announcements = {\n onDragStart: ({ active: a }) => `Picked up ${nameOf(simplify({ active: a }).active)}. Move with the arrow keys, drop with Space, cancel with Escape.`,\n onDragOver: ({ active: a, over: o }) => {\n const s = simplify({ active: a, over: o });\n const w = whereOf(s.over);\n return w ? `${nameOf(s.active)} is over ${w}.` : `${nameOf(s.active)} is not over a place to drop.`;\n },\n onDragEnd: ({ active: a, over: o }) => {\n const s = simplify({ active: a, over: o });\n const w = whereOf(s.over);\n return w ? `${nameOf(s.active)} dropped on ${w}.` : `${nameOf(s.active)} put back.`;\n },\n onDragCancel: ({ active: a }) => `Moving ${nameOf(simplify({ active: a }).active)} was cancelled. It is back where it was.`,\n };\n return (\n <DndContext\n id={id}\n sensors={disabled ? NO_SENSORS : sensors}\n collisionDetection={typeof collision === \"function\" ? collision : COLLISIONS[collision] ?? listCollision}\n measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}\n accessibility={{\n announcements,\n screenReaderInstructions: { draggable: \"To move this, press Space or Enter, then the arrow keys. Space drops it, Escape cancels.\" },\n }}\n onDragStart={(e) => {\n const s = simplify(e);\n setActive(s.active);\n onDragStart?.(s);\n }}\n onDragOver={(e) => onDragOver?.(simplify(e))}\n onDragEnd={(e) => {\n setActive(null);\n onDragEnd?.(simplify(e));\n }}\n onDragCancel={(e) => {\n setActive(null);\n onDragCancel?.(simplify(e));\n }}\n >\n {children}\n {overlay && portal\n ? createPortal(\n <DragOverlay dropAnimation={reduced ? null : { duration: 150, easing: \"ease-out\" }} zIndex={60}>\n {active ? <div className={cx(\"cursor-grabbing\", !reduced && \"rotate-1 scale-[1.02]\")}>{overlay(active)}</div> : null}\n </DragOverlay>,\n portal,\n )\n : null}\n </DndContext>\n );\n}\n\nconst NO_SENSORS = [];\n\nconst STRATEGIES = { vertical: verticalListSortingStrategy, horizontal: horizontalListSortingStrategy, grid: rectSortingStrategy };\n\n/** A run of items that reorder among themselves (and take items from\n * other groups in the same provider). `ids` in the order shown. */\nexport function SortableGroup({ ids, layout = \"vertical\", children }) {\n return (\n <SortableContext items={ids} strategy={STRATEGIES[layout] ?? verticalListSortingStrategy}>\n {children}\n </SortableContext>\n );\n}\n\n/** One item in a SortableGroup: put `setNodeRef` and `style` on the item,\n * spread `handle` on its grip (or on the item itself for drag-anywhere).\n * `isDragging` (it is the one moving: dim it), `isOver` (something is\n * over it). */\nexport function useSortableTask(id, data, { disabled = false } = {}) {\n const s = useSortable({ id, data, disabled });\n return {\n setNodeRef: s.setNodeRef,\n setHandleRef: s.setActivatorNodeRef,\n style: { transform: CSS.Translate.toString(s.transform), transition: s.transition },\n handle: { ...s.attributes, ...s.listeners },\n isDragging: s.isDragging,\n isOver: s.isOver,\n isSorting: s.isSorting,\n };\n}\n\n/** A free item that is dragged onto targets (a calendar chip, a gantt\n * bar): no reordering. `transform` is the pointer's offset, for views\n * that move the item itself instead of an overlay. */\nexport function useDraggableTask(id, data, { disabled = false } = {}) {\n const d = useDraggable({ id, data, disabled });\n return {\n setNodeRef: d.setNodeRef,\n setHandleRef: d.setActivatorNodeRef,\n handle: { ...d.attributes, ...d.listeners },\n transform: d.transform,\n style: d.transform ? { transform: CSS.Translate.toString(d.transform) } : undefined,\n isDragging: d.isDragging,\n };\n}\n\n/** A place to drop (a group, a day, a person's week). `isOver` while\n * something is over it; `active` is what is being dragged, if anything. */\nexport function useDropTarget(id, data, { disabled = false } = {}) {\n const d = useDroppable({ id, data, disabled });\n return {\n setNodeRef: d.setNodeRef,\n isOver: d.isOver,\n active: d.active ? { id: String(d.active.id), data: d.active.data?.current ?? {} } : null,\n };\n}\n\n/** The grip a row is dragged by: a quiet square with the grip glyph,\n * shown on hover (always on touch). `handle` from useSortableTask. */\nexport function DragHandle({ handle, handleRef, label, className, alwaysVisible = false }) {\n return (\n // raw: dnd-kit's listeners and its role/aria go on the element that\n // starts the drag; it must not be a click action like a kit Button.\n <button\n type=\"button\"\n ref={handleRef}\n {...handle}\n aria-label={`Move ${label}`}\n className={cx(\n \"inline-flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\",\n !alwaysVisible && \"opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 [@media(hover:none)]:opacity-100\",\n className,\n )}\n >\n <Icon name=\"grip\" size={14} />\n </button>\n );\n}\n\n/** A line where a dragged item will land (between rows). */\nexport function DropLine({ className }) {\n return <span aria-hidden=\"true\" className={cx(\"pointer-events-none block h-0.5 rounded-full bg-primary\", className)} />;\n}\n\n/** The lifted look for an overlay you draw yourself. */\nexport const LIFTED = \"rounded-md border border-border bg-card text-card-foreground shadow-lg ring-1 ring-primary/20\";\n\nexport { arrayMove };\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "registry/blocks/task-model/README.md",
|
|
60
|
+
"type": "registry:file",
|
|
61
|
+
"target": "@components/makersclaw/blocks/task-model/README.md",
|
|
62
|
+
"content": "# Task model\n\nThe shared model every view of work reads: one task shape, one context\n(statuses, people, projects, custom fields, today), the arithmetic every\nview repeats (search, filter, group, sort, subtasks, dates, capacity,\ndependencies), the Change a view sends when the founder moves something,\nthe small parts every view draws the same way, the toolbar above them, and\nthe drag primitives on @dnd-kit. List, board, table, calendar, gantt,\nworkload, portfolio and roadmap all run through it, so a task filtered out\nof one is filtered out of all, and one `onChange` saves every view.\n\n```jsx\nimport { createTaskContext, applyView, ViewToolbar, useTaskChanges } from \"../components/blocks/task-model\"; // a Makersclaw app\nimport { createTaskContext, applyView, ViewToolbar, useTaskChanges } from \"@/components/makersclaw/blocks/task-model\"; // a shadcn project\n```\n\nA surface rarely renders it alone: `task-list` (and calendar, gantt,\nworkload, work-views…) are built on it. A view built on it `requires`\ntask-model and imports @dnd-kit only through it. `model.js` and\n`dates.js` are pure (no React): they run on the server too.\n\n## The data\n\n### A task\n\n```js\n{\n id: \"t1\", // string (numbers are stringified)\n title: \"Write the launch email\",\n status: \"todo\", // a status id\n assignees: [\"maya\"], // person ids; [] when unassigned\n start: \"2026-09-22\", // optional; a day, or \"2026-09-22T09:30\"\n due: \"2026-09-24T17:00\", // optional; same forms\n estimate: 4, // optional, hours\n progress: 40, // optional, 0–100 (else from status / subtasks)\n priority: \"high\", // optional: urgent · high · medium · low\n parentId: \"t0\", // optional: a subtask of t0\n dependsOn: [\"t7\"], // optional: t7 must finish first\n location: { lat, lng, label }, // optional, for a map view\n tags: [\"copy\"], // []\n project: \"site\", // optional, a project id\n order: 2048, // optional: the hand-made order (see placeChange)\n …extra // any other key is kept and passed through\n}\n```\n\n`normalizeTask(t)` makes the lists lists and the id a string (it also\ntakes `name` for `title` and a single `assignee`); every helper does this\nfor you. Dates are wall-calendar strings, never Date objects: they compare\nin order, survive JSON and a `date` column, and mean the same day in every\ntimezone. A time is local wall time, no zone.\n\n### The context\n\n```js\nconst ctx = useMemo(() => createTaskContext({\n statuses: [{ id: \"todo\", label: \"To do\", tone: \"neutral\" }, …, { id: \"done\", label: \"Done\", tone: \"success\", done: true }],\n people: [{ id: \"maya\", name: \"Maya Chen\", src?, capacity?: 6 }], // capacity: hours a day (default 8)\n projects: [{ id: \"site\", name: \"Website relaunch\", hue?: 1 }], // hue 1–5 = chart-1…5\n fields: [{ key: \"channel\", label: \"Channel\", kind: \"select\", options: [\"Email\", \"Social\"] }],\n today: \"2026-09-23\", // fix it in a demo; default the browser's today\n weekStartsOn: 1, // 0 Sunday … 6; default Monday\n weekend: [0, 6], holidays: [\"2026-12-25\"], // for working-day math\n}), [statuses, people, projects]);\n```\n\nA status's `tone` is neutral · info · warning · success · danger · primary\n(or `hue` 1–5 for a category-like status). `done: true` marks the done\nstatuses (several may be). Without `statuses` you get `DEFAULT_STATUSES`\n(To do, In progress, In review, Done). Custom field kinds: text · number ·\nselect · date · checkbox. Every helper takes `ctx` (or the plain options\nobject, built on the fly — build it once instead).\n\nWhat `ctx` holds: `statuses`, `people`, `projects`, `fields` (built-in +\ncustom, see `TASK_FIELDS`), `statusById` / `personById` / `projectById`\n(Maps; a status also has its `index`), `doneIds` (Set), `today`,\n`weekStartsOn`, `weekend`, `holidays`.\n\n### A view\n\nWhat a view remembers, and what work-views saves per tab:\n\n```js\nDEFAULT_VIEW = { search: \"\", filters: {}, groupBy: \"status\", sort: [], fields: [\"assignees\", \"due\", \"priority\"], hideDone: false }\n```\n\n- `filters` — `{ status: [\"todo\", \"doing\"], assignees: [\"maya\", NONE], due: [\"overdue\", \"today\"] }`:\n any value within a field, every field. `NONE` (`\"__none\"`) is\n \"Unassigned / No date / No project…\". Date fields filter by due bucket.\n- `groupBy` — any field key, or `\"none\"`.\n- `sort` — `[{ key, dir: \"asc\" | \"desc\" }]`; `[]` is the manual order\n (`order`). Keys: any field, or `\"manual\"`.\n- `fields` — which fields a row shows (the list's \"Fields\" menu).\n\n### A change — how a view reports what the founder did\n\nA view never edits tasks. It builds a Change and commits it:\n\n```js\n{ type: \"create\", task } or { type: \"create\", tasks: [...] }\n{ type: \"update\", id, patch } or { type: \"update\", ids: [...], patch }\n{ type: \"delete\", id } or { type: \"delete\", ids: [...] }\n{ type: \"batch\", changes: [...] } // several at once (a renumber, an undo)\n```\n\nThe page answers every change through ONE async callback,\n`onChange(change) → { ok: true } | { ok: false, error }` (a throw counts as\nfailed; `{ ok: true, id }` may return a create's database id). In a\nMakersclaw app it is a loop over `changeWrites(change)`:\n\n```js\nconst COLUMNS = [\"id\", \"title\", \"status\", \"assignees\", \"due\", \"priority\", \"parentId\", \"order\"]; // your table's\n\nasync function onChange(change) {\n for (const w of changeWrites(change, { columns: COLUMNS, keepNewIds: false })) {\n const r = w.op === \"insert\" ? await insertRow({ source: \"public.tasks\", values: w.values })\n : w.op === \"update\" ? await updateRow({ source: \"public.tasks\", id: w.id, patch: w.patch })\n : await deleteRow({ source: \"public.tasks\", id: w.id });\n if (r.ok === false) return r;\n }\n return { ok: true };\n}\n```\n\n## Props\n\ntask-model has no one component; these are its exports, grouped by what\na view builder needs. Every one is importable from the block's index.\n\n### Views: search, filter, group, sort\n\n- `applyView(tasks, view, ctx)` → `{ tasks, groups, total }` — the whole\n pipeline: normalise, hide done (`hideDone`), search, filter, sort, group.\n Use this and you match every other view.\n- `searchTasks(tasks, query, ctx)` — every word must appear in the\n title, tags, people's names, project, status label, description or a\n custom text field. `searchText(task, ctx)` is that haystack.\n- `filterTasks(tasks, filters, ctx)` · `activeFilterCount(view)`.\n- `sortTasks(tasks, sort, ctx)` — stable, blanks last either way; status\n sorts in the statuses' order, priority urgent → low, people by name,\n dates by day then time. `sortOptions(ctx)` for a menu.\n- `groupTasks(tasks, by, ctx, { showEmpty? })` →\n `[{ key, label, tone?, hue?, option, tasks }]` in the field's order,\n the \"none\" group last. A task with two assignees (or tags) is in both\n groups. Empty groups are kept for status and priority (somewhere to\n drop) or with `showEmpty: true`. `groupOptions(ctx)` for a menu.\n- `valuesOf(task, key, ctx)` — the group keys a task is filed under\n (`[\"maya\", \"leo\"]`, `[\"overdue\"]`, `[NONE]`).\n- `optionsOf(key, ctx, tasks?)` → `[{ id, label, tone?, hue?, person?, project?, none? }]`\n — a field's choices in order; `labelOf(key, value, ctx)` — one's words.\n- `movePatch(task, by, fromKey, toKey, ctx)` — the patch that moves a task\n between groups of `by`: a new status; one assignee swapped for another\n (the rest stay); a due date for a due bucket (a start moves with it,\n the length kept); `null` when the target is not a place to drop\n (Overdue). Board columns, list groups, workload rows all use it.\n- `fieldOf(key, ctx)` · `TASK_FIELDS` · `PRIORITIES` · `NONE` ·\n `DEFAULT_STATUSES` · `DEFAULT_VIEW`.\n- `isDone(task, ctx)` · `doneStatusId(ctx)` · `openStatusId(ctx)` ·\n `toggleDoneChange(task, ctx)` (the completion checkbox's change).\n\n### Manual order\n\n- `orderOf(task)` — its `order`, or null.\n- `rankBetween(a, b)` — a number between two orders (either may be null).\n- `placeChange(list, task, index, patch?)` — the Change that puts `task` at\n `index` of `list` (the target group's tasks as shown, without the task),\n with `patch` (its new group) merged in: a midpoint `order` when there is\n room, else one `batch` that renumbers the list. Drag-to-reorder is this\n one call.\n- `draftTask(title, ctx, patch?, { after? })` — a new task for a quick-add:\n a client id (`newTaskId()`), the first open status, an order after\n `after`, the group's patch.\n\n### Subtasks\n\n- `flattenTree(tasks, { collapsed? })` → `[{ task, depth, childCount, hasChildren, collapsed }]`\n in reading order; a task whose parent is not in `tasks` is a root.\n- `buildTree(tasks)` → `[{ task, depth, children }]` · `childrenMap(tasks)`.\n- `descendantIds(tasks, id)` · `ancestorIds(tasks, id)`.\n- `subtaskCounts(tasks, ctx)` → Map id → `{ total, done }` (a row's \"2/5\").\n- `rollup(tasks, id, ctx)` → `{ count, done, estimate, progress }`;\n `progressOf(task, tasks, ctx)` → 0–100 (its own, else 100 if done, else\n from its subtasks weighted by estimate).\n- `deleteChange(tasks, ids)` — a delete that takes the subtasks too.\n\n### Dates (dates.js — the calendar, gantt and workload math)\n\nDays are `\"YYYY-MM-DD\"`, optionally `\"…THH:mm\"`.\n\n- Reading: `isDay(v)` · `dayOf(v)` (a day, from a datetime or a Date) ·\n `minutesOf(v)` (minutes after midnight, null for all-day) ·\n `withTime(day, minutes)` · `todayDay()` · `dayNumber(day)` /\n `fromDayNumber(n)` (integer days, for pixel math: `x = (dayNumber(d) - dayNumber(start)) * dayWidth`).\n- Arithmetic: `addDays` · `diffDays(a, b)` (b − a) · `addMonths` (clamps\n to the month's end) · `weekdayOf` (0 Sunday) · `startOfWeek(day, ws)` ·\n `endOfWeek` · `weekDays(day, ws)` (7 days) · `startOfMonth` ·\n `endOfMonth` · `daysBetween(a, b)` (inclusive list).\n- Grids: `monthGrid(\"2026-09\", { weekStartsOn, fixedWeeks })` → weeks of\n 7 days (mute the days whose `slice(0, 7)` is not the month) ·\n `timeScale(from, to, unit, { weekStartsOn, locale })` → columns\n `{ key, start, end, label }` for unit day · week · month · quarter (a\n gantt header, workload columns, a roadmap's quarters) ·\n `rangeOfTasks(tasks, { pad, fallback })` → the window all tasks fit in.\n- Working days: `WEEKEND` · `isWeekend` · `isWorkingDay(day, { weekend, holidays })` ·\n `workingDaysBetween(a, b, opts)` · `addWorkingDays(day, n, opts)`.\n- Spans: `spanOf(task)` → `{ start, end }` (one date → a one-day span;\n none → null = unscheduled) · `spansOverlap(a, b)` ·\n `tasksInRange(tasks, from, to)` · `tasksOnDay(tasks, day)`.\n- Patches a drag saves: `shiftDates(task, n)` (move by n days, keeping\n length and times) · `spanPatch(task, start, end)` (a resize).\n- Words: `formatDay(day, today)` (\"Today\", \"Tomorrow\", \"Yesterday\", \"Fri\",\n \"Oct 30\", \"Jan 3, 2027\") · `formatTime(v)` (\"2:30 PM\") ·\n `formatDate(day, intlOptions)`.\n- Due buckets: `dueBucket(due, today, { done, weekStartsOn })` → overdue ·\n today · tomorrow · this-week · next-week · later · past (a done task's\n old date) · none; `DUE_BUCKETS` (their words and tones) ·\n `dayForBucket(bucket, today)` (the day a drop into a bucket sets).\n\nThe kit's own `addDays` / `startOfMonth` / `formatDate` exist too, with\nthe same string dates; import one or the other in a file, not both.\n\n### Capacity\n\n- `workload(tasks, ctx, { from, to, unit: \"day\" | \"week\", defaultCapacity: 8, includeDone })`\n → `{ rows: [{ person, total, capacity, buckets: [{ key, start, end, hours, capacity, over, tasks: [{ task, hours }] }] }], buckets, unassigned, unscheduled }`.\n A task's estimate is spread evenly over the working days of its span and\n split between its assignees; a bucket's capacity is the person's hours a\n day × its working days. `over` is load past capacity.\n- `hoursPerDay(task, ctx)` · `sumOf(tasks, key = \"estimate\")`.\n\n### Dependencies\n\n- `dependencyEdges(tasks)` → `[{ from, to }]` (`to` waits on `from`).\n- `blockersOf(task, tasks, ctx)` — what it waits on that is not done.\n- `wouldCycle(tasks, fromId, toId)` — refuse a link that loops.\n- `dateConflicts(tasks)` — edges whose `to` starts before `from` ends.\n- `criticalPath(tasks)` → Set of ids on the longest dependent chain.\n\n### Changes\n\n- `useTaskChanges(tasks, onChange)` → `{ tasks, commit, error, clearError, saving }`\n — the hook every view uses. `commit(change, { label?, undo? })` shows\n the change at once, calls `onChange`, and on `{ ok: false }` takes it\n back and sets `error` (\"“Write brief” was not saved: … It is back as it\n was.\") — show it in a `Notice`. `undo: \"Deleted “Write brief”\"` also\n shows a toast with Undo (it commits the inverse; deleted tasks come back\n with their ids). It returns the page's answer. Example rows\n (`useFixture`, `_sample`) change on screen and are never sent. The\n change stays on screen until the page's `tasks` move on, so nothing\n flickers back between the save and the re-read.\n- `applyChange(tasks, change)` · `invertChange(tasksBefore, change)` ·\n `changedIds(change)`.\n- `changeWrites(change, { columns?, keepNewIds? })` — the change as plain\n `{ op: \"insert\", values } | { op: \"update\", id, patch } | { op: \"delete\", id }`\n rows. `columns` keeps only your table's keys (a new task carries empty\n `tags` and `dependsOn` lists your table may not have); `keepNewIds:\n false` drops a created task's client id (`new-…`, see `isNewId`) so the\n table makes its own. Keep it (the default) when your id column is text\n and the client id may stand.\n\n### Adapters for the existing blocks\n\nDrive `board` and `data-table` from tasks without touching them:\n\n```jsx\nconst b = toBoard(visibleTasks, { groupBy: \"status\", ctx }); // or \"assignees\", \"priority\", \"due\"…\n<Board rows={b.rows} groupBy={b.groupBy} columns={b.columns} otherLabel={b.otherLabel}\n onMove={(row, to, from) => commit(b.moveChange(row, to, from), { label: `“${row.title}”` })}\n titleOf={(t) => t.title}\n renderDetails={(t) => <><DueLabel value={t.due} ctx={ctx} /><AssigneeStack ids={t.assignees} ctx={ctx} /></>} />\n\n<DataTable rows={toTableRows(visibleTasks, ctx)}\n columns={taskColumns(ctx, { keys: [\"title\", \"status\", \"assignees\", \"due\", \"priority\"], onChange: commit })}\n facets={taskFacets(ctx)} noun=\"task\" />\n```\n\n- `toBoard(tasks, { groupBy, ctx })` → `{ rows, groupBy: \"__group\", columns, otherLabel, moveChange(row, to, from) }`\n — one card per task, under its first value; `moveChange` is\n `movePatch` as an update (null when the column refuses it). Overdue is\n not a column when grouped by due date.\n- `toTableRows(tasks, ctx)` → the tasks plus `statusLabel`, `statusIndex`,\n `assigneeNames`, `projectName`, `priorityLabel`, `priorityRank`,\n `dueDay`, `dueBucket`, `parentTitle`, `subtasks` (\"2/5\"), `done`.\n- `taskColumns(ctx, { keys?, onChange? })` — DataTable columns (title,\n status, assignees, due, start, priority, project, estimate, progress,\n tags), sortable, drawn with the parts; with `onChange` the status,\n people, due and priority cells are pickers that send an update Change.\n- `taskFacets(ctx, { keys? })` — facets for status, assignee, priority,\n project.\n\n### Parts every view shares\n\nEach is a label; with `onChange` it becomes a picker (a kit Menu or\nPopover) and hands the picked value back — you wrap it in a Change.\n\n- `<StatusPill status ctx onChange?(id) size?=\"sm\"|\"md\" hideLabel? />` — dot + words.\n- `<CompleteToggle done onToggle label size?=\"md\"|\"sm\" />` — the round\n completion check (not the square selection Checkbox).\n- `<AssigneeStack ids ctx onChange?(ids) max?=3 size?=\"xs\" />` — faces,\n or a dashed \"assign\" circle; the picker ticks people (stays open).\n- `<PersonLabel id ctx />` — face + name (\"Unassigned\" for NONE).\n- `<DueLabel value done? ctx onChange?(day|null) quiet?=true placeholder? />`\n — \"Today\" / \"Fri\" / \"Oct 30\", danger when overdue, warning today; the\n picker is quick picks (Today, Tomorrow, Next week) + a calendar + Remove.\n `quiet` hides an empty one's words until its row (`group/row`) is hovered.\n- `<PriorityFlag value onChange?(id|null) showLabel? />` — a flag in the\n priority's tone.\n- `<TagList tags max?=2 />` · `<SubtaskCount done total />` ·\n `<ProgressLine value />` (0–100).\n- `<TaskChip task ctx onOpen? showTime? showPeople? dragging? lifted? dragProps? />`\n — a task on one compact line (status dot, time, title, first face, the\n project's hue on its leading edge): a calendar day's item, a gantt\n label, a workload cell.\n- `<GroupLabel group by ctx />` — a group heading's face for any field.\n- `TONE_TEXT` · `HUE_EDGE` · `HUE_BG` — the token classes those use\n (tone → `text-…`, hue 1–5 → `border-s-chart-N` / `bg-chart-N`).\n\nRows that host pickers should carry the `group/row` class: the quiet\nparts (an empty due date, an empty priority, a drag handle) appear when\nthe row is hovered or focused, and always on touch.\n\n### ViewToolbar\n\n```jsx\n<ViewToolbar view={view} onViewChange={setView} ctx={ctx} tasks={allTasks}\n count={shown.tasks.length} extra={<Button variant=\"primary\" size=\"sm\" icon=\"plus\">Add task</Button>} />\n```\n\nSearch (Escape clears), Filter (a popover of every filterable field with\ncounts, and \"Hide done tasks\"), Group by, Sort (field + direction), Fields\n(which fields rows show), your `extra` at the end; active filters as\nchips underneath, each with ×, and \"Clear filters\". On a phone the search\ntakes the first line and the controls are icons.\n\n- `view` · `onViewChange(nextView)` — controlled; a view object as above.\n- `ctx` · `tasks` (the counts in the filter menu).\n- `controls` — default `[\"search\", \"filter\", \"group\", \"sort\", \"fields\"]`;\n a calendar might pass `[\"search\", \"filter\"]`.\n- `filterFields` · `groupFields` · `showableFields` — limit the choices.\n- `count` — \"12 tasks\" beside the controls (from 768px).\n- `searchPlaceholder` (default \"Search tasks\") · `extra` · `className`.\n\n### Drag primitives (@dnd-kit, never imported directly)\n\nOne interaction model everywhere: a mouse drags after 5px, a finger after\na short press (so lists still scroll), the keyboard picks up with Space\nor Enter, moves with the arrows, drops with Space, cancels with Escape;\nscreen readers hear what was picked up and where it is.\n\n```jsx\n<TaskDragProvider\n onDragEnd={({ active, over }) => { /* active.data / over.data are what you attached */ }}\n overlay={(active) => <div className={LIFTED}><TaskChip task={active.data.task} ctx={ctx} /></div>}\n collision=\"list\">\n <SortableGroup ids={ids}>…rows using useSortableTask…</SortableGroup>\n …cells using useDropTarget, chips using useDraggableTask…\n</TaskDragProvider>\n```\n\n- `<TaskDragProvider onDragStart? onDragOver? onDragEnd? onDragCancel? overlay? collision? describe? place? disabled? />`\n — callbacks get `{ active: { id, data }, over: { id, data } | null, delta }`.\n `overlay(active)` draws the lifted copy (tilted 1°, 150ms drop; flat\n and instant under reduced motion). `collision`: `\"list\"` (default: the\n item under the pointer, else the group under it, else — keyboard — the\n closest) · `\"closest\"` · `\"pointer\"` (grids and calendars) · `\"rect\"` ·\n a function. `describe(active)` / `place(over)` word the announcements\n (default `data.task.title`, `data.label`).\n- `<SortableGroup ids layout?=\"vertical\"|\"horizontal\"|\"grid\">` — items that\n reorder among themselves.\n- `useSortableTask(id, data, { disabled })` → `{ setNodeRef, setHandleRef, style, handle, isDragging, isOver }`\n — `setNodeRef` + `style` on the item, `handle` spread on its grip.\n- `useDraggableTask(id, data)` → `{ setNodeRef, setHandleRef, handle, style, transform, isDragging }`\n — a free item dropped on targets (a calendar chip, a gantt bar; use\n `delta` in onDragEnd for a resize or a pixel shift).\n- `useDropTarget(id, data)` → `{ setNodeRef, isOver, active }` — a group,\n a day cell, a person's week.\n- `<DragHandle handle handleRef? label alwaysVisible? />` — the grip, shown\n on row hover (always on touch). `<DropLine />` — a landing line.\n- `LIFTED` — the lifted-card classes · `useReducedMotion()` · `arrayMove`.\n\nHow a drop becomes a save: read `over.data` (a group key, a day, a\nperson), build the patch (`movePatch`, `shiftDates`, `spanPatch`, or\n`placeChange` for an order), `commit` it.\n\n## Example\n\nA minimal view on the model — search, filter and group from the toolbar,\ntasks as chips, drag a chip to another status:\n\n```jsx\n\"use client\";\nimport { useMemo, useState } from \"react\";\nimport { Notice } from \"./ui\";\nimport {\n DEFAULT_VIEW, TaskChip, TaskDragProvider, ViewToolbar, applyView, createTaskContext,\n movePatch, useDraggableTask, useDropTarget, useTaskChanges, LIFTED,\n} from \"./blocks/task-model\";\n\nexport default function StatusLanes({ tasks, statuses, people, onChange }) {\n const ctx = useMemo(() => createTaskContext({ statuses, people }), [statuses, people]);\n const { tasks: live, commit, error, clearError } = useTaskChanges(tasks, onChange);\n const [view, setView] = useState({ ...DEFAULT_VIEW, groupBy: \"status\" });\n const { groups } = applyView(live, view, ctx);\n return (\n <div className=\"grid gap-3\">\n <ViewToolbar view={view} onViewChange={setView} ctx={ctx} tasks={live} controls={[\"search\", \"filter\"]} />\n {error ? <Notice tone=\"danger\" onDismiss={clearError}>{error}</Notice> : null}\n <TaskDragProvider collision=\"pointer\"\n overlay={(a) => <div className={LIFTED}><TaskChip task={a.data.task} ctx={ctx} /></div>}\n onDragEnd={({ active, over }) => {\n if (!over) return;\n const patch = movePatch(active.data.task, \"status\", active.data.group, over.data.group, ctx);\n if (patch && over.data.group !== active.data.group) commit({ type: \"update\", id: active.data.task.id, patch });\n }}>\n {groups.map((g) => <Lane key={g.key} group={g} ctx={ctx} />)}\n </TaskDragProvider>\n </div>\n );\n}\n\nfunction Lane({ group, ctx }) {\n const drop = useDropTarget(`lane-${group.key}`, { group: group.key, label: group.label });\n return (\n <section ref={drop.setNodeRef} className={drop.isOver ? \"rounded-lg bg-accent p-2\" : \"rounded-lg p-2\"}>\n <h3 className=\"text-sm font-semibold\">{group.label}</h3>\n <ul className=\"mt-2 grid gap-1\">{group.tasks.map((t) => <Chip key={t.id} task={t} group={group.key} ctx={ctx} />)}</ul>\n </section>\n );\n}\n\nfunction Chip({ task, group, ctx }) {\n const d = useDraggableTask(task.id, { task, group });\n return <li ref={d.setNodeRef}><TaskChip task={task} ctx={ctx} dragging={d.isDragging} dragProps={d.handle} /></li>;\n}\n```\n\n## What it will not do\n\n- Draw a whole view: that is `task-list`, `board` (through `toBoard`),\n `data-table` (through `toTableRows`), and the calendar / gantt /\n workload blocks built on this one.\n- Fetch or save: tasks come in as props, changes go out through the\n page's `onChange`.\n- Recurring tasks, time zones, or time tracking: a date is a wall-calendar\n day (and time); a repeat is the page's to create.\n- Sync between people live: the page re-reads.\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd task-model` restores them. A view it cannot be is your own component\nbuilt from these exports.\n\n## Credits\n\nThe drag primitives wrap @dnd-kit (MIT, https://dndkit.com): core 6.3.1,\nsortable 10.0.0, utilities 3.2.2.\n"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"categories": [
|
|
66
|
+
"tasks",
|
|
67
|
+
"task",
|
|
68
|
+
"work items",
|
|
69
|
+
"project management",
|
|
70
|
+
"model",
|
|
71
|
+
"filter",
|
|
72
|
+
"group",
|
|
73
|
+
"sort",
|
|
74
|
+
"subtasks",
|
|
75
|
+
"dates",
|
|
76
|
+
"calendar math",
|
|
77
|
+
"workload",
|
|
78
|
+
"capacity",
|
|
79
|
+
"dependencies",
|
|
80
|
+
"critical path",
|
|
81
|
+
"toolbar",
|
|
82
|
+
"drag",
|
|
83
|
+
"drop",
|
|
84
|
+
"dnd-kit",
|
|
85
|
+
"optimistic",
|
|
86
|
+
"undo",
|
|
87
|
+
"clickup",
|
|
88
|
+
"linear",
|
|
89
|
+
"asana"
|
|
90
|
+
],
|
|
91
|
+
"docs": "Installed to components/makersclaw/blocks/task-model. Import: import { NONE, PRIORITIES, DEFAULT_STATUSES, TASK_FIELDS, DEFAULT_VIEW, normalizeTask, normalizeTasks, createTaskContext, fieldOf, isDone, doneStatusId, openStatusId, toggleDoneChange, newTaskId, draftTask, valuesOf, optionsOf, labelOf, searchText, searchTasks, filterTasks, orderOf, sortTasks, sortOptions, groupTasks, groupOptions, movePatch, applyView, activeFilterCount, rankBetween, placeChange, childrenMap, buildTree, flattenTree, descendantIds, ancestorIds, subtaskCounts, rollup, progressOf, dependencyEdges, blockersOf, wouldCycle, dateConflicts, criticalPath, hoursPerDay, workload, sumOf, applyChange, invertChange, changeWrites, changedIds, deleteChange, toBoard, toTableRows, taskFacets, StatusPill, CompleteToggle, AssigneeStack, PersonLabel, DueLabel, PriorityFlag, TagList, SubtaskCount, ProgressLine, TaskChip, GroupLabel, taskColumns, TONE_TEXT, HUE_EDGE, HUE_BG, TaskDragProvider, SortableGroup, useSortableTask, useDraggableTask, useDropTarget, DragHandle, DropLine, LIFTED, useReducedMotion, arrayMove, isDay, dayNumber, fromDayNumber, dayOf, minutesOf, withTime, todayDay, addDays, diffDays, weekdayOf, startOfWeek, endOfWeek, weekDays, startOfMonth, endOfMonth, addMonths, daysBetween, monthGrid, WEEKEND, isWeekend, isWorkingDay, workingDaysBetween, addWorkingDays, spanOf, spansOverlap, tasksInRange, tasksOnDay, shiftDates, spanPatch, dueBucket, DUE_BUCKETS, dayForBucket, formatDay, formatTime, formatDate, timeScale, rangeOfTasks, isNewId } from \"@/components/makersclaw/blocks/task-model\". Props and an example: components/makersclaw/blocks/task-model/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
92
|
+
"meta": {
|
|
93
|
+
"makersclaw": {
|
|
94
|
+
"version": "0.3.0",
|
|
95
|
+
"useWhen": "You are building a view of work items — a list, calendar, gantt, workload, roadmap or a switcher between them — or driving Board / DataTable from tasks. Views import it; a surface rarely renders it alone. Not for records that are not work (use data-table or board directly)."
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "testimonials",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Testimonials",
|
|
6
|
+
"description": "A marketing section of customer quotes: name, role, company, face or logo, the quote, an optional star rating, a verified mark and a \"view on X\" source, and optional headline numbers (stats). Layouts: a grid of cards (plain, card or raised; hover can light one and dim the rest; a \"write a review\" tile), one large quote, a switcher of faces, and a carousel, optionally over a picture.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/blocks/testimonials/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/testimonials/index.js",
|
|
16
|
+
"content": "// The Testimonials block — `makersclaw add testimonials`. Fixed: compose\n// it through its props (README.md beside this file); a section it cannot\n// be is your own component in components/.\nexport { Testimonials } from \"./testimonials\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/testimonials/testimonials.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/testimonials/testimonials.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Avatar, Button, Carousel, Empty, Icon, Image, Kicker, Rating, Skeleton, Stat, StatGroup, cx } from \"../../ui\";\n\n// Testimonials — a marketing section of customer quotes: who said it\n// (name, role, company, face or logo), the quote, an optional star\n// rating, an optional \"see it on X\" source, and optional headline\n// numbers beside them. Five layouts over the same rows: a grid of cards,\n// one large quote, a switcher (press a face to swap the quote), a\n// carousel (swipe, arrows, dots), and a carousel over a picture.\n//\n// <Testimonials title=\"Loved by small teams\" items={quotes}\n// stats={[{ label: \"Teams\", value: \"4,200+\" }]} />\n//\n// README.md beside this file lists every prop.\n\nconst COLUMNS = {\n 1: \"grid-cols-1\",\n 2: \"grid-cols-1 sm:grid-cols-2\",\n 3: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\",\n 4: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\",\n};\n\nconst LOOKS = {\n card: \"rounded-lg border border-border bg-card text-card-foreground p-6\",\n raised: \"rounded-lg border border-border bg-card text-card-foreground p-6 shadow-lg\",\n plain: \"\",\n};\n\n// A network's name and the brand glyph drawn for it.\nconst NETWORKS = {\n x: { label: \"X\", icon: \"x-logo\" },\n linkedin: { label: \"LinkedIn\", icon: \"linkedin\" },\n instagram: { label: \"Instagram\", icon: \"instagram\" },\n youtube: { label: \"YouTube\", icon: \"youtube\" },\n facebook: { label: \"Facebook\", icon: \"facebook\" },\n github: { label: \"GitHub\", icon: \"github\" },\n google: { label: \"Google\", icon: \"google\" },\n};\n\nconst keyOf = (item, i) => item.id ?? `${i}-${item.name ?? \"\"}`;\nconst roleLine = (item) => [item.role, item.company].filter(Boolean).join(\", \");\n\nfunction Header({ kicker, title, description, align, action }) {\n if (!kicker && !title && !description && !action) return null;\n const center = align === \"center\";\n return (\n <div className={cx(\"grid gap-3\", center ? \"mx-auto max-w-2xl justify-items-center text-center\" : \"max-w-2xl\")}>\n {kicker ? <Kicker>{kicker}</Kicker> : null}\n {title ? <h2 className=\"text-balance text-3xl font-semibold tracking-tight sm:text-4xl\">{title}</h2> : null}\n {description ? <p className=\"text-pretty text-base text-muted-foreground sm:text-lg\">{description}</p> : null}\n {action ? <div className=\"mt-2\">{action}</div> : null}\n </div>\n );\n}\n\nfunction ActionButton({ action, className }) {\n if (!action) return null;\n return (\n <Button variant=\"secondary\" href={action.href} onClick={action.onClick} icon={action.icon ?? \"plus\"} className={className}>\n {action.label}\n </Button>\n );\n}\n\n// Name (with a verified mark), then role and company, or the date.\nfunction Byline({ item, size = \"md\", center, avatar = true }) {\n const sub = roleLine(item);\n return (\n <figcaption className={cx(\"flex min-w-0 gap-3\", center ? \"flex-col items-center text-center\" : \"items-center\")}>\n {avatar ? <Avatar name={item.name} src={item.avatar} size={size === \"lg\" ? \"lg\" : \"md\"} /> : null}\n <div className=\"min-w-0\">\n <p className={cx(\"flex items-center gap-1.5 font-semibold\", center && \"justify-center\")}>\n <span className=\"min-w-0 truncate\">{item.name}</span>\n {item.verified ? (\n <span className=\"inline-flex shrink-0 text-primary\">\n <Icon name=\"circle-check\" size={16} />\n <span className=\"sr-only\">Verified customer</span>\n </span>\n ) : null}\n </p>\n {sub ? <p className=\"text-sm text-muted-foreground\">{sub}</p> : null}\n {item.date && !item.source ? <p className=\"text-sm text-muted-foreground\">{item.date}</p> : null}\n </div>\n </figcaption>\n );\n}\n\nfunction Stars({ item, center }) {\n if (item.rating == null) return null;\n return (\n <div className={cx(\"flex\", center && \"justify-center\")}>\n <Rating value={item.rating} size=\"sm\" label={`${item.name}'s rating`} />\n </div>\n );\n}\n\n// \"View on X\" and the date, under a quote that was posted somewhere.\nfunction Source({ item }) {\n const s = item.source;\n if (!s) return null;\n const net = NETWORKS[s.network] ?? { label: s.label ?? \"the source\", icon: \"external\" };\n return (\n <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border pt-4 text-sm\">\n {s.href ? (\n <Button variant=\"link\" size=\"sm\" href={s.href} tone=\"muted\">\n View on {s.label ?? net.label} <Icon name={net.icon} size={14} />\n </Button>\n ) : (\n <span className=\"inline-flex items-center gap-1.5 text-muted-foreground\">\n On {s.label ?? net.label} <Icon name={net.icon} size={14} />\n </span>\n )}\n {item.date ? <span className=\"text-muted-foreground\">{item.date}</span> : null}\n </div>\n );\n}\n\nfunction QuoteCard({ item, look, center, bylineTop, quoteMark }) {\n const byline = <Byline item={item} center={center} size={bylineTop && center ? \"lg\" : \"md\"} />;\n return (\n <figure\n className={cx(\n \"flex h-full min-w-0 flex-col gap-4\",\n LOOKS[look] ?? LOOKS.card,\n item.highlight && look !== \"plain\" && \"border-primary ring-1 ring-primary\",\n center && \"items-center text-center\",\n )}\n >\n {item.logo ? <div className={cx(\"flex min-h-10 items-center\", center && \"justify-center\")}>{item.logo}</div> : null}\n {bylineTop ? byline : null}\n {!bylineTop ? <Stars item={item} center={center} /> : null}\n {quoteMark ? <Icon name=\"quote\" size={28} className=\"text-muted-foreground/50\" /> : null}\n <blockquote className={cx(\"text-pretty leading-relaxed\", look === \"plain\" ? \"text-muted-foreground\" : \"text-foreground\")}>\n {item.quote}\n </blockquote>\n <div className={cx(\"mt-auto grid gap-4\", center && \"justify-items-center\")}>\n {bylineTop ? <Stars item={item} center={center} /> : byline}\n {item.href ? (\n <Button variant=\"link\" size=\"sm\" href={item.href}>\n {item.hrefLabel ?? \"Read the story\"} <Icon name=\"arrow-right\" size={14} />\n </Button>\n ) : null}\n <Source item={item} />\n </div>\n </figure>\n );\n}\n\nfunction StatsStrip({ stats, center, className }) {\n if (!stats?.length) return null;\n return (\n <StatGroup variant=\"divided\" label=\"In numbers\" className={cx(center && \"mx-auto w-full max-w-3xl\", className)}>\n {stats.map((s, i) => (\n <Stat key={s.label ?? i} label={s.label} value={s.value} unit={s.unit} caption={s.caption} size=\"lg\" />\n ))}\n </StatGroup>\n );\n}\n\nfunction Loading({ columns }) {\n return (\n <div aria-busy=\"true\" aria-label=\"Loading testimonials\" className={cx(\"grid gap-6\", COLUMNS[columns] ?? COLUMNS[3])}>\n {[0, 1, 2].map((i) => (\n <div key={i} className=\"grid gap-4 rounded-lg border border-border bg-card p-6\">\n <Skeleton className=\"w-24\" />\n <Skeleton className=\"w-full\" />\n <Skeleton className=\"w-5/6\" />\n <Skeleton className=\"w-2/3\" />\n <div className=\"flex items-center gap-3 pt-2\">\n <Skeleton shape=\"circle\" className=\"size-10\" />\n <div className=\"grid flex-1 gap-2\">\n <Skeleton className=\"w-1/2\" />\n <Skeleton className=\"w-1/3\" />\n </div>\n </div>\n </div>\n ))}\n </div>\n );\n}\n\nfunction Grid({ items, columns, look, center, bylineTop, quoteMark, focusOnHover, action }) {\n return (\n <div\n className={cx(\n \"grid gap-6\",\n COLUMNS[columns] ?? COLUMNS[3],\n // One card lit, the rest dimmed, while the pointer is over one.\n focusOnHover && \"[&:has(>*:hover)>*:not(:hover)]:opacity-50 [&>*]:transition-opacity motion-reduce:[&>*]:transition-none\",\n )}\n >\n {items.map((item, i) => (\n <div key={keyOf(item, i)} className={cx(\"min-w-0\", item.wide && \"sm:col-span-full\")}>\n <QuoteCard item={item} look={look} center={center} bylineTop={bylineTop} quoteMark={quoteMark} />\n </div>\n ))}\n {action ? (\n <div className=\"grid min-h-48 min-w-0\">\n <Button\n variant=\"secondary\"\n href={action.href}\n onClick={action.onClick}\n className=\"h-full w-full flex-col gap-3 rounded-lg border-dashed\"\n >\n <span className=\"grid size-14 place-items-center rounded-full bg-muted\">\n <Icon name={action.icon ?? \"plus\"} size={24} />\n </span>\n {action.label}\n </Button>\n </div>\n ) : null}\n </div>\n );\n}\n\n// One quote, large: a picture or a logo on one side, the words on the\n// other; with `stats`, the numbers take the picture's place.\nfunction Single({ item, kicker, title, description, stats, look, action }) {\n const heading = item.title ?? title;\n const media = item.image ? (\n <Image src={item.image} alt={item.imageAlt ?? \"\"} ratio=\"square\" rounded=\"lg\" className=\"w-full max-w-sm\" />\n ) : item.logo && look === \"plain\" ? (\n <div className=\"grid min-h-40 place-items-center rounded-lg bg-muted p-8 text-2xl\">{item.logo}</div>\n ) : null;\n const body = (\n <div className=\"grid min-w-0 gap-5\">\n {kicker ? <Kicker>{kicker}</Kicker> : null}\n {heading ? <h2 className=\"text-balance text-3xl font-semibold tracking-tight sm:text-4xl\">{heading}</h2> : null}\n {description && !heading ? <p className=\"text-muted-foreground\">{description}</p> : null}\n <Stars item={item} />\n <blockquote className=\"text-pretty text-lg leading-relaxed text-foreground sm:text-xl\">\n <Icon name=\"quote\" size={24} className=\"mb-3 block text-muted-foreground/50\" />\n {item.quote}\n </blockquote>\n <div className=\"flex flex-wrap items-center justify-between gap-4\">\n <Byline item={item} size=\"lg\" />\n {item.logo && look !== \"plain\" ? <div className=\"shrink-0\">{item.logo}</div> : null}\n </div>\n {item.href ? (\n <div>\n <Button variant=\"link\" href={item.href}>\n {item.hrefLabel ?? \"Read the story\"} <Icon name=\"arrow-right\" size={14} />\n </Button>\n </div>\n ) : null}\n {action ? (\n <div>\n <ActionButton action={action} />\n </div>\n ) : null}\n </div>\n );\n const side = stats?.length ? (\n <div className=\"grid content-center gap-6 sm:grid-cols-3 md:grid-cols-1 md:border-l md:border-border md:pl-10\">\n {stats.map((s, i) => (\n <Stat key={s.label ?? i} label={s.label} value={s.value} unit={s.unit} caption={s.caption} size=\"lg\" />\n ))}\n </div>\n ) : media;\n return (\n <figure\n className={cx(\n \"grid items-center gap-8\",\n side && \"md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]\",\n stats?.length && \"md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]\",\n look !== \"plain\" && \"rounded-xl border border-border bg-card p-6 text-card-foreground sm:p-10\",\n )}\n >\n {stats?.length ? body : side}\n {stats?.length ? side : body}\n </figure>\n );\n}\n\n// Press a face to show that person's quote.\nfunction Switcher({ items, title, index, onIndex }) {\n const item = items[index] ?? items[0];\n return (\n <div className=\"grid items-center gap-8 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]\">\n <div className=\"grid min-w-0 gap-5\">\n {title ? <h2 className=\"text-balance text-3xl font-semibold tracking-tight sm:text-4xl\">{title}</h2> : null}\n <figure className=\"grid gap-5\" aria-live=\"polite\">\n <Stars item={item} />\n <blockquote className=\"max-w-2xl text-pretty text-lg leading-relaxed text-muted-foreground\">{item.quote}</blockquote>\n <Byline item={item} avatar={false} />\n </figure>\n <div role=\"group\" aria-label=\"Choose a customer\" className=\"flex flex-wrap items-center gap-2\">\n {items.map((it, i) => (\n <Button\n key={keyOf(it, i)}\n variant=\"quiet\"\n iconOnly\n aria-pressed={i === index}\n aria-label={`${it.name}${it.company ? `, ${it.company}` : \"\"}`}\n onClick={() => onIndex(i)}\n className={cx(\"rounded-lg transition-opacity\", i === index ? \"ring-2 ring-primary\" : \"opacity-60 hover:opacity-100\")}\n >\n <Avatar name={it.name} src={it.avatar} shape=\"square\" size=\"md\" label=\"\" />\n </Button>\n ))}\n </div>\n </div>\n <div className=\"hidden justify-center md:flex\">\n {item.image ? (\n <Image src={item.image} alt={item.imageAlt ?? item.name} ratio=\"portrait\" rounded=\"lg\" className=\"w-full max-w-72\" />\n ) : (\n <div className=\"grid aspect-[3/4] w-full max-w-72 place-items-center rounded-lg bg-muted\">\n <Avatar name={item.name} src={item.avatar} size=\"lg\" className=\"scale-150\" />\n </div>\n )}\n </div>\n </div>\n );\n}\n\nfunction Slide({ item }) {\n return (\n <figure className=\"mx-auto grid max-w-3xl justify-items-center gap-6 px-2 py-6 text-center sm:px-12\">\n {item.logo ? <div className=\"flex min-h-10 items-center justify-center text-xl\">{item.logo}</div> : null}\n <Stars item={item} center />\n <blockquote className=\"text-pretty text-lg font-medium leading-relaxed sm:text-2xl\">“{item.quote}”</blockquote>\n <Byline item={item} center />\n </figure>\n );\n}\n\nfunction Slides({ items, look, background, backgroundAlt, onIndex }) {\n const carousel = (\n <Carousel label=\"Customer quotes\" loop onChange={onIndex}>\n {items.map((item, i) => (\n <Slide key={keyOf(item, i)} item={item} />\n ))}\n </Carousel>\n );\n if (!background && look === \"plain\") return carousel;\n if (!background)\n return <div className={cx(LOOKS[look] ?? LOOKS.card, \"rounded-xl px-2 py-6 sm:px-8\")}>{carousel}</div>;\n return (\n <div className=\"relative isolate overflow-hidden rounded-xl border border-border\">\n <div className=\"absolute inset-0 -z-10\">\n <Image src={background} alt={backgroundAlt ?? \"\"} rounded=\"none\" className=\"h-full w-full\" />\n </div>\n <div className=\"bg-background/85 px-4 py-8 backdrop-blur-sm sm:px-10 sm:py-12\">{carousel}</div>\n </div>\n );\n}\n\nexport function Testimonials({\n items,\n layout = \"grid\",\n kicker,\n title,\n description,\n align,\n stats,\n columns = 3,\n look = \"card\",\n cardAlign = \"start\",\n byline = \"bottom\",\n quoteMark = false,\n focusOnHover = false,\n action,\n background,\n backgroundAlt,\n defaultIndex = 0,\n onIndexChange,\n loading = false,\n empty,\n label,\n className,\n}) {\n const list = (items ?? []).filter((it) => it && it.quote);\n const [index, setIndex] = useState(Math.min(defaultIndex, Math.max(0, list.length - 1)));\n const pick = (i) => {\n setIndex(i);\n onIndexChange?.(i);\n };\n const headAlign = align ?? (layout === \"switcher\" || layout === \"single\" ? \"start\" : \"center\");\n const singleOwnsHeader = layout === \"single\" || layout === \"switcher\";\n\n let body;\n if (loading) body = <Loading columns={columns} />;\n else if (list.length === 0)\n body = (\n <Empty\n icon=\"quote\"\n title={empty?.title ?? \"No reviews yet\"}\n body={empty?.body ?? \"What customers say about you will show here.\"}\n action={action ? <ActionButton action={action} /> : null}\n />\n );\n else if (layout === \"single\")\n body = <Single item={list[index] ?? list[0]} kicker={kicker} title={title} description={description} stats={stats} look={look} action={action} />;\n else if (layout === \"switcher\") body = <Switcher items={list} title={title} index={Math.min(index, list.length - 1)} onIndex={pick} />;\n else if (layout === \"carousel\") body = <Slides items={list} look={look} background={background} backgroundAlt={backgroundAlt} onIndex={onIndexChange} />;\n else\n body = (\n <Grid\n items={list}\n columns={columns}\n look={look}\n center={cardAlign === \"center\"}\n bylineTop={byline === \"top\"}\n quoteMark={quoteMark}\n focusOnHover={focusOnHover}\n action={action}\n />\n );\n\n const showHeader = !singleOwnsHeader || loading || list.length === 0;\n return (\n <section aria-label={label ?? (typeof title === \"string\" ? title : \"Testimonials\")} className={cx(\"px-4 py-16 sm:px-6 sm:py-20\", className)}>\n <div className=\"mx-auto grid w-full max-w-6xl gap-10 sm:gap-12\">\n {showHeader ? (\n <Header\n kicker={kicker}\n title={title}\n description={description}\n align={headAlign}\n action={list.length && layout === \"carousel\" ? <ActionButton action={action} /> : null}\n />\n ) : null}\n {!loading && list.length && layout !== \"single\" ? <StatsStrip stats={stats} center={headAlign === \"center\"} /> : null}\n {body}\n </div>\n </section>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/testimonials/README.md",
|
|
26
|
+
"type": "registry:file",
|
|
27
|
+
"target": "@components/makersclaw/blocks/testimonials/README.md",
|
|
28
|
+
"content": "# Testimonials\n\nA marketing section of customer quotes. Each quote carries who said it\n(name, role, company, a face or a logo), an optional star rating drawn\nwith the kit's `Rating`, an optional verified mark and \"View on X\"\nsource, and an optional link to the full story. Headline numbers\n(`stats`) sit above the cards, or beside a single quote. Five layouts\nover the same rows: a **grid** of cards, a **single** large quote, a\n**switcher** (press a face to swap the quote), and a **carousel** (swipe,\narrow keys, buttons, dots), optionally in a panel or over a picture. It\nis a full-width section: it pads itself and centres its content.\n\n```jsx\nimport { Testimonials } from \"../components/blocks/testimonials\"; // a Makersclaw app (\"./blocks/testimonials\" from components/)\nimport { Testimonials } from \"@/components/makersclaw/blocks/testimonials\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component.\n\n## The data\n\n`items` is the quotes, in order:\n\n```js\n{\n quote: \"We closed the books four days faster…\", // required; a row without one is skipped\n name: \"Maya Okafor\", // required\n id?: \"maya\", // a stable key\n role?: \"Head of Operations\", company?: \"Greenhaus\", // shown as \"role, company\"\n avatar?: \"/faces/maya.jpg\", // initials while it loads, and when it fails\n logo?: <GreenhausLogo />, // a node: the company's mark, above the quote\n rating?: 4.5, // 0–5, halves drawn; no rating, no stars\n date?: \"12 Aug 2026\", // already formatted\n verified?: true, // a check after the name\n source?: { network: \"x\", href: \"https://x.com/…\", label? }, // \"View on X\" + the brand glyph\n href?: \"/customers/greenhaus\", hrefLabel?: \"Read the story\", // a link under the quote\n title?: \"From a week of month-end to an afternoon\", // single: the heading\n image?: \"/stories/greenhaus.jpg\", imageAlt?: \"\", // single and switcher: a photo beside the quote\n highlight?: true, // grid: a primary ring round this card\n wide?: true, // grid: this card spans the row\n}\n```\n\n`source.network` is one of `x`, `linkedin`, `instagram`, `youtube`,\n`facebook`, `github`, `google`; anything else shows `label` and a link\nglyph.\n\n## Props\n\n- `items` — the quotes, as above. Required.\n- `layout` — `grid` (default) · `single` (the first quote, or the one at\n `defaultIndex`) · `switcher` · `carousel`.\n- `kicker`, `title`, `description` — the header. In `single` and\n `switcher` the title sits beside the quote (an item's own `title` wins\n in `single`).\n- `align` — the header: `center` (default for grid and carousel) ·\n `start` (default for single and switcher).\n- `stats` — `[{ label, value, unit?, caption? }]`, values already\n formatted (\"4,200+\", \"4.8\" with unit \"/ 5\"). A strip of kit `Stat`s\n under the header; in `single` they take the picture's place, beside the\n quote.\n- `columns` — the grid from `lg` up: `1` · `2` · `3` (default) · `4`. One\n column on a phone, two from `sm`.\n- `look` — each card: `card` (default: border and card fill) · `raised`\n (plus a shadow) · `plain` (no box; quiet text). In `single` and\n `carousel`, `plain` drops the panel round it.\n- `cardAlign` — `start` (default) · `center`.\n- `byline` — `bottom` (default: stars, quote, then the person) · `top`\n (the person first, stars last).\n- `quoteMark` — `true` draws a quote glyph above each quote. Default\n `false`.\n- `focusOnHover` — `true`: the card under the pointer stays lit and the\n rest dim. Decoration only; nothing is selected. Default `false`.\n- `action` — `{ label, onClick?, href?, icon? }`: \"Write a review\". A\n dashed tile after the cards in `grid`, a button in `single` and\n `carousel`, and the way forward in the empty state.\n- `background`, `backgroundAlt` — `carousel` only: a picture behind the\n slides, under a veil of the background colour so the text reads in\n every theme.\n- `defaultIndex` — the quote `single` and `switcher` open on. Default `0`.\n- `onIndexChange(index)` — the switcher's pick, or the carousel's\n settled slide.\n- `loading` — `true` draws three skeleton cards (the region is\n `aria-busy`).\n- `empty` — `{ title?, body? }` for the empty state (no `items`). Default\n \"No reviews yet\".\n- `label` — what a screen reader calls the section. Default the title.\n- `className` — the section's spacing and width. Never colour.\n\n## Example\n\n```jsx\n// components/Proof.jsx\n\"use client\";\nimport { Testimonials } from \"./blocks/testimonials\";\n\nconst QUOTES = [\n { id: \"maya\", name: \"Maya Okafor\", role: \"Head of Operations\", company: \"Greenhaus\", rating: 5,\n quote: \"We closed the books four days faster in the first month.\" },\n { id: \"tomas\", name: \"Tomás Berg\", role: \"Founder\", company: \"Northwind Freight\", rating: 4.5,\n quote: \"Every quote we send now carries its own margin.\" },\n { id: \"priya\", name: \"Priya Raman\", role: \"Customer Lead\", company: \"Loop Studio\", rating: 5,\n quote: \"Support replies went from a day to under an hour.\" },\n];\n\nexport default function Proof() {\n return (\n <Testimonials\n kicker=\"Customers\"\n title=\"Loved by small teams\"\n description=\"Operators, founders and support leads on what changed.\"\n items={QUOTES}\n stats={[\n { label: \"Teams onboarded\", value: \"4,200+\" },\n { label: \"Average rating\", value: \"4.8\", unit: \"/ 5\" },\n { label: \"Hours saved a month\", value: \"31\" },\n ]}\n />\n );\n}\n```\n\n## What it will not do\n\n- Fetch reviews or take one in: `action` hands off, and the form is\n yours (a `Sheet` with a `Rating` input, saved through your own action).\n- Colour a card by brand: a `highlight` is the theme's primary ring.\n- Autoplay: the carousel moves when the reader moves it.\n- Draw a company's logo from a URL: pass the logo as a node.\n\nA section it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add testimonials` restores them.\n\n## Credits\n\nLayouts ported from Creative Tim UI's testimonials blocks\n(https://github.com/creativetimofficial/ui, MIT), rewritten on the\nMakersclaw kit.\n"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"categories": [
|
|
32
|
+
"testimonials",
|
|
33
|
+
"reviews",
|
|
34
|
+
"quotes",
|
|
35
|
+
"social proof",
|
|
36
|
+
"customers",
|
|
37
|
+
"rating",
|
|
38
|
+
"stars",
|
|
39
|
+
"stats",
|
|
40
|
+
"carousel",
|
|
41
|
+
"marketing",
|
|
42
|
+
"landing page",
|
|
43
|
+
"case study"
|
|
44
|
+
],
|
|
45
|
+
"docs": "Installed to components/makersclaw/blocks/testimonials. Import: import { Testimonials } from \"@/components/makersclaw/blocks/testimonials\". Props and an example: components/makersclaw/blocks/testimonials/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
46
|
+
"meta": {
|
|
47
|
+
"makersclaw": {
|
|
48
|
+
"version": "0.3.0",
|
|
49
|
+
"useWhen": "Social proof on a public page: a landing page, a pricing page, a product page, a case-study teaser. Not for reviews a signed-in user writes and manages (a record list or form), or an app's own activity feed."
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|