makersclaw 0.2.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/THIRD-PARTY-NOTICES.md +180 -0
- package/package.json +3 -2
- 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 +10 -6
- 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 +205 -8
- 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 +12 -7
- 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 +3411 -29
- 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 +55 -5
- package/registry/blocks/board/block.json +2 -2
- package/registry/blocks/board/board.jsx +230 -86
- 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 +80 -0
- 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 +8 -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/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/template.json +264 -8
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "calendar-view",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Calendar",
|
|
6
|
+
"description": "Tasks on a calendar, like Google Calendar, Cron or Notion Calendar: month, week, day and agenda views over task-model's work items. Month shows all-day tasks and spans as tinted bars across their days (stacked in lanes, \"2 more\" past three) and timed ones as a dot and a time; week and day show an all-day strip over an hour grid where timed tasks are blocks placed by their times, overlapping ones side by side, with a line at now. Drag an item to another day or hour (it snaps to the quarter hour and the day columns as it moves), drag its bottom or end to change how long it runs, click an empty day or hour (or drag across hours) to type a new task in place, drag tasks without a date in from an unscheduled tray (or drop one there to take its date off). Arrow keys move a focused block, Shift+arrows resize it, Delete removes with Undo; T, J/K, M/W/D/A and C for today, next/previous, the views and a new task. Search and filters from the shared ViewToolbar, a colour per project or status, a real phone layout (a dotted month with the tapped day listed, a three-day week, a sheet for the tray). An available-time layer (closed hours muted, open slots as boxes you pick, from `availability`, `slots` or an async `getSlots`, with pure `freeSlots` / `availabilityOn` helpers) is what a booking page builds on. One async onChange saves every change; failures roll back with a notice.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/task-model.json"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/blocks/calendar-view/index.js",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/makersclaw/blocks/calendar-view/index.js",
|
|
17
|
+
"content": "// The calendar-view block — `makersclaw add calendar-view` (it brings task-model).\n// Tasks on a month, week, day or agenda: drag to move, drag an end to\n// resize, an unscheduled tray, a line at now, click an empty slot to add.\n// Fixed: compose it through its props (README.md beside this file). The\n// helpers in calendar-model.js are pure (the server included): where a\n// task sits, overlap and lane layout, the patches a gesture saves, and\n// the available-time layer (availabilityOn, freeSlots) booking-page uses.\nexport { CalendarView } from \"./calendar\";\nexport {\n CALENDAR_MODES,\n MODE_LABELS,\n DAY_MINUTES,\n UNSCHEDULE,\n snapMinutes,\n durationOf,\n eventOf,\n eventsOf,\n unscheduledOf,\n layoutTimed,\n layoutLanes,\n moveEventPatch,\n resizeEventPatch,\n placeEventPatch,\n timedPatch,\n dayPatch,\n allDayPatch,\n extendPatch,\n rangeFor,\n stepAnchor,\n rangeLabel,\n titleFor,\n hourLabel,\n minutesLabel,\n eventTimeLabel,\n availabilityOn,\n closedOn,\n freeSlots,\n normalizeSlots,\n} from \"./calendar-model\";\n"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "registry/blocks/calendar-view/calendar.jsx",
|
|
21
|
+
"type": "registry:component",
|
|
22
|
+
"target": "@components/makersclaw/blocks/calendar-view/calendar.jsx",
|
|
23
|
+
"content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, IconButton, Notice, Sheet, Skeleton, ToggleGroup, cx } from \"../../ui\";\nimport {\n DEFAULT_VIEW,\n LIFTED,\n TaskChip,\n TaskDragProvider,\n ViewToolbar,\n addDays,\n applyView,\n createTaskContext,\n dayOf,\n deleteChange,\n draftTask,\n formatDay,\n isDone,\n minutesOf,\n startOfMonth,\n useTaskChanges,\n withTime,\n} from \"../task-model\";\nimport {\n CALENDAR_MODES,\n DAY_MINUTES,\n MODE_LABELS,\n UNSCHEDULE,\n allDayPatch,\n closedOn,\n dayPatch,\n durationOf,\n eventsOf,\n extendPatch,\n minutesLabel,\n moveEventPatch,\n normalizeSlots,\n placeEventPatch,\n rangeFor,\n resizeEventPatch,\n snapMinutes,\n stepAnchor,\n timedPatch,\n titleFor,\n unscheduledOf,\n} from \"./calendar-model\";\nimport { AgendaView } from \"./agenda\";\nimport { MonthView } from \"./month\";\nimport { TimeGrid } from \"./time-grid\";\nimport { UnscheduledTray } from \"./tray\";\nimport { isTyping } from \"./paint\";\n\n// CalendarView — tasks on a calendar: month, week, day and agenda. Drag an\n// item to another day or hour, drag its end to change how long it runs,\n// click (or drag across) an empty stretch to add one there, and drag\n// tasks without a date in from the unscheduled tray. A line marks now.\n// Search and filters come from task-model's ViewToolbar, so a task\n// filtered out of the list is filtered out here too.\n//\n// Everything saves through one async `onChange(change)` answering { ok }\n// (task-model's change shapes); the calendar shows each change at once\n// and takes it back, with a notice, when the save fails.\n//\n// The available-time layer (closed hours muted, open slots as boxes you\n// can pick) is what booking-page builds on: `availability`, `slots` or\n// `getSlots`, `renderSlot`, `onSlotClick`. README.md lists every prop.\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\nconst clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\nconst UNIT = { month: \"month\", week: \"week\", day: \"day\", agenda: \"stretch\" };\nconst quote = (t) => `“${t?.title || \"Untitled\"}”`;\n\nfunction useControlled(value, initial, onChange) {\n const [own, setOwn] = useState(initial);\n const controlled = value !== undefined;\n const set = useCallback(\n (next) => {\n if (!controlled) setOwn(next);\n onChange?.(next);\n },\n [controlled, onChange],\n );\n return [controlled ? value : own, set];\n}\n\n// The wall clock, a minute at a time — after mount only, so the server\n// and the first client render agree. A fixed `now` wins (a demo).\nfunction useNow(fixed) {\n const [clock, setClock] = useState(null);\n useEffect(() => {\n if (fixed) return undefined;\n const tick = () => {\n const d = new Date();\n setClock(withTime(dayOf(d), d.getHours() * 60 + d.getMinutes()));\n };\n tick();\n const t = setInterval(tick, 60000);\n return () => clearInterval(t);\n }, [fixed]);\n return fixed ?? clock;\n}\n\nexport function CalendarView({\n tasks,\n statuses,\n people,\n projects,\n fields,\n today,\n weekStartsOn,\n weekend,\n holidays,\n now: nowProp,\n locale,\n onChange,\n onOpen,\n mode: modeProp,\n defaultMode = \"month\",\n onModeChange,\n modes = CALENDAR_MODES,\n date: dateProp,\n defaultDate,\n onDateChange,\n view: viewProp,\n defaultView,\n onViewChange,\n header = true,\n toolbar = true,\n toolbarExtra,\n tray = true,\n colorBy = \"project\",\n availability,\n slots,\n getSlots,\n renderSlot,\n onSlotClick,\n selectedSlot,\n slotLabel = \"Open\",\n hourHeight = 48,\n height = \"40rem\",\n snap = 15,\n defaultDuration = 60,\n agendaDays = 14,\n addLabel = \"Add task\",\n loading = false,\n error: readError,\n label = \"Calendar\",\n className,\n}) {\n const ctx = useMemo(\n () => createTaskContext({ statuses, people, projects, fields, today, weekStartsOn, weekend, holidays }),\n [statuses, people, projects, fields, today, weekStartsOn, weekend, holidays],\n );\n const editable = typeof onChange === \"function\";\n const { tasks: all, commit, error, clearError } = useTaskChanges(tasks, onChange);\n const now = useNow(nowProp);\n\n const [modeRaw, setModeRaw] = useControlled(modeProp, defaultMode, onModeChange);\n const mode = modes.includes(modeRaw) ? modeRaw : modes[0] ?? \"month\";\n const [anchorRaw, setAnchor] = useControlled(dateProp, dayOf(defaultDate) ?? ctx.today, onDateChange);\n const anchor = dayOf(anchorRaw) ?? ctx.today;\n const [viewRaw, setView] = useControlled(viewProp, { ...DEFAULT_VIEW, groupBy: \"none\", ...defaultView }, onViewChange);\n const view = useMemo(() => ({ ...DEFAULT_VIEW, groupBy: \"none\", ...viewRaw }), [viewRaw]);\n\n // How wide we are, not the window: a calendar in a side panel is narrow.\n const rootRef = useRef(null);\n const [width, setWidth] = useState(null);\n useIsoLayoutEffect(() => {\n const el = rootRef.current;\n if (!el) return undefined;\n setWidth(el.getBoundingClientRect().width);\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n const narrow = width !== null && width < 640;\n const sidePanel = width === null || width >= 960;\n const count = mode === \"week\" && narrow ? 3 : 7;\n\n const range = useMemo(() => rangeFor(mode, anchor, { weekStartsOn: ctx.weekStartsOn, count, agendaDays }), [mode, anchor, ctx.weekStartsOn, count, agendaDays]);\n const shown = useMemo(() => applyView(all, view, ctx).tasks, [all, view, ctx]);\n const events = useMemo(() => eventsOf(shown, { defaultMinutes: defaultDuration }), [shown, defaultDuration]);\n const unscheduled = useMemo(() => unscheduledOf(shown).filter((t) => !isDone(t, ctx)), [shown, ctx]);\n\n const [announce, setAnnounce] = useState(\"\");\n const say = (text) => setAnnounce(text);\n const [draft, setDraft] = useState(null); // { day, allDay } | { day, from, to }\n const [selected, setSelected] = useState(null); // a phone's month: the tapped day\n const [trayOpen, setTrayOpen] = useState(true);\n const [traySheet, setTraySheet] = useState(false);\n useEffect(() => setDraft(null), [mode, anchor]);\n\n // ---- saving ----------------------------------------------------------\n\n function update(task, patch, words) {\n if (!patch) return Promise.resolve({ ok: false });\n return commit({ type: \"update\", id: task.id, patch }, { label: quote(task) }).then((r) => {\n if (r.ok !== false && words) say(words);\n return r;\n });\n }\n\n function remove(task) {\n return commit(deleteChange(all, [task.id]), { label: quote(task), undo: `Deleted ${quote(task)}` });\n }\n\n function saveDraft(title) {\n const d = draft;\n setDraft(null);\n if (!d || !editable) return undefined;\n const patch = d.allDay ? { due: d.day } : { start: withTime(d.day, d.from), due: withTime(d.day, d.to) };\n const task = draftTask(title, ctx, patch);\n return commit({ type: \"create\", task }, { label: quote(task) }).then((r) => {\n if (r.ok !== false) say(`Added ${quote(task)} on ${formatDay(d.day, ctx.today, { locale })}${d.allDay ? \"\" : `, ${minutesLabel(d.from, { locale })}`}`);\n return r;\n });\n }\n\n function extend(event, dir) {\n const end = addDays(event.end, dir);\n if (end < event.start) return;\n update(event.task, extendPatch(event, end), `${quote(event.task)} now ends ${formatDay(end, ctx.today, { locale })}`);\n }\n\n // Arrow keys on a timed block: previewed at once, saved when the keys\n // rest, so ten presses are one save.\n const [nudge, setNudge] = useState(null);\n const nudgeRef = useRef(null);\n const nudgeTimer = useRef(null);\n const focusRef = useRef(null);\n useEffect(() => () => clearTimeout(nudgeTimer.current), []);\n\n function flushNudge() {\n clearTimeout(nudgeTimer.current);\n const n = nudgeRef.current;\n nudgeRef.current = null;\n setNudge(null);\n if (!n || (!n.dm && !n.dd && !n.dr)) return;\n const e = n.event;\n const len = Math.max(15, e.to - e.from + n.dr);\n const from = clamp(e.from + n.dm, 0, DAY_MINUTES - len);\n const day = addDays(e.start, n.dd);\n update(e.task, placeEventPatch(e, day, from, from + len), `${quote(e.task)} is now ${formatDay(day, ctx.today, { locale })}, ${minutesLabel(from, { locale })} to ${minutesLabel(from + len, { locale })}`);\n }\n\n function nudgeBy(event, { dm = 0, dd = 0, dr = 0 }) {\n if (nudgeRef.current && nudgeRef.current.id !== event.id) flushNudge();\n focusRef.current = event.id;\n const cur = nudgeRef.current ?? { id: event.id, event, dm: 0, dd: 0, dr: 0 };\n const next = { ...cur, dm: cur.dm + dm, dd: cur.dd + dd, dr: cur.dr + dr };\n nudgeRef.current = next;\n setNudge(next);\n clearTimeout(nudgeTimer.current);\n nudgeTimer.current = setTimeout(flushNudge, 700);\n }\n\n const shownEvents = useMemo(() => {\n if (!nudge) return events;\n return events.map((e) => {\n if (e.id !== nudge.id || !e.timed) return e;\n const day = addDays(e.start, nudge.dd);\n const len = Math.max(15, e.to - e.from + nudge.dr);\n const from = clamp(e.from + nudge.dm, 0, DAY_MINUTES - len);\n return { ...e, start: day, end: day, from, to: from + len, preview: true };\n });\n }, [events, nudge]);\n\n // A block moved by keys to another column is a new element: keep focus.\n useEffect(() => {\n if (!focusRef.current || !rootRef.current) return;\n const el = rootRef.current.querySelector(`[data-cal-id=\"${CSS.escape(focusRef.current)}\"]`);\n if (!el) return;\n if (document.activeElement !== el) el.focus({ preventScroll: false });\n if (!nudgeRef.current) focusRef.current = null;\n });\n\n // ---- navigation --------------------------------------------------------\n\n function setMode(next) {\n setModeRaw(next);\n setDraft(null);\n }\n\n function go(dir) {\n const next = stepAnchor(mode, anchor, dir, { count, agendaDays });\n setAnchor(next);\n if (mode === \"month\") setSelected(next.slice(0, 7) === ctx.today.slice(0, 7) ? ctx.today : startOfMonth(next));\n }\n\n function goToday() {\n setAnchor(ctx.today);\n setSelected(ctx.today);\n }\n\n function openDay(day) {\n if (modes.includes(\"day\")) {\n setAnchor(day);\n setModeRaw(\"day\");\n } else setAnchor(day);\n }\n\n function addNew() {\n if (!editable) return;\n const inView = mode === \"month\" ? ctx.today.slice(0, 7) === anchor.slice(0, 7) : range.days.includes(ctx.today);\n const day = inView ? ctx.today : mode === \"month\" ? startOfMonth(anchor) : range.days[0];\n if (mode === \"week\" || mode === \"day\") {\n const nowMin = dayOf(now) === day ? minutesOf(now) : null;\n const from = nowMin !== null ? Math.min(Math.ceil(nowMin / 60) * 60, DAY_MINUTES - defaultDuration) : 9 * 60;\n setDraft({ day, from, to: from + defaultDuration });\n } else {\n setDraft({ day, allDay: true });\n if (narrow) setSelected(day);\n }\n }\n\n function onKeyDown(e) {\n if (e.defaultPrevented || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;\n if (e.target.closest?.(\"[popover], dialog\")) return;\n const k = e.key.toLowerCase();\n const byKey = { m: \"month\", w: \"week\", d: \"day\", a: \"agenda\" };\n if (k === \"t\") goToday();\n else if (k === \"j\" || k === \"n\") go(1);\n else if (k === \"k\" || k === \"p\") go(-1);\n else if (k === \"c\" && editable) addNew();\n else if (byKey[k] && modes.includes(byKey[k])) setMode(byKey[k]);\n else return;\n e.preventDefault();\n }\n\n // ---- dragging ------------------------------------------------------------\n\n const ppm = hourHeight / 60;\n const pointer = useRef(null); // where the last press started\n const lastMove = useRef(null); // where the pointer is during a drag\n const grab = useRef(null); // the day of a span that was grabbed\n const [dragging, setDragging] = useState(false);\n useEffect(() => {\n if (!dragging) return undefined;\n const move = (e) => {\n lastMove.current = { x: e.clientX, y: e.clientY };\n };\n window.addEventListener(\"pointermove\", move, { passive: true });\n return () => window.removeEventListener(\"pointermove\", move);\n }, [dragging]);\n\n function pointerMinute(over, delta) {\n const p = lastMove.current ?? (pointer.current ? { y: pointer.current.y + delta.y } : null);\n if (!p || typeof over.top !== \"function\") return 9 * 60;\n return clamp(Math.floor((p.y - over.top()) / ppm / 15) * 15, 0, DAY_MINUTES - 15);\n }\n\n function onDragEnd({ active, over, delta }) {\n setDragging(false);\n const a = active?.data ?? {};\n const o = over?.data ?? null;\n const task = a.task;\n const grabbed = grab.current;\n grab.current = null;\n if (!task) return;\n const name = quote(task);\n const on = (day) => formatDay(day, ctx.today, { locale });\n if (a.kind === \"resize-y\") {\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!dm) return;\n const to = clamp(a.event.to + dm, a.event.from + 15, DAY_MINUTES);\n update(task, resizeEventPatch(a.event, to), `${name} now ends at ${minutesLabel(to, { locale })}`);\n return;\n }\n if (a.kind === \"resize-x\") {\n const dd = Math.round(delta.x / a.cellWidth());\n if (!dd) return;\n const end = addDays(a.event.end, dd) < a.event.start ? a.event.start : addDays(a.event.end, dd);\n update(task, extendPatch(a.event, end), `${name} now ends ${on(end)}`);\n return;\n }\n if (o?.kind === \"tray\") {\n if (a.kind !== \"tray\") update(task, UNSCHEDULE, `${name} has no date now`);\n return;\n }\n if (a.kind === \"timed\") {\n if (o?.kind === \"cell\") {\n update(task, allDayPatch(o.day), `${name} is all day on ${on(o.day)}`);\n return;\n }\n const days = range.days;\n const k = clamp(Math.round(delta.x / a.colWidth()), -a.colIndex, days.length - 1 - a.colIndex);\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!k && !dm) return;\n const day = days[a.colIndex + k];\n const from = clamp(a.event.from + dm, 0, DAY_MINUTES - (a.event.to - a.event.from));\n update(task, moveEventPatch(a.event, day, from), `${name} is now ${on(day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (!o) return;\n if (o.kind === \"column\") {\n const from = pointerMinute(o, delta);\n const len = a.kind === \"tray\" ? durationOf(task, defaultDuration) : defaultDuration;\n update(task, timedPatch(o.day, from, len), `${name} is now ${on(o.day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (o.kind === \"cell\") {\n const patch = a.kind === \"tray\" ? { due: o.day } : dayPatch(task, o.day, { grabbed });\n if (patch) update(task, patch, `${name} is now on ${on(o.day)}`);\n }\n }\n\n // ---- the available-time layer -------------------------------------------\n\n const slotKey = `${range.from}:${range.to}`;\n const [fetched, setFetched] = useState({ key: null, slots: [] });\n const timeMode = mode === \"week\" || mode === \"day\";\n useEffect(() => {\n if (!getSlots || !timeMode) return undefined;\n let alive = true;\n Promise.resolve()\n .then(() => getSlots({ from: range.from, to: range.to }))\n .then(\n (r) => alive && setFetched({ key: slotKey, slots: normalizeSlots(r) }),\n () => alive && setFetched({ key: slotKey, slots: [] }),\n );\n return () => {\n alive = false;\n };\n // range.from / range.to are in slotKey\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [getSlots, slotKey, timeMode]);\n const gridSlots = useMemo(() => [...normalizeSlots(slots), ...(fetched.key === slotKey ? fetched.slots : [])], [slots, fetched, slotKey]);\n const closed = useMemo(() => (availability ? (day) => closedOn(day, availability) : null), [availability]);\n\n const api = {\n ctx,\n editable,\n locale,\n colorBy,\n addLabel,\n snap,\n defaultDuration,\n open: onOpen ?? null,\n openDay,\n draft,\n startDraft: (d) => editable && setDraft(d),\n cancelDraft: () => setDraft(null),\n saveDraft,\n commit,\n update,\n remove,\n extend,\n nudge: nudgeBy,\n setGrab: (day) => {\n grab.current = day;\n },\n };\n\n // ---- drawing -----------------------------------------------------------------\n\n const title = titleFor(mode, range, anchor, { today: ctx.today, locale });\n const trayShown = tray && (sidePanel ? trayOpen : traySheet);\n const empty = editable && !loading && all.length === 0;\n\n let body;\n if (loading) body = <CalendarSkeleton />;\n else if (mode === \"month\")\n body = <MonthView weeks={range.weeks} month={anchor.slice(0, 7)} events={shownEvents} api={api} narrow={narrow} selected={selected} onSelect={setSelected} />;\n else if (mode === \"agenda\") body = <AgendaView days={range.days} events={shownEvents} api={api} />;\n else\n body = (\n <TimeGrid\n days={range.days}\n events={shownEvents}\n api={api}\n hourHeight={hourHeight}\n height={height}\n closed={closed}\n slots={gridSlots}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n now={now}\n />\n );\n\n return (\n <section\n ref={rootRef}\n aria-label={label}\n className={cx(\"grid min-w-0 gap-3\", className)}\n onKeyDown={onKeyDown}\n onPointerDownCapture={(e) => {\n pointer.current = { x: e.clientX, y: e.clientY };\n focusRef.current = null;\n }}\n onKeyDownCapture={(e) => {\n if (e.key === \" \" || e.key === \"Enter\") {\n pointer.current = null;\n grab.current = null;\n }\n }}\n >\n {header ? (\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n <h2 className=\"min-w-0 flex-1 truncate text-lg font-semibold tracking-tight\" aria-live=\"polite\">\n {title}\n </h2>\n <div className=\"flex items-center gap-1\">\n <Button size=\"sm\" variant=\"secondary\" onClick={goToday} title=\"Today (T)\">\n Today\n </Button>\n <IconButton size=\"sm\" icon=\"chevron-left\" label={`Previous ${UNIT[mode]}`} onClick={() => go(-1)} />\n <IconButton size=\"sm\" icon=\"chevron-right\" label={`Next ${UNIT[mode]}`} onClick={() => go(1)} />\n </div>\n {modes.length > 1 ? (\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Calendar view\"\n value={mode}\n onChange={setMode}\n items={modes.map((m) => ({ value: m, label: MODE_LABELS[m] ?? m }))}\n className={narrow ? \"w-full\" : undefined}\n />\n ) : null}\n </div>\n ) : null}\n {toolbar ? (\n <ViewToolbar\n view={view}\n onViewChange={setView}\n ctx={ctx}\n tasks={all}\n controls={[\"search\", \"filter\"]}\n count={shown.length}\n extra={\n <>\n {tray ? (\n <Button\n size=\"sm\"\n variant={trayShown ? \"secondary\" : \"quiet\"}\n icon=\"inbox\"\n aria-pressed={sidePanel ? trayOpen : undefined}\n onClick={() => (sidePanel ? setTrayOpen((v) => !v) : setTraySheet(true))}\n >\n {unscheduled.length ? `Unscheduled · ${unscheduled.length}` : \"Unscheduled\"}\n </Button>\n ) : null}\n {editable ? (\n <Button size=\"sm\" variant=\"primary\" icon=\"plus\" onClick={addNew} title={`${addLabel} (C)`}>\n {addLabel}\n </Button>\n ) : null}\n {toolbarExtra}\n </>\n }\n />\n ) : null}\n {readError ? <Notice tone=\"danger\">{readError}</Notice> : null}\n {error ? (\n <Notice tone=\"danger\" onDismiss={clearError}>\n {error}\n </Notice>\n ) : null}\n {empty ? (\n <p className=\"text-sm text-muted-foreground\">\n {timeMode ? \"Nothing on the calendar yet. Click an hour, or drag across a few, to add a task.\" : \"Nothing on the calendar yet. Click any day to add a task there.\"}\n </p>\n ) : null}\n <TaskDragProvider\n disabled={!editable}\n onDragStart={() => {\n lastMove.current = null;\n setDragging(true);\n }}\n onDragOver={({ over }) => {\n if (grab.current == null && over?.data?.kind === \"cell\") grab.current = over.data.day;\n }}\n onDragEnd={onDragEnd}\n onDragCancel={() => {\n setDragging(false);\n grab.current = null;\n }}\n overlay={(a) =>\n a.data.kind === \"span\" || a.data.kind === \"tray\" ? (\n <div className={cx(LIFTED, \"w-52 max-w-[70vw]\")}>\n <TaskChip task={a.data.task} ctx={ctx} />\n </div>\n ) : null\n }\n >\n <div className=\"flex min-w-0 items-start gap-4\">\n <div className=\"min-w-0 flex-1\">{body}</div>\n {tray && sidePanel && trayOpen ? <UnscheduledTray tasks={unscheduled} api={api} className=\"sticky top-4 max-h-[40rem] w-60 shrink-0 overflow-y-auto\" /> : null}\n </div>\n </TaskDragProvider>\n {tray && !sidePanel ? (\n <Sheet open={traySheet} onClose={() => setTraySheet(false)} title=\"Unscheduled\" description=\"Tasks without a date yet. Give each one a day.\">\n <UnscheduledTray tasks={unscheduled} api={api} mode=\"sheet\" />\n </Sheet>\n ) : null}\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n </section>\n );\n}\n\nfunction CalendarSkeleton() {\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\" aria-busy=\"true\" aria-label=\"Loading the calendar\">\n <div className=\"grid grid-cols-7 gap-px border-b border-border p-2\">\n {Array.from({ length: 7 }, (_, i) => (\n <Skeleton key={i} className=\"h-3 w-10\" />\n ))}\n </div>\n {Array.from({ length: 5 }, (_, w) => (\n <div key={w} className=\"grid grid-cols-7 border-b border-border last:border-b-0\">\n {Array.from({ length: 7 }, (_, d) => (\n <div key={d} className=\"grid min-h-28 content-start gap-1.5 border-e border-border p-2 last:border-e-0\">\n <Skeleton className=\"h-3 w-4\" />\n {(w + d) % 3 === 0 ? <Skeleton className=\"h-4 w-full\" /> : null}\n {(w * d) % 4 === 1 ? <Skeleton className=\"h-4 w-2/3\" /> : null}\n </div>\n ))}\n </div>\n ))}\n </div>\n );\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/blocks/calendar-view/calendar-model.js",
|
|
27
|
+
"type": "registry:component",
|
|
28
|
+
"target": "@components/makersclaw/blocks/calendar-view/calendar-model.js",
|
|
29
|
+
"content": "// The calendar's arithmetic — pure, no React. Where a task sits on a\n// calendar (a timed block on one day, or an all-day span), how\n// overlapping blocks share a column, how spans stack into lanes, the\n// patches a move or a resize saves, the window each mode shows, and the\n// available-time layer (working hours, open slots) booking-page builds\n// on. Dates are task-model's wall-calendar strings; minutes are minutes\n// after midnight.\n\nimport {\n addDays,\n addMonths,\n dayNumber,\n dayOf,\n diffDays,\n formatDate,\n formatTime,\n minutesOf,\n monthGrid,\n shiftDates,\n spanOf,\n startOfWeek,\n weekdayOf,\n withTime,\n} from \"../task-model\";\n\nexport const DAY_MINUTES = 1440;\nexport const CALENDAR_MODES = [\"month\", \"week\", \"day\", \"agenda\"];\nexport const MODE_LABELS = { month: \"Month\", week: \"Week\", day: \"Day\", agenda: \"Agenda\" };\n\nconst clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\n\n/** Rounds minutes to the nearest `step` (default 15). */\nexport function snapMinutes(minutes, step = 15) {\n return Math.round(minutes / step) * step;\n}\n\n/** How long a timed block runs when the task says only when it starts:\n * its estimate (hours), else `fallback` minutes. */\nexport function durationOf(task, fallback = 60) {\n const est = Number(task?.estimate);\n return est > 0 ? clamp(Math.round(est * 60), 15, 8 * 60) : fallback;\n}\n\n/**\n * Where a task sits on a calendar, or null when it has no date.\n * { id, task, timed, start, end, from, to, deadline, startOnly }\n * `start` / `end` are days (inclusive). A timed item lives on one day\n * (`start === end`) from `from` to `to` minutes: a start and a due time\n * on the same day; a start time alone (runs its estimate, else an hour);\n * or a due time alone (a `deadline`: a half-hour block at the due time).\n * Anything else — plain days, or a span over several days — is all-day.\n */\nexport function eventOf(task, { defaultMinutes = 60 } = {}) {\n const span = spanOf(task);\n if (!span) return null;\n const base = { id: String(task.id), task, start: span.start, end: span.end, timed: false, from: null, to: null, deadline: false, startOnly: false };\n if (span.start !== span.end) return base;\n const sDay = dayOf(task.start);\n const dDay = dayOf(task.due);\n const sm = sDay ? minutesOf(task.start) : null;\n const dm = dDay ? minutesOf(task.due) : null;\n if (sm === null && dm === null) return base;\n let from;\n let to;\n let deadline = false;\n let startOnly = false;\n if (sm !== null && dm !== null && dm > sm) [from, to] = [sm, dm];\n else if (sm !== null) {\n from = sm;\n to = sm + durationOf(task, defaultMinutes);\n startOnly = !dDay;\n } else {\n from = dm;\n to = dm + 30;\n deadline = true;\n }\n from = clamp(from, 0, DAY_MINUTES - 15);\n to = clamp(to, from + 15, DAY_MINUTES);\n return { ...base, timed: true, from, to, deadline, startOnly };\n}\n\n/** Every dated task as an event (in the order given). */\nexport function eventsOf(tasks, options) {\n const out = [];\n for (const t of tasks ?? []) {\n const e = eventOf(t, options);\n if (e) out.push(e);\n }\n return out;\n}\n\n/** Tasks with neither a start nor a due date: the unscheduled tray. */\nexport function unscheduledOf(tasks) {\n return (tasks ?? []).filter((t) => !spanOf(t));\n}\n\n/**\n * Side-by-side columns for the timed events of one day, the way Google\n * Calendar lays a busy morning: events that overlap (directly or through\n * a chain) share a cluster; each takes the first column free at its\n * start; a block widens over the columns to its right that stay free for\n * its whole run. Returns Map id → { col, cols, span }. `minMinutes` is\n * the shortest block drawn (short ones still need room for a title).\n */\nexport function layoutTimed(events, { minMinutes = 20 } = {}) {\n const list = [...events]\n .filter((e) => e.timed)\n .map((e) => ({ e, a: e.from, b: Math.max(e.to, e.from + minMinutes) }))\n .sort((x, y) => x.a - y.a || y.b - x.b || String(x.e.task.title).localeCompare(String(y.e.task.title)));\n const out = new Map();\n let cluster = [];\n let clusterEnd = -1;\n const flush = () => {\n if (!cluster.length) return;\n const cols = Math.max(...cluster.map((c) => c.col)) + 1;\n for (const c of cluster) {\n let span = 1;\n for (let k = c.col + 1; k < cols; k++) {\n if (cluster.some((o) => o.col === k && o.a < c.b && c.a < o.b)) break;\n span++;\n }\n out.set(c.e.id, { col: c.col, cols, span });\n }\n cluster = [];\n clusterEnd = -1;\n };\n let ends = [];\n for (const item of list) {\n if (item.a >= clusterEnd) {\n flush();\n ends = [];\n }\n let col = ends.findIndex((end) => end <= item.a);\n if (col < 0) col = ends.length;\n ends[col] = item.b;\n cluster.push({ ...item, col });\n clusterEnd = Math.max(clusterEnd, item.b);\n }\n flush();\n return out;\n}\n\n/**\n * Spans stacked into lanes across a row of days (a month week, a week's\n * all-day strip): longest first among those starting together, each in\n * the first lane free for its days. Returns { items: [{ event, lane, col,\n * span, clippedStart, clippedEnd }], lanes } — `col` / `span` in days\n * from the row's first; `clipped*` when it runs past the row's ends.\n */\nexport function layoutLanes(events, days) {\n const n = days.length;\n if (!n) return { items: [], lanes: 0 };\n const first = days[0];\n const last = days[n - 1];\n const list = events\n .filter((e) => e.start <= last && e.end >= first)\n .map((e) => {\n const a = Math.max(0, diffDays(first, e.start));\n const b = Math.min(n - 1, diffDays(first, e.end));\n return { event: e, col: a, span: b - a + 1, clippedStart: e.start < first, clippedEnd: e.end > last };\n })\n .sort(\n (x, y) =>\n x.col - y.col ||\n y.span - x.span ||\n Number(x.event.timed) - Number(y.event.timed) ||\n (x.event.from ?? -1) - (y.event.from ?? -1) ||\n String(x.event.task.title).localeCompare(String(y.event.task.title)),\n );\n const lanes = [];\n const items = [];\n for (const it of list) {\n let lane = lanes.findIndex((cells) => !cells.some((c) => c >= it.col && c < it.col + it.span));\n if (lane < 0) {\n lane = lanes.length;\n lanes.push([]);\n }\n for (let k = it.col; k < it.col + it.span; k++) lanes[lane].push(k);\n items.push({ ...it, lane });\n }\n return { items, lanes: lanes.length };\n}\n\n// ---- the patches a gesture saves -----------------------------------------\n\n/** A timed event moved to `day` at `from` minutes, keeping its length. */\nexport function moveEventPatch(event, day, from) {\n const len = event.to - event.from;\n const a = clamp(snapMinutes(from, 1), 0, DAY_MINUTES - len);\n if (event.deadline) return { due: withTime(day, a) };\n if (event.startOnly) return { start: withTime(day, a) };\n return { start: withTime(day, a), due: withTime(day, a + len) };\n}\n\n/** A timed event's end dragged to `to` minutes (at least 15 after it\n * starts). A deadline or a start-only task becomes a start–due block. */\nexport function resizeEventPatch(event, to) {\n const b = clamp(to, event.from + 15, DAY_MINUTES);\n if (event.deadline || event.startOnly) return { start: withTime(event.start, event.from), due: withTime(event.start, b) };\n return { due: withTime(event.start, b) };\n}\n\n/** A timed event given a new day and a new [from, to] (a keyboard nudge\n * that may move and resize at once). */\nexport function placeEventPatch(event, day, from, to) {\n if (to - from === event.to - event.from) return moveEventPatch(event, day, from);\n const a = clamp(from, 0, DAY_MINUTES - 15);\n const b = clamp(to, a + 15, DAY_MINUTES);\n return { start: withTime(day, a), due: withTime(day, b) };\n}\n\n/** A task put on the time grid (from all-day, from the tray): a block\n * of `minutes` from `from`. */\nexport function timedPatch(day, from, minutes = 60) {\n const a = clamp(from, 0, DAY_MINUTES - 15);\n return { start: withTime(day, a), due: withTime(day, Math.min(DAY_MINUTES, a + minutes)) };\n}\n\n/** A task dropped on a day (a month cell, the all-day strip). An all-day\n * span moves whole (by the days between where it was grabbed and where\n * it landed); a timed one keeps its times on the new day; an\n * unscheduled one gets that day as its due date. */\nexport function dayPatch(task, day, { grabbed } = {}) {\n const e = eventOf(task);\n if (!e) return { due: day };\n const shift = diffDays(grabbed ?? e.start, day);\n return shift ? shiftDates(task, shift) : null;\n}\n\n/** A timed task made all-day on `day` (dropped on the all-day strip). */\nexport function allDayPatch(day) {\n return { start: null, due: day };\n}\n\n/** An all-day span's last day dragged to `end` (never before it starts). */\nexport function extendPatch(event, end) {\n const e = end < event.start ? event.start : end;\n const task = event.task;\n if (e === event.start && !dayOf(task.start)) return { due: withTime(e, minutesOf(task.due)) };\n return { start: withTime(event.start, minutesOf(task.start)), due: withTime(e, minutesOf(task.due)) };\n}\n\n/** Takes a task off the calendar (dropped on the unscheduled tray). */\nexport const UNSCHEDULE = Object.freeze({ start: null, due: null });\n\n// ---- what each mode shows ------------------------------------------------\n\n/**\n * The days a mode shows around `anchor`: { from, to, days, weeks? }.\n * month the weeks that hold the anchor's month (4–6 rows)\n * week the anchor's week, or `count` days from the anchor (a phone's\n * three-day week)\n * day the anchor\n * agenda `agendaDays` days from the anchor\n */\nexport function rangeFor(mode, anchor, { weekStartsOn = 1, count = 7, agendaDays = 14 } = {}) {\n const a = dayOf(anchor);\n if (mode === \"month\") {\n const weeks = monthGrid(a, { weekStartsOn });\n return { from: weeks[0][0], to: weeks[weeks.length - 1][6], days: weeks.flat(), weeks };\n }\n if (mode === \"week\") {\n const from = count >= 7 ? startOfWeek(a, weekStartsOn) : a;\n const days = Array.from({ length: count >= 7 ? 7 : count }, (_, i) => addDays(from, i));\n return { from: days[0], to: days[days.length - 1], days };\n }\n if (mode === \"agenda\") {\n const days = Array.from({ length: agendaDays }, (_, i) => addDays(a, i));\n return { from: days[0], to: days[days.length - 1], days };\n }\n return { from: a, to: a, days: [a] };\n}\n\n/** The anchor one step back (dir −1) or on (+1) in a mode. */\nexport function stepAnchor(mode, anchor, dir, { count = 7, agendaDays = 14 } = {}) {\n if (mode === \"month\") return addMonths(anchor, dir);\n if (mode === \"week\") return addDays(anchor, dir * (count >= 7 ? 7 : count));\n if (mode === \"agenda\") return addDays(anchor, dir * agendaDays);\n return addDays(anchor, dir);\n}\n\n/** A range in words: \"Sep 21 – 27, 2026\", \"Sep 28 – Oct 4, 2026\",\n * \"Dec 29, 2026 – Jan 4, 2027\". */\nexport function rangeLabel(from, to, { locale } = {}) {\n if (from === to) return formatDate(from, { month: \"short\", day: \"numeric\", year: \"numeric\" }, { locale });\n const sameYear = from.slice(0, 4) === to.slice(0, 4);\n const sameMonth = sameYear && from.slice(5, 7) === to.slice(5, 7);\n const a = formatDate(from, sameYear ? { month: \"short\", day: \"numeric\" } : { month: \"short\", day: \"numeric\", year: \"numeric\" }, { locale });\n const b = formatDate(to, sameMonth ? { day: \"numeric\" } : { month: \"short\", day: \"numeric\" }, { locale });\n return `${a} – ${b}, ${to.slice(0, 4)}`;\n}\n\n/** The heading a mode shows: \"September 2026\", a week's range, \"Wednesday,\n * September 23\" (with the year when it is not `today`'s). */\nexport function titleFor(mode, range, anchor, { today, locale } = {}) {\n if (mode === \"month\") return formatDate(anchor, { month: \"long\", year: \"numeric\" }, { locale });\n if (mode === \"day\") {\n const sameYear = !today || anchor.slice(0, 4) === today.slice(0, 4);\n return formatDate(anchor, sameYear ? { weekday: \"long\", month: \"long\", day: \"numeric\" } : { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\" }, { locale });\n }\n return rangeLabel(range.from, range.to, { locale });\n}\n\n/** \"9 AM\" / \"21\" — an hour on the grid's gutter. */\nexport function hourLabel(hour, { locale } = {}) {\n return new Intl.DateTimeFormat(locale, { hour: \"numeric\", timeZone: \"UTC\" }).format(new Date(hour * 3600000));\n}\n\n/** \"9:30 AM\" for minutes after midnight. */\nexport function minutesLabel(minutes, { locale } = {}) {\n return formatTime(withTime(\"2000-01-01\", Math.min(minutes, DAY_MINUTES - 1)), { locale });\n}\n\n/** \"9:30 – 11:00 AM\"-ish words for a timed event, \"All day\", or \"Due\n * 5:00 PM\" for a deadline. */\nexport function eventTimeLabel(event, { locale } = {}) {\n if (!event?.timed) return \"All day\";\n if (event.deadline) return `Due ${minutesLabel(event.from, { locale })}`;\n return `${minutesLabel(event.from, { locale })} – ${minutesLabel(event.to, { locale })}`;\n}\n\n// ---- the available-time layer --------------------------------------------\n\nconst HHMM = /^(\\d{1,2}):(\\d{2})$/;\nconst toMin = (v) => {\n if (typeof v === \"number\") return v;\n const m = HHMM.exec(String(v ?? \"\"));\n return m ? +m[1] * 60 + +m[2] : null;\n};\n\nfunction intervals(list) {\n return (list ?? [])\n .map((r) => (Array.isArray(r) ? [toMin(r[0]), toMin(r[1])] : [toMin(r?.from), toMin(r?.to)]))\n .filter(([a, b]) => a !== null && b !== null && b > a)\n .map(([a, b]) => [clamp(a, 0, DAY_MINUTES), clamp(b, 0, DAY_MINUTES)])\n .sort((x, y) => x[0] - y[0]);\n}\n\n/**\n * The open hours on a day, as [[from, to], …] minutes. `availability` is\n * { days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" } the simple week\n * { weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } } per weekday (0 Sunday)\n * …either, plus dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }\n * or a function (day) → [[from, to], …]\n * Nothing given → the whole day is open.\n */\nexport function availabilityOn(day, availability) {\n if (!availability) return [[0, DAY_MINUTES]];\n if (typeof availability === \"function\") return intervals(availability(day));\n const d = dayOf(day);\n if (availability.dates && Object.hasOwn(availability.dates, d)) return intervals(availability.dates[d]);\n const wd = weekdayOf(d);\n if (availability.weekly) return intervals(availability.weekly[wd] ?? availability.weekly[String(wd)]);\n const days = availability.days ?? [1, 2, 3, 4, 5];\n if (!days.includes(wd)) return [];\n return intervals([[availability.from ?? \"09:00\", availability.to ?? \"17:00\"]]);\n}\n\n/** The closed stretches of a day: the complement of availabilityOn. */\nexport function closedOn(day, availability) {\n const open = availabilityOn(day, availability);\n const out = [];\n let cursor = 0;\n for (const [a, b] of open) {\n if (a > cursor) out.push([cursor, a]);\n cursor = Math.max(cursor, b);\n }\n if (cursor < DAY_MINUTES) out.push([cursor, DAY_MINUTES]);\n return out;\n}\n\nfunction busyIntervals(busy, day, buffer) {\n const out = [];\n for (const b of busy ?? []) {\n let a;\n let z;\n if (b && Object.hasOwn(b, \"end\")) {\n // an interval { start, end } (a booking, a meeting)\n const sd = dayOf(b.start);\n const ed = dayOf(b.end);\n if (!sd || !ed || sd > day || ed < day) continue;\n a = sd < day ? 0 : minutesOf(b.start) ?? 0;\n z = ed > day ? DAY_MINUTES : minutesOf(b.end) ?? DAY_MINUTES;\n } else {\n // a task: only a timed block takes time; an all-day date does not\n const e = eventOf(b);\n if (!e?.timed || e.start !== day) continue;\n a = e.from;\n z = e.to;\n }\n out.push([a - buffer, z + buffer]);\n }\n return out;\n}\n\n/**\n * Open slots between two days — what a booking page offers, and what a\n * calendar's `getSlots` can return as is:\n * freeSlots({ from, to, availability, busy, minutes: 30, step, buffer, notice, now })\n * → [{ id, day, start: \"2026-09-24T09:00\", end: \"2026-09-24T09:30\", from: 540, to: 570 }]\n * `busy` mixes tasks (their timed blocks) and { start, end } intervals;\n * `buffer` minutes are kept free around each; `step` is how far apart\n * slots start (default `minutes`); nothing starts before `now` plus\n * `notice` minutes (`now` a \"YYYY-MM-DDTHH:mm\" string).\n */\nexport function freeSlots({ from, to, availability, busy = [], minutes = 30, step, buffer = 0, notice = 0, now } = {}) {\n const a = dayNumber(from);\n const z = dayNumber(to ?? from);\n if (a === null || z === null) return [];\n const inc = step > 0 ? step : minutes;\n const nowDay = dayOf(now);\n const earliest = nowDay ? (minutesOf(now) ?? 0) + notice : null;\n const out = [];\n for (let n = a; n <= z && out.length < 5000; n++) {\n const day = addDays(from, n - a);\n if (nowDay && day < nowDay) continue;\n const taken = busyIntervals(busy, day, buffer);\n for (const [open, close] of availabilityOn(day, availability)) {\n for (let s = open; s + minutes <= close; s += inc) {\n const e = s + minutes;\n if (nowDay && day === nowDay && s < earliest) continue;\n if (taken.some(([x, y]) => s < y && x < e)) continue;\n out.push({ id: `${day}T${s}`, day, start: withTime(day, s), end: withTime(day, Math.min(e, DAY_MINUTES - 1)), from: s, to: e });\n }\n }\n }\n return out;\n}\n\n/** A slot list (from `slots` or `getSlots`) normalised to the shape the\n * grid draws: { id, day, from, to, start, end, …yours }. */\nexport function normalizeSlots(slots) {\n const out = [];\n for (const s of slots ?? []) {\n const day = s.day ?? dayOf(s.start);\n const from = s.from ?? minutesOf(s.start);\n const to = s.to ?? minutesOf(s.end) ?? (from !== null ? from + 30 : null);\n if (!day || from === null || to === null) continue;\n out.push({ ...s, id: String(s.id ?? `${day}T${from}`), day, from, to, start: s.start ?? withTime(day, from), end: s.end ?? withTime(day, to) });\n }\n return out;\n}\n"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "registry/blocks/calendar-view/month.jsx",
|
|
33
|
+
"type": "registry:component",
|
|
34
|
+
"target": "@components/makersclaw/blocks/calendar-view/month.jsx",
|
|
35
|
+
"content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\nimport { IconButton, Popover, cx } from \"../../ui\";\nimport { TaskChip, formatDate, isDone, isWeekend, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { layoutLanes, minutesLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\nimport { AgendaDay } from \"./agenda\";\n\n// The month: weeks of seven days. All-day tasks and spans are tinted bars\n// that run across the days they cover (stacked in lanes, never over each\n// other); a timed task is a dot, its time and its title. Three lanes show;\n// past that a day says \"2 more\" and opens its full list. Drag a bar to\n// another day, drag its end to make it longer, click an empty day to add\n// a task there. On a phone the days show dots, and tapping one lists it\n// under the grid.\n\nconst LANES = 3;\nconst LANE_PX = 24;\n\nconst dayName = (day, locale) => formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n\nexport function MonthView({ weeks, month, events, api, narrow, selected, onSelect }) {\n const locale = api.locale;\n const heads = weeks[0] ?? [];\n if (narrow) return <NarrowMonth weeks={weeks} month={month} events={events} api={api} selected={selected} onSelect={onSelect} />;\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {heads.map((d) => (\n <div key={d} className=\"px-2 py-2 text-xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"short\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <WeekRow key={week[0]} week={week} month={month} events={events} api={api} />\n ))}\n </div>\n );\n}\n\nfunction WeekRow({ week, month, events, api }) {\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, week), [events, week]);\n const overflow = layout.lanes > LANES;\n const shownLanes = overflow ? LANES - 1 : layout.lanes;\n const visible = layout.items.filter((it) => it.lane < shownLanes);\n // What each day cannot show: its items in the lanes that did not fit.\n const hidden = week.map((_, i) => layout.items.filter((it) => it.lane >= shownLanes && it.col <= i && i < it.col + it.span));\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / 7;\n const reserve = Math.max(shownLanes + (overflow ? 1 : 0), 1) * LANE_PX;\n return (\n <div ref={rowRef} className=\"relative grid grid-cols-7 border-b border-border last:border-b-0\">\n {week.map((day, i) => (\n <DayCell\n key={day}\n day={day}\n inMonth={day.slice(0, 7) === month}\n api={api}\n reserve={reserve}\n hidden={hidden[i]}\n dayItems={layout.items.filter((it) => it.col <= i && i < it.col + it.span)}\n overflow={overflow}\n />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-10 grid grid-cols-7 gap-y-0.5\" style={{ gridAutoRows: `${LANE_PX - 2}px` }}>\n {visible.map((it) => (\n <MonthItem key={`${it.event.id}:${week[0]}`} item={it} week={week} api={api} cellWidth={cellWidth} />\n ))}\n </div>\n </div>\n );\n}\n\nfunction DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {\n const { ctx, editable, locale } = api;\n const today = day === ctx.today;\n const label = dayName(day, locale);\n const drop = useDropTarget(`cell:${day}`, { kind: \"cell\", day, label });\n const drafting = api.draft && api.draft.allDay && api.draft.day === day;\n const firstOfMonth = day.endsWith(\"-01\");\n const onBackground = (e) => {\n // Only the cell's own empty space: not its buttons, not the \"more\"\n // list (a popover inside the cell, whose clicks bubble here).\n if (!editable || e.target.dataset?.calBg === undefined) return;\n api.startDraft({ day, allDay: true });\n };\n return (\n <div\n ref={drop.setNodeRef}\n data-cal-bg=\"\"\n onClick={onBackground}\n className={cx(\n \"group/cell relative flex min-h-32 min-w-0 flex-col border-e border-border p-1 transition-colors duration-150 last:border-e-0\",\n !inMonth && \"bg-muted/40\",\n inMonth && isWeekend(day, ctx.weekend) && \"bg-muted/20\",\n editable && \"cursor-default\",\n drop.isOver && \"bg-accent\",\n )}\n >\n <div className=\"flex h-9 items-center justify-between gap-1\">\n <DayNumber day={day} today={today} muted={!inMonth} label={firstOfMonth ? formatDate(day, { month: \"short\", day: \"numeric\" }, { locale }) : null} api={api} count={dayItems.length} />\n {editable ? (\n <IconButton\n size=\"sm\"\n icon=\"plus\"\n label={`${api.addLabel} on ${label}`}\n onClick={(e) => {\n e.stopPropagation();\n api.startDraft({ day, allDay: true });\n }}\n className=\"opacity-0 transition-opacity duration-150 group-hover/cell:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:hidden\"\n />\n ) : null}\n </div>\n <div data-cal-bg=\"\" aria-hidden=\"true\" style={{ height: reserve - (overflow ? LANE_PX : 0) }} />\n {overflow && hidden.length ? <MoreItems day={day} label={label} items={dayItems} count={hidden.length} api={api} /> : overflow ? <div aria-hidden=\"true\" style={{ height: LANE_PX }} /> : null}\n {drafting ? (\n <div className=\"mt-0.5 flex h-6 items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\">\n <DraftInput label={`New task on ${label}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n <div data-cal-bg=\"\" className=\"min-h-2 flex-1\" />\n </div>\n );\n}\n\nfunction DayNumber({ day, today, muted, label, api, count }) {\n const n = Number(day.slice(8, 10));\n const words = `${dayName(day, api.locale)}${count ? `, ${count} ${count === 1 ? \"item\" : \"items\"}` : \"\"}. Open the day`;\n return (\n // raw: the date is a small round target in the corner of the cell\n // (the day view opens from it); a kit Button is a 36px padded action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n api.openDay(day);\n }}\n aria-label={words}\n aria-current={today ? \"date\" : undefined}\n className={cx(\n \"inline-flex h-7 min-w-7 items-center justify-center rounded-full px-1.5 text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n today ? \"bg-primary font-semibold text-primary-foreground\" : \"hover:bg-accent\",\n !today && (muted ? \"text-muted-foreground\" : \"font-medium text-foreground\"),\n )}\n >\n {label ?? n}\n </button>\n );\n}\n\nfunction MoreItems({ day, label, items, count, api }) {\n const [open, setOpen] = useState(false);\n return (\n <Popover\n label={`Everything on ${label}`}\n open={open}\n onOpenChange={setOpen}\n width=\"md\"\n align=\"start\"\n className=\"p-2\"\n trigger={(props) => (\n // raw: a line of small text inside the day cell, sized to a lane;\n // Popover hands its trigger props to a button of ours.\n <button\n type=\"button\"\n {...props}\n onClick={(e) => e.stopPropagation()}\n className=\"flex h-6 items-center rounded-sm px-1.5 text-left text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {count} more\n </button>\n )}\n >\n <p className=\"px-1 pb-2 text-sm font-semibold\">{label}</p>\n <ul className=\"grid gap-1\">\n {items.map((it) => (\n <li key={it.event.id}>\n <TaskChip task={it.event.task} ctx={api.ctx} onOpen={api.open ? (t) => (setOpen(false), api.open(t)) : undefined} />\n </li>\n ))}\n </ul>\n </Popover>\n );\n}\n\nfunction MonthItem({ item, week, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const bar = !event.timed;\n const d = useDraggableTask(`bar:${event.id}:${week[0]}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && bar && !clippedEnd;\n const r = useDraggableTask(`rx:${event.id}:${week[0]}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n let grow = 0;\n if (r.isDragging && r.transform) grow = Math.round(r.transform.x / cellWidth());\n const shownSpan = Math.max(1, Math.min(7 - col, span + Math.max(grow, 1 - span)));\n const when = event.timed ? minutesLabel(event.from, { locale: api.locale }) : \"\";\n const words = `${title}, ${event.timed ? (event.deadline ? `due ${when}` : when) : event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`;\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (bar && editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an item on the month is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (editable) {\n // Which of its days was grabbed: a span moves by the days\n // between that one and the one it is dropped on.\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(week[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={words}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center gap-1.5 text-left text-xs outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n clippedStart ? \"ms-0 rounded-s-none\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n bar ? cx(\"rounded-sm border-s-[3px] px-1.5 font-medium text-foreground\", paint.tint, clippedStart && \"border-s-0 ps-2\") : \"rounded-sm px-1.5 hover:bg-accent\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {bar ? null : <span aria-hidden=\"true\" className={cx(\"size-2 shrink-0 rounded-full\", paint.dot, done && \"opacity-50\")} />}\n {when ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {when}\n </span>\n ) : null}\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- a phone's month: dots, and the tapped day listed under it ----------\n\nfunction NarrowMonth({ weeks, month, events, api, selected, onSelect }) {\n const { ctx, locale } = api;\n const byDay = useMemo(() => {\n const m = new Map();\n for (const e of events) {\n for (const week of weeks) {\n for (const day of week) if (e.start <= day && day <= e.end) m.set(day, [...(m.get(day) ?? []), e]);\n }\n }\n return m;\n }, [events, weeks]);\n const day = selected ?? ctx.today;\n return (\n <div className=\"grid gap-4\">\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {weeks[0].map((d) => (\n <div key={d} className=\"py-2 text-center text-2xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"narrow\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <div key={week[0]} className=\"grid grid-cols-7\">\n {week.map((d) => {\n const items = byDay.get(d) ?? [];\n const isSel = d === day;\n const isToday = d === ctx.today;\n return (\n // raw: a day on a phone's month is one tap target — a\n // number and its dots — that lists the day below.\n <button\n key={d}\n type=\"button\"\n onClick={() => onSelect(d)}\n aria-pressed={isSel}\n aria-label={`${dayName(d, locale)}${items.length ? `, ${items.length} ${items.length === 1 ? \"item\" : \"items\"}` : \"\"}`}\n className={cx(\n \"flex min-h-12 flex-col items-center justify-start gap-1 py-1.5 outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n d.slice(0, 7) !== month && \"text-muted-foreground\",\n )}\n >\n <span\n className={cx(\n \"inline-flex size-7 items-center justify-center rounded-full text-sm tabular-nums\",\n isSel ? \"bg-foreground font-semibold text-background\" : isToday ? \"font-semibold text-primary\" : undefined,\n )}\n >\n {Number(d.slice(8, 10))}\n </span>\n <span className=\"flex h-1.5 items-center gap-0.5\" aria-hidden=\"true\">\n {items.slice(0, 3).map((e) => (\n <span key={e.id} className={cx(\"size-1.5 rounded-full\", paintOf(e.task, ctx, api.colorBy).dot)} />\n ))}\n </span>\n </button>\n );\n })}\n </div>\n ))}\n </div>\n <AgendaDay day={day} events={byDay.get(day) ?? eventsOn(events, day)} api={api} standalone />\n </div>\n );\n}\n\nfunction eventsOn(events, day) {\n return events.filter((e) => e.start <= day && day <= e.end);\n}\n\n"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "registry/blocks/calendar-view/time-grid.jsx",
|
|
39
|
+
"type": "registry:component",
|
|
40
|
+
"target": "@components/makersclaw/blocks/calendar-view/time-grid.jsx",
|
|
41
|
+
"content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { dayOf, formatDate, isDone, isWeekend, minutesOf, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { DAY_MINUTES, eventTimeLabel, hourLabel, layoutLanes, layoutTimed, minutesLabel, snapMinutes } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\n\n// The week and the day: a strip of all-day items on top, then the hours\n// as a scrolling grid with one column per day. Timed tasks are tinted\n// blocks placed by their times; overlapping ones share the column side by\n// side. Drag a block to move it (it snaps to the quarter hour and to the\n// day columns as it goes), drag its bottom edge to change how long it\n// runs, press on an empty stretch and drag to make a new one (a click\n// makes an hour). A line marks now. The available-time layer draws closed\n// hours muted and open slots as dashed boxes.\n//\n// Keyboard: a focused block moves with the arrow keys (up/down 15\n// minutes, left/right a day), Shift+up/down changes its length, Enter\n// opens it, Delete removes it (with Undo).\n\nconst GUTTER = \"w-14 shrink-0\";\n\nexport function TimeGrid({ days, events, api, hourHeight = 48, height = \"40rem\", closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, now }) {\n const { ctx, locale } = api;\n const n = days.length;\n const cols = { gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` };\n const scrollRef = useRef(null);\n const colsRef = useRef(null);\n const colWidth = () => (colsRef.current?.getBoundingClientRect().width ?? 700) / n;\n const timed = useMemo(() => events.filter((e) => e.timed), [events]);\n const allDay = useMemo(() => events.filter((e) => !e.timed), [events]);\n const nowDay = dayOf(now);\n const nowMin = now ? minutesOf(now) : null;\n\n // Open the grid near what matters: now, else the day's first item, else 8 AM.\n const first = timed.length ? Math.min(...timed.map((e) => e.from)) : null;\n const focusMin = nowDay && days.includes(nowDay) && nowMin !== null ? nowMin - 90 : first !== null ? first - 30 : 8 * 60;\n const scrollKey = `${days[0]}:${n}`;\n const scrolled = useRef(null);\n useEffect(() => {\n if (scrolled.current === scrollKey || !scrollRef.current) return;\n scrolled.current = scrollKey;\n scrollRef.current.scrollTop = Math.max(0, (focusMin / 60) * hourHeight);\n }, [scrollKey, focusMin, hourHeight]);\n\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={GUTTER} />\n <div className=\"grid flex-1\" style={cols}>\n {days.map((day) => (\n <DayHead key={day} day={day} api={api} single={n === 1} />\n ))}\n </div>\n </div>\n <AllDayStrip days={days} events={allDay} api={api} cols={cols} />\n <div ref={scrollRef} className=\"relative overflow-y-auto overscroll-contain [scrollbar-gutter:stable]\" style={{ height }}>\n <div className=\"relative flex\" style={{ height: 24 * hourHeight }}>\n <div className={cx(GUTTER, \"relative\")} aria-hidden=\"true\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <span key={h} data-nums className=\"absolute end-2 -translate-y-1/2 text-2xs text-muted-foreground\" style={{ top: h * hourHeight }}>\n {hourLabel(h, { locale })}\n </span>\n ))}\n </div>\n <div ref={colsRef} className=\"relative grid flex-1\" style={cols}>\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <div key={h} className=\"absolute inset-x-0 border-t border-border\" style={{ top: h * hourHeight }} />\n ))}\n </div>\n {days.map((day, i) => (\n <DayColumn\n key={day}\n day={day}\n index={i}\n days={days}\n events={timed.filter((e) => e.start === day)}\n api={api}\n hourHeight={hourHeight}\n colWidth={colWidth}\n closed={closed ? closed(day) : null}\n slots={slots?.filter((s) => s.day === day)}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n nowMin={day === nowDay ? nowMin : null}\n weekend={isWeekend(day, ctx.weekend)}\n />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n\nfunction DayHead({ day, api, single }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n return (\n <div className={cx(\"flex min-w-0 items-center justify-center border-s border-border py-2 first:border-s-0\", single && \"justify-start ps-3\")}>\n {/* raw: the heading is the way into that day (a button in the header\n row); a kit Button would pad it as an action. */}\n <button\n type=\"button\"\n onClick={() => api.openDay(day)}\n disabled={single}\n aria-label={`${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale })}. Open the day`}\n aria-current={today ? \"date\" : undefined}\n className=\"group/head inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent\"\n >\n <span className={cx(\"text-xs\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>{formatDate(day, { weekday: \"short\" }, { locale })}</span>\n <span\n data-nums\n className={cx(\"inline-flex size-7 items-center justify-center rounded-full text-sm font-semibold\", today ? \"bg-primary text-primary-foreground\" : \"text-foreground\")}\n >\n {Number(day.slice(8, 10))}\n </span>\n </button>\n </div>\n );\n}\n\n// ---- the all-day strip ----------------------------------------------------\n\nconst STRIP_LANES = 3;\n\nfunction AllDayStrip({ days, events, api, cols }) {\n const [open, setOpen] = useState(false);\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, days), [events, days]);\n const over = layout.lanes > STRIP_LANES && !open;\n const lanes = over ? STRIP_LANES - 1 : layout.lanes;\n const hiddenCount = layout.items.filter((it) => it.lane >= lanes).length;\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / days.length;\n const drafting = api.draft?.allDay && days.includes(api.draft.day);\n const rows = Math.max(lanes + (over ? 1 : 0) + (drafting ? 1 : 0), 1);\n return (\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={cx(GUTTER, \"flex items-start justify-end pe-2 pt-1.5 text-2xs text-muted-foreground\")}>All day</div>\n <div ref={rowRef} className=\"relative grid flex-1\" style={{ ...cols, minHeight: rows * 24 + 8 }}>\n {days.map((day) => (\n <StripCell key={day} day={day} api={api} />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-1 grid gap-y-0.5\" style={{ ...cols, gridAutoRows: \"22px\" }}>\n {layout.items\n .filter((it) => it.lane < lanes)\n .map((it) => (\n <StripItem key={it.event.id} item={it} days={days} api={api} cellWidth={cellWidth} />\n ))}\n {over ? (\n // raw: a line of small text sized to a lane that opens the strip;\n // a kit Button is a 36px action.\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n style={{ gridColumn: `1 / span ${days.length}`, gridRow: lanes + 1 }}\n className=\"pointer-events-auto mx-1 justify-self-start rounded-sm px-1.5 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {hiddenCount} more\n </button>\n ) : null}\n {drafting ? (\n <div\n style={{ gridColumn: `${days.indexOf(api.draft.day) + 1}`, gridRow: lanes + (over ? 2 : 1) }}\n className=\"pointer-events-auto mx-1 flex items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\"\n >\n <DraftInput label={`New all-day task on ${formatDate(api.draft.day, { weekday: \"long\", month: \"long\", day: \"numeric\" })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nfunction StripCell({ day, api }) {\n const label = `${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale: api.locale })}, all day`;\n const drop = useDropTarget(`strip:${day}`, { kind: \"cell\", day, label });\n return (\n <div\n ref={drop.setNodeRef}\n onClick={(e) => {\n if (api.editable && e.target === e.currentTarget) api.startDraft({ day, allDay: true });\n }}\n className={cx(\"border-s border-border transition-colors duration-150 first:border-s-0\", drop.isOver && \"bg-accent\")}\n />\n );\n}\n\nfunction StripItem({ item, days, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`strip:${event.id}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && !clippedEnd;\n const r = useDraggableTask(`sx:${event.id}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n const grow = r.isDragging && r.transform ? Math.round(r.transform.x / cellWidth()) : 0;\n const shownSpan = Math.max(1, Math.min(days.length - col, span + Math.max(grow, 1 - span)));\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an all-day bar is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (!editable) return;\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(days[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center rounded-sm border-s-[3px] px-1.5 text-left text-xs font-medium text-foreground outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n clippedStart ? \"ms-0 rounded-s-none border-s-0 ps-2\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- one day's column of hours ------------------------------------------\n\nfunction DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, nowMin, weekend }) {\n const { editable, locale } = api;\n const ppm = hourHeight / 60;\n const ref = useRef(null);\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n const drop = useDropTarget(`col:${day}`, { kind: \"column\", day, label, top: () => ref.current?.getBoundingClientRect().top ?? 0 });\n const layout = useMemo(() => layoutTimed(events), [events]);\n const [hover, setHover] = useState(null); // minutes under the mouse\n const [making, setMaking] = useState(null); // { a, b } while press-dragging a new block\n const lastPointer = useRef(\"mouse\");\n const drafting = api.draft && !api.draft.allDay && api.draft.day === day ? api.draft : null;\n\n const minuteAt = (clientY) => {\n const top = ref.current?.getBoundingClientRect().top ?? 0;\n return Math.max(0, Math.min(DAY_MINUTES - 15, (clientY - top) / ppm));\n };\n const onBg = (e) => e.target === e.currentTarget || e.target.dataset?.calBg !== undefined;\n\n return (\n <div\n ref={(el) => {\n ref.current = el;\n drop.setNodeRef(el);\n }}\n data-cal-bg=\"\"\n className={cx(\n \"relative border-s border-border transition-colors duration-150 first:border-s-0\",\n weekend && \"bg-muted/20\",\n drop.isOver && \"bg-accent/50\",\n )}\n onPointerDown={(e) => {\n lastPointer.current = e.pointerType;\n if (!editable || e.pointerType !== \"mouse\" || e.button !== 0 || !onBg(e)) return;\n const m = snapMinutes(minuteAt(e.clientY) - 7, 15);\n setMaking({ a: m, b: m + 15 });\n e.currentTarget.setPointerCapture?.(e.pointerId);\n }}\n onPointerMove={(e) => {\n if (e.pointerType !== \"mouse\") return;\n const m = minuteAt(e.clientY);\n if (making) {\n const b = snapMinutes(m, 15);\n setMaking((x) => (x && (b !== x.b ? { ...x, b: Math.max(b, x.a + 15) } : x)));\n } else if (editable && onBg(e)) {\n const h = Math.floor(m / 30) * 30;\n if (h !== hover) setHover(h);\n } else if (hover !== null) setHover(null);\n }}\n onPointerLeave={() => setHover(null)}\n onPointerUp={(e) => {\n if (!making) return;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n const { a, b } = making;\n setMaking(null);\n setHover(null);\n // A click (no real drag) makes the default length from the half hour.\n if (b - a <= 15) {\n const from = Math.floor(a / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n } else api.startDraft({ day, from: a, to: b });\n }}\n onClick={(e) => {\n // A tap on a touch screen: an hour from the half hour tapped.\n if (!editable || lastPointer.current === \"mouse\" || !onBg(e)) return;\n const from = Math.floor(minuteAt(e.clientY) / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n }}\n >\n {closed?.map(([a, b]) => (\n <div key={a} data-cal-bg=\"\" aria-hidden=\"true\" className=\"absolute inset-x-0 bg-muted/60\" style={{ top: a * ppm, height: (b - a) * ppm }} />\n ))}\n {slots?.map((s) => (\n <Slot key={s.id} slot={s} ppm={ppm} renderSlot={renderSlot} onSlotClick={onSlotClick} selected={selectedSlot != null && (selectedSlot === s.id || selectedSlot === s.start)} label={slotLabel} locale={locale} />\n ))}\n {hover !== null && !making && !drafting ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-1 flex items-start rounded-md border border-dashed border-primary/40 px-1.5 pt-0.5 text-2xs font-medium text-primary\"\n style={{ top: hover * ppm + 1, height: 30 * ppm - 2 }}\n >\n + {minutesLabel(hover, { locale })}\n </div>\n ) : null}\n {events.map((e) => (\n <EventBlock key={e.id} event={e} pos={layout.get(e.id) ?? { col: 0, cols: 1, span: 1 }} ppm={ppm} api={api} index={index} days={days} colWidth={colWidth} />\n ))}\n {making ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-1 z-10 rounded-md bg-primary/15 px-1.5 py-0.5 text-2xs font-medium text-primary ring-1 ring-primary\" style={{ top: making.a * ppm, height: (making.b - making.a) * ppm }}>\n {minutesLabel(making.a, { locale })} – {minutesLabel(making.b, { locale })}\n </div>\n ) : null}\n {drafting ? (\n <div\n className=\"absolute inset-x-1 z-20 flex flex-col gap-0.5 rounded-md bg-card px-1.5 py-1 shadow-md ring-1 ring-primary\"\n style={{ top: drafting.from * ppm, height: Math.max((drafting.to - drafting.from) * ppm, 40) }}\n >\n <DraftInput label={`New task on ${label}, ${minutesLabel(drafting.from, { locale })} to ${minutesLabel(drafting.to, { locale })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n <span data-nums className=\"text-2xs text-muted-foreground\">\n {minutesLabel(drafting.from, { locale })} – {minutesLabel(drafting.to, { locale })}\n </span>\n </div>\n ) : null}\n {nowMin !== null ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 z-10 flex items-center\" style={{ top: nowMin * ppm - 1 }}>\n <span className=\"-ms-1 size-2.5 shrink-0 rounded-full bg-primary ring-2 ring-card\" />\n <span className=\"h-0.5 flex-1 bg-primary\" />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction Slot({ slot, ppm, renderSlot, onSlotClick, selected, label = \"Open\", locale }) {\n const style = { top: slot.from * ppm + 1, height: Math.max((slot.to - slot.from) * ppm - 2, 16) };\n const words = `${label}, ${minutesLabel(slot.from, { locale })} to ${minutesLabel(slot.to, { locale })}`;\n const face = renderSlot ? (\n renderSlot(slot, { selected })\n ) : (\n <span data-nums className=\"truncate\">\n {minutesLabel(slot.from, { locale })}\n </span>\n );\n const cls = cx(\n \"absolute inset-x-1 flex items-start overflow-hidden rounded-md border border-dashed px-1.5 pt-0.5 text-left text-2xs font-medium transition-colors duration-150\",\n selected ? \"border-solid border-primary bg-primary text-primary-foreground\" : \"border-primary/50 bg-primary/5 text-primary\",\n );\n if (!onSlotClick) {\n return (\n <div className={cx(cls, \"pointer-events-none\")} style={style} aria-label={words}>\n {face}\n </div>\n );\n }\n return (\n // raw: an open slot is a box on the grid placed by its times; a kit\n // Button would pad and centre it as an action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n onSlotClick(slot);\n }}\n onPointerDown={(e) => e.stopPropagation()}\n aria-label={words}\n aria-pressed={selected}\n style={style}\n className={cx(cls, \"z-[1] outline-none hover:bg-primary/15 focus-visible:ring-2 focus-visible:ring-ring\", selected && \"hover:bg-primary\")}\n >\n {face}\n </button>\n );\n}\n\nfunction EventBlock({ event, pos, ppm, api, index, days, colWidth }) {\n const { ctx, editable, locale } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`ev:${event.id}`, { kind: \"timed\", task, event, colIndex: index, colWidth }, { disabled: !editable });\n const canResize = editable && !event.deadline;\n const r = useDraggableTask(`ry:${event.id}`, { kind: \"resize-y\", task, event }, { disabled: !canResize });\n\n // While dragged, the block itself shows where it will land: snapped to\n // the quarter hour and to a day column.\n let from = event.from;\n let to = event.to;\n let dx = 0;\n if (d.isDragging && d.transform) {\n const w = colWidth();\n const k = Math.max(-index, Math.min(days.length - 1 - index, Math.round(d.transform.x / w)));\n dx = k * w;\n const dm = Math.max(-event.from, Math.min(DAY_MINUTES - event.to, snapMinutes(d.transform.y / ppm, api.snap)));\n from += dm;\n to += dm;\n }\n if (r.isDragging && r.transform) to = Math.max(from + 15, Math.min(DAY_MINUTES, to + snapMinutes(r.transform.y / ppm, api.snap)));\n const h = Math.max((to - from) * ppm, 18);\n const tall = h >= 38;\n const shown = { ...event, from, to };\n const time = eventTimeLabel(shown, { locale });\n const moving = d.isDragging || r.isDragging;\n\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\" || e.key === \" \") return true;\n if (!editable) return false;\n if (e.key === \"Delete\" || e.key === \"Backspace\") {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n const step = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];\n if (!step) return false;\n e.preventDefault();\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") api.nudge(event, { dd: step });\n else if (e.shiftKey) api.nudge(event, { dr: step * 15 });\n else api.nudge(event, { dm: step * 15 });\n return true;\n });\n\n return (\n // raw: a timed block is a drag source placed by its times that also\n // opens; a kit Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : { onKeyDown: keys.onKeyDown })}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${time}${done ? \", done\" : \"\"}`}\n aria-keyshortcuts={editable ? \"ArrowUp ArrowDown ArrowLeft ArrowRight Shift+ArrowUp Shift+ArrowDown Enter Delete\" : undefined}\n title={`${title}\\n${time}`}\n style={{\n top: from * ppm + 1,\n height: h - 2,\n left: `calc(${(pos.col / pos.cols) * 100}% + 2px)`,\n width: `calc(${(pos.span / pos.cols) * 100}% - ${pos.col + pos.span < pos.cols ? 2 : 6}px)`,\n transform: dx ? `translateX(${dx}px)` : undefined,\n }}\n className={cx(\n \"absolute flex flex-col overflow-hidden rounded-md border-s-[3px] px-1.5 text-left text-xs leading-tight text-foreground outline-none transition-[box-shadow,opacity,background-color] duration-150 focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n tall ? \"py-1\" : \"justify-center py-0\",\n pos.cols > 1 && !moving && \"ring-1 ring-card\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n moving && \"z-30 shadow-lg ring-1 ring-primary/40\",\n event.preview && \"z-20 ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {tall ? (\n <>\n <span className={cx(\"line-clamp-2 font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"mt-0.5 truncate text-2xs text-muted-foreground\">\n {time}\n </span>\n </>\n ) : (\n <span className=\"flex min-w-0 items-baseline gap-1.5\">\n <span className={cx(\"truncate font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"shrink-0 text-2xs text-muted-foreground\">\n {minutesLabel(from, { locale })}\n </span>\n </span>\n )}\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-x-0 bottom-0 h-2 cursor-ns-resize after:absolute after:inset-x-1/3 after:bottom-0.5 after:h-0.5 after:rounded-full after:bg-foreground/30 after:opacity-0 after:transition-opacity hover:after:opacity-100\"\n />\n ) : null}\n </button>\n );\n}\n"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "registry/blocks/calendar-view/agenda.jsx",
|
|
45
|
+
"type": "registry:component",
|
|
46
|
+
"target": "@components/makersclaw/blocks/calendar-view/agenda.jsx",
|
|
47
|
+
"content": "\"use client\";\n\nimport { Button, Empty, cx } from \"../../ui\";\nimport { AssigneeStack, CompleteToggle, diffDays, formatDate, isDone, toggleDoneChange } from \"../task-model\";\nimport { eventTimeLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf } from \"./paint\";\n\n// The agenda: the days ahead as a list, each day's items in time order\n// (all-day first), with a round check to complete each. Days with nothing\n// on them are left out — except today, which always shows. On a phone\n// the month's tapped day is drawn by the same AgendaDay.\n\nconst WORDS = \"flex min-w-0 flex-1 flex-col items-start py-1.5 text-left outline-none\";\n\nconst order = (a, b) => Number(a.timed) - Number(b.timed) || (a.from ?? 0) - (b.from ?? 0) || String(a.task.title).localeCompare(String(b.task.title));\n\nexport function AgendaView({ days, events, api }) {\n const { ctx } = api;\n const sections = days\n .map((day) => ({ day, items: events.filter((e) => e.start <= day && day <= e.end).sort(order) }))\n .filter((s) => s.items.length || s.day === ctx.today || (api.draft?.allDay && api.draft.day === s.day));\n const any = sections.some((s) => s.items.length);\n if (!any && !(api.draft && days.includes(api.draft.day))) {\n return (\n <Empty\n icon=\"calendar\"\n title=\"Nothing scheduled in these days\"\n body=\"Tasks with a date show up here, day by day. Give one a date, or add a new one.\"\n action={\n api.editable ? (\n <Button variant=\"primary\" size=\"sm\" icon=\"plus\" onClick={() => api.startDraft({ day: days[0], allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null\n }\n />\n );\n }\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n {sections.map((s) => (\n <AgendaDay key={s.day} day={s.day} events={s.items} api={api} />\n ))}\n </div>\n );\n}\n\n/** One day of the agenda: its date on the left, its items on the right. */\nexport function AgendaDay({ day, events, api, standalone = false }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n const items = [...events].sort(order);\n const drafting = api.draft?.allDay && api.draft.day === day;\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n return (\n <section\n aria-label={label}\n className={cx(\n \"grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-3 px-3 py-3 sm:grid-cols-[6.5rem_minmax(0,1fr)] sm:px-4\",\n standalone ? \"rounded-lg border border-border bg-card\" : \"border-b border-border last:border-b-0\",\n )}\n >\n <h3 className=\"flex flex-col items-start pt-1 sm:flex-row sm:items-baseline sm:gap-2\">\n <span data-nums className={cx(\"text-xl font-semibold leading-none\", today ? \"text-primary\" : \"text-foreground\")}>\n {Number(day.slice(8, 10))}\n </span>\n <span className={cx(\"mt-1 text-xs sm:mt-0\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>\n {today ? \"Today\" : diffDays(ctx.today, day) === 1 ? \"Tomorrow\" : formatDate(day, { weekday: \"short\" }, { locale })}\n </span>\n </h3>\n <ul className=\"grid min-w-0 gap-0.5\">\n {drafting ? (\n <li className=\"flex min-h-11 items-center gap-2 rounded-md bg-primary/10 px-2 ring-1 ring-primary\">\n <DraftInput className=\"text-sm\" label={`New task on ${label}`} placeholder=\"New task, then Enter\" onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </li>\n ) : null}\n {items.map((e) => (\n <AgendaRow key={e.id} event={e} day={day} api={api} />\n ))}\n {!items.length && !drafting ? (\n <li className=\"flex min-h-11 items-center justify-between gap-2 px-2 text-sm text-muted-foreground\">\n <span>{today ? \"Nothing scheduled today\" : \"Nothing scheduled\"}</span>\n {api.editable ? (\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null}\n </li>\n ) : null}\n {items.length > 0 && standalone && api.editable && !drafting ? (\n <li className=\"pt-1\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" align=\"start\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n </li>\n ) : null}\n </ul>\n </section>\n );\n}\n\nfunction AgendaRow({ event, day, api }) {\n const { ctx, locale, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const title = task.title || \"Untitled\";\n const paint = paintOf(task, ctx, api.colorBy);\n const project = ctx.projectById.get(String(task.project));\n const multi = event.start !== event.end;\n const time = multi ? `Day ${diffDays(event.start, day) + 1} of ${diffDays(event.start, event.end) + 1}` : eventTimeLabel(event, { locale });\n const words = (\n <>\n <span className={cx(\"w-full truncate text-sm font-medium\", done && \"text-muted-foreground line-through\")} title={title}>\n {title}\n </span>\n <span className=\"flex w-full min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n {editable ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", paint.dot)} /> : null}\n <span data-nums className=\"shrink-0\">\n {time}\n </span>\n {project ? <span className=\"truncate\">· {project.name}</span> : null}\n </span>\n </>\n );\n return (\n <li className=\"group/row flex min-h-11 min-w-0 items-center gap-1 rounded-md pe-2 transition-colors duration-150 hover:bg-accent/60\">\n {editable ? (\n <CompleteToggle size=\"sm\" done={done} label={`“${title}”`} onToggle={() => api.commit(toggleDoneChange(task, ctx), { label: `“${title}”` })} />\n ) : (\n <span aria-hidden=\"true\" className=\"inline-flex size-9 shrink-0 items-center justify-center\">\n <span className={cx(\"size-2 rounded-full\", paint.dot)} />\n </span>\n )}\n {api.open ? (\n // raw: the row's words open the task; a kit Button centres and pads\n // an action, this is two lines of text.\n <button type=\"button\" onClick={() => api.open(task)} className={cx(WORDS, \"rounded-sm focus-visible:ring-2 focus-visible:ring-ring\")}>\n {words}\n </button>\n ) : (\n <span className={WORDS}>{words}</span>\n )}\n {task.assignees?.length ? <AssigneeStack ids={task.assignees} ctx={ctx} max={2} /> : null}\n </li>\n );\n}\n"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "registry/blocks/calendar-view/tray.jsx",
|
|
51
|
+
"type": "registry:component",
|
|
52
|
+
"target": "@components/makersclaw/blocks/calendar-view/tray.jsx",
|
|
53
|
+
"content": "\"use client\";\n\nimport { Icon, cx } from \"../../ui\";\nimport { DueLabel, TaskChip, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { withKeys } from \"./paint\";\n\n// The unscheduled tray: open tasks with no date yet. Beside a wide\n// calendar it is a column to drag from (onto a day, onto an hour) and to\n// drop on (which takes the date off). On a phone, where there is nothing\n// to drag onto, each task has a date picker instead.\n\nexport function UnscheduledTray({ tasks, api, mode = \"panel\", className }) {\n const drop = useDropTarget(\"tray\", { kind: \"tray\", label: \"Unscheduled\" }, { disabled: !api.editable || mode !== \"panel\" });\n const dragging = drop.active && drop.active.data?.kind !== \"tray\";\n return (\n <section\n ref={drop.setNodeRef}\n aria-label=\"Unscheduled tasks\"\n className={cx(\n \"flex min-h-0 flex-col gap-2 transition-colors duration-150\",\n mode === \"panel\" && \"rounded-lg border border-border bg-card p-3\",\n mode === \"panel\" && dragging && \"border-dashed border-primary\",\n drop.isOver && \"bg-accent\",\n className,\n )}\n >\n {mode === \"panel\" ? (\n <header className=\"flex items-baseline justify-between gap-2\">\n <h3 className=\"text-sm font-semibold\">Unscheduled</h3>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {tasks.length}\n </span>\n </header>\n ) : null}\n {dragging ? (\n <p className=\"text-xs text-muted-foreground\">Drop here to take the date off.</p>\n ) : tasks.length && api.editable && mode === \"panel\" ? (\n <p className=\"text-xs text-muted-foreground\">Drag a task onto a day or an hour to schedule it.</p>\n ) : null}\n {tasks.length ? (\n <ul className=\"grid gap-1.5\">\n {tasks.map((t) => (mode === \"panel\" ? <TrayItem key={t.id} task={t} api={api} /> : <SheetItem key={t.id} task={t} api={api} />))}\n </ul>\n ) : (\n <div className=\"grid place-items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-6 text-center\">\n <Icon name=\"circle-check\" size={18} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">Everything has a date</p>\n <p className=\"text-xs text-muted-foreground\">Tasks without one wait here.</p>\n </div>\n )}\n </section>\n );\n}\n\nfunction TrayItem({ task, api }) {\n const d = useDraggableTask(`tray:${task.id}`, { kind: \"tray\", task }, { disabled: !api.editable });\n const keys = withKeys(d.handle, (e) => e.key === \"Enter\");\n return (\n <li ref={d.setNodeRef} data-cal-id={task.id}>\n <TaskChip\n task={task}\n ctx={api.ctx}\n onOpen={api.open ?? undefined}\n dragging={d.isDragging}\n dragProps={api.editable ? keys : undefined}\n className={cx(\"min-h-9\", api.editable && \"cursor-grab active:cursor-grabbing\")}\n />\n </li>\n );\n}\n\nfunction SheetItem({ task, api }) {\n return (\n <li className=\"group/row flex min-h-11 items-center gap-2\">\n <TaskChip task={task} ctx={api.ctx} onOpen={api.open ?? undefined} className=\"min-h-9 flex-1\" />\n {api.editable ? (\n <DueLabel\n value={null}\n ctx={api.ctx}\n quiet={false}\n placeholder=\"Schedule\"\n onChange={(day) => day && api.update(task, { due: day }, `Scheduled “${task.title}”`)}\n />\n ) : null}\n </li>\n );\n}\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "registry/blocks/calendar-view/draft.jsx",
|
|
57
|
+
"type": "registry:component",
|
|
58
|
+
"target": "@components/makersclaw/blocks/calendar-view/draft.jsx",
|
|
59
|
+
"content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { cx } from \"../../ui\";\n\n// The quick-add field that opens where the founder clicked: an empty\n// month day, a stretch of the time grid, the top of an agenda day. Type a\n// title, Enter saves (the item appears at once), Escape or leaving it\n// empty puts it away; leaving it with words in it saves them.\nexport function DraftInput({ label, placeholder = \"New task\", onSubmit, onCancel, className }) {\n const [value, setValue] = useState(\"\");\n const settled = useRef(false);\n function submit() {\n if (settled.current) return;\n const title = value.trim();\n settled.current = true;\n if (!title) onCancel?.();\n else onSubmit?.(title);\n }\n return (\n // The title is typed straight into the new item's own box, the way a\n // calendar names a new event; the box around it is the border.\n // raw: a kit Input is a 44px bordered form field, too big for a slot.\n <input\n type=\"text\"\n autoFocus\n aria-label={label}\n value={value}\n placeholder={placeholder}\n enterKeyHint=\"done\"\n onChange={(e) => setValue(e.target.value)}\n onPointerDown={(e) => e.stopPropagation()}\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n e.stopPropagation();\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n e.preventDefault();\n submit();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n settled.current = true;\n onCancel?.();\n }\n }}\n onBlur={submit}\n className={cx(\"w-full min-w-0 bg-transparent text-xs font-medium text-foreground outline-none placeholder:font-normal placeholder:text-muted-foreground\", className)}\n />\n );\n}\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "registry/blocks/calendar-view/paint.js",
|
|
63
|
+
"type": "registry:component",
|
|
64
|
+
"target": "@components/makersclaw/blocks/calendar-view/paint.js",
|
|
65
|
+
"content": "// How an item is coloured on the calendar — tokens only. A timed block\n// and an all-day bar wear a tint with a solid leading edge; a timed item\n// in a month cell is a dot. The colour comes from the task's project\n// (chart-1…5), or its status's tone with colorBy=\"status\"; one without\n// either wears the theme's primary.\n\nconst HUE_TINT = {\n 1: \"bg-chart-1/15 hover:bg-chart-1/25 border-s-chart-1\",\n 2: \"bg-chart-2/15 hover:bg-chart-2/25 border-s-chart-2\",\n 3: \"bg-chart-3/15 hover:bg-chart-3/25 border-s-chart-3\",\n 4: \"bg-chart-4/15 hover:bg-chart-4/25 border-s-chart-4\",\n 5: \"bg-chart-5/15 hover:bg-chart-5/25 border-s-chart-5\",\n};\n\nconst HUE_DOT = { 1: \"bg-chart-1\", 2: \"bg-chart-2\", 3: \"bg-chart-3\", 4: \"bg-chart-4\", 5: \"bg-chart-5\" };\n\nconst TONE_TINT = {\n neutral: \"bg-muted hover:bg-accent border-s-muted-foreground\",\n primary: \"bg-primary/10 hover:bg-primary/15 border-s-primary\",\n info: \"bg-info/15 hover:bg-info/25 border-s-info\",\n success: \"bg-success/15 hover:bg-success/25 border-s-success\",\n warning: \"bg-warning/15 hover:bg-warning/25 border-s-warning\",\n danger: \"bg-destructive/10 hover:bg-destructive/20 border-s-destructive\",\n};\n\nconst TONE_DOT = {\n neutral: \"bg-muted-foreground\",\n primary: \"bg-primary\",\n info: \"bg-info\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n danger: \"bg-destructive\",\n};\n\nconst DEFAULT = { tint: TONE_TINT.primary, dot: TONE_DOT.primary };\n\n/** { tint, dot } class strings for a task. */\nexport function paintOf(task, ctx, colorBy = \"project\") {\n if (colorBy === \"status\") {\n const s = ctx.statusById.get(String(task.status));\n if (s?.hue && HUE_TINT[s.hue]) return { tint: HUE_TINT[s.hue], dot: HUE_DOT[s.hue] };\n const tone = s?.tone ?? \"neutral\";\n return { tint: TONE_TINT[tone] ?? TONE_TINT.neutral, dot: TONE_DOT[tone] ?? TONE_DOT.neutral };\n }\n if (colorBy === \"project\") {\n const p = ctx.projectById.get(String(task.project));\n if (p?.hue && HUE_TINT[p.hue]) return { tint: HUE_TINT[p.hue], dot: HUE_DOT[p.hue] };\n }\n return DEFAULT;\n}\n\n/** A drag handle's props with our keys first: `onKey(e)` returns true\n * when it handled the key, and dnd-kit's own keyboard pickup never sees\n * it (Enter opens, Delete deletes, the arrows nudge a timed block). */\nexport function withKeys(handle, onKey) {\n return {\n ...handle,\n onKeyDown: (e) => {\n if (onKey?.(e) === true) return;\n handle?.onKeyDown?.(e);\n },\n };\n}\n\n/** Whether a key event came from a field the founder is typing in. */\nexport function isTyping(e) {\n const t = e.target;\n return Boolean(t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)));\n}\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "registry/blocks/calendar-view/README.md",
|
|
69
|
+
"type": "registry:file",
|
|
70
|
+
"target": "@components/makersclaw/blocks/calendar-view/README.md",
|
|
71
|
+
"content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { Sheet } from \"./ui\";\nimport { CalendarView } from \"./blocks/calendar-view\";\n\nexport default function Schedule({ tasks, people, projects, onChange }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode=\"week\" />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"categories": [
|
|
75
|
+
"calendar",
|
|
76
|
+
"schedule",
|
|
77
|
+
"month",
|
|
78
|
+
"week",
|
|
79
|
+
"day",
|
|
80
|
+
"agenda",
|
|
81
|
+
"time grid",
|
|
82
|
+
"events",
|
|
83
|
+
"tasks",
|
|
84
|
+
"deadlines",
|
|
85
|
+
"content calendar",
|
|
86
|
+
"drag",
|
|
87
|
+
"resize",
|
|
88
|
+
"unscheduled",
|
|
89
|
+
"availability",
|
|
90
|
+
"slots",
|
|
91
|
+
"booking",
|
|
92
|
+
"google calendar",
|
|
93
|
+
"cron",
|
|
94
|
+
"notion calendar",
|
|
95
|
+
"clickup",
|
|
96
|
+
"planner"
|
|
97
|
+
],
|
|
98
|
+
"docs": "Installed to components/makersclaw/blocks/calendar-view. Import: import { CalendarView, CALENDAR_MODES, MODE_LABELS, DAY_MINUTES, UNSCHEDULE, snapMinutes, durationOf, eventOf, eventsOf, unscheduledOf, layoutTimed, layoutLanes, moveEventPatch, resizeEventPatch, placeEventPatch, timedPatch, dayPatch, allDayPatch, extendPatch, rangeFor, stepAnchor, rangeLabel, titleFor, hourLabel, minutesLabel, eventTimeLabel, availabilityOn, closedOn, freeSlots, normalizeSlots } from \"@/components/makersclaw/blocks/calendar-view\". Props and an example: components/makersclaw/blocks/calendar-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
99
|
+
"meta": {
|
|
100
|
+
"makersclaw": {
|
|
101
|
+
"version": "0.3.0",
|
|
102
|
+
"useWhen": "A screen where dated work or events are seen by day and moved around: a team's deadlines, a content calendar, a launch plan, shifts, a personal schedule, meetings on tasks, or the slot picker of a booking page (modes week/day, slots from getSlots). Not for dependencies over a long timeline (gantt), for undated work (task-list, board), or for a single date field (the kit's DatePicker)."
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/r/cart.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "cart",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Cart",
|
|
6
|
+
"description": "A shopping cart: line items with picture, option, quantity and remove, a summary (subtotal, discount code, shipping, tax, total) and Checkout. step=\"checkout\" is the checkout page: contact and delivery details beside the order summary, ending at onPay, which hands off to Paddle. No card fields. Empty and loading states built in.",
|
|
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/cart/index.js",
|
|
14
|
+
"type": "registry:component",
|
|
15
|
+
"target": "@components/makersclaw/blocks/cart/index.js",
|
|
16
|
+
"content": "// The Cart block — `makersclaw add cart`. Fixed: compose it through its\n// props (README.md beside this file); a cart it cannot be is your own\n// component in components/.\nexport { Cart } from \"./cart\";\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/blocks/cart/cart.jsx",
|
|
20
|
+
"type": "registry:component",
|
|
21
|
+
"target": "@components/makersclaw/blocks/cart/cart.jsx",
|
|
22
|
+
"content": "\"use client\";\n\nimport { useEffect, useId, useState } from \"react\";\nimport {\n Button,\n Card,\n Checkbox,\n Empty,\n Field,\n Icon,\n IconButton,\n Image,\n Input,\n Notice,\n Select,\n Separator,\n Skeleton,\n cx,\n} from \"../../ui\";\nimport { asAmount, countOf, lineTotal, money, subtotalOf } from \"./money\";\n\n// The cart and its checkout step. Line items come in as props; every\n// change goes out through a callback that returns { ok } and the row says\n// when it failed. The checkout step collects contact and delivery details\n// only — never a card: payment is Paddle's, and the step ends at `onPay`.\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst ADDRESS_KEYS = [\"address\", \"city\", \"postal\", \"country\"];\n\nasync function attempt(fn) {\n try {\n const res = await fn();\n return res && res.ok === false ? res.error || \"That did not save.\" : null;\n } catch (e) {\n return e?.message || \"That did not save.\";\n }\n}\n\nfunction quantityOptions(item) {\n const max = Math.max(Number(item.maxQuantity) || 10, Number(item.quantity) || 1);\n return Array.from({ length: max }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }));\n}\n\n// Server and browser may differ in locale, time zone and even the day:\n// the first render uses fixed ones (en-US, UTC) so it matches the server's\n// HTML, then the reader's own take over.\nfunction useHydrated() {\n const [hydrated, setHydrated] = useState(false);\n useEffect(() => setHydrated(true), []);\n return hydrated;\n}\n\n/* ---------- one line item ---------- */\n\nfunction LineItem({ item, currency: currencyProp, locale, editable, onQuantityChange, onRemove }) {\n const currency = item.currency ?? currencyProp;\n const compareAt = Number(item.compareAt) > Number(item.price) ? Number(item.compareAt) : null;\n const [busy, setBusy] = useState(null);\n const [error, setError] = useState(null);\n const qtyId = useId();\n\n async function change(next) {\n if (!onQuantityChange) return;\n setBusy(\"qty\");\n setError(null);\n setError(await attempt(() => onQuantityChange(item, next)));\n setBusy(null);\n }\n async function remove() {\n setBusy(\"remove\");\n setError(null);\n const failed = await attempt(() => onRemove(item));\n if (failed) setError(failed);\n setBusy(null);\n }\n\n return (\n <li className=\"p-4\">\n <div className=\"flex gap-3 sm:gap-4\">\n <Image src={item.image} alt=\"\" ratio=\"square\" fit=\"cover\" className=\"size-20 shrink-0 sm:size-24\" />\n <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"min-w-0\">\n {item.href ? (\n <Button variant=\"link\" href={item.href} className=\"font-medium leading-snug\">\n {item.name}\n </Button>\n ) : (\n <p className=\"font-medium leading-snug\">{item.name}</p>\n )}\n {item.variant ? <p className=\"mt-0.5 text-sm text-muted-foreground\">{item.variant}</p> : null}\n </div>\n {editable && onRemove ? (\n <IconButton\n icon=\"x\"\n label={`Remove ${item.name}`}\n size=\"sm\"\n onClick={remove}\n disabled={Boolean(busy)}\n className=\"-mr-2 -mt-1\"\n />\n ) : null}\n </div>\n <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2\">\n {editable && onQuantityChange ? (\n <div className=\"flex items-center gap-2\">\n <label htmlFor={qtyId} className=\"text-sm text-muted-foreground\">\n Qty\n </label>\n <Select\n id={qtyId}\n value={String(item.quantity)}\n onChange={(e) => change(Number(e.target.value))}\n options={quantityOptions(item)}\n disabled={Boolean(busy)}\n className=\"w-20\"\n />\n </div>\n ) : (\n <p className=\"text-sm text-muted-foreground\">Qty {item.quantity}</p>\n )}\n <div className=\"text-right\">\n <p className=\"font-semibold tabular-nums\">\n {compareAt ? (\n <>\n <s className=\"mr-1.5 text-sm font-normal text-muted-foreground\">\n <span className=\"sr-only\">Was </span>\n {money(compareAt * (Number(item.quantity) || 0), currency, locale)}\n </s>\n <span className=\"sr-only\">now </span>\n </>\n ) : null}\n {money(lineTotal(item), currency, locale)}\n </p>\n {Number(item.quantity) > 1 ? (\n <p className=\"text-xs text-muted-foreground tabular-nums\">{money(item.price, currency, locale)} each</p>\n ) : null}\n </div>\n </div>\n {error ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n </div>\n </div>\n </li>\n );\n}\n\n/* ---------- the totals ---------- */\n\nfunction SummaryRow({ label, value, strong, tone }) {\n return (\n <div\n className={cx(\n \"flex items-baseline justify-between gap-3\",\n strong ? \"mt-1.5 border-t border-border pt-3 text-base\" : \"text-sm\",\n )}\n >\n <dt className={strong ? \"font-semibold\" : \"text-muted-foreground\"}>{label}</dt>\n <dd\n className={cx(\n \"text-right tabular-nums\",\n strong ? \"text-xl font-semibold\" : \"font-medium\",\n tone === \"success\" && \"text-success\",\n )}\n >\n {value}\n </dd>\n </div>\n );\n}\n\nfunction charge(v, currency, locale, freeLabel) {\n if (typeof v === \"string\") return { value: <span className=\"font-normal text-muted-foreground\">{v}</span> };\n if (v === 0) return { value: freeLabel, tone: \"success\" };\n return { value: money(v, currency, locale) };\n}\n\nfunction Totals({ items, currency, locale, shipping, tax, discount, onRemoveCode }) {\n const subtotal = subtotalOf(items);\n const off = discount ? Math.abs(asAmount(discount.amount)) : 0;\n const total = Math.max(0, subtotal - off + asAmount(shipping) + asAmount(tax));\n const ship = shipping !== undefined ? charge(shipping, currency, locale, \"Free\") : null;\n const taxRow = tax !== undefined ? charge(tax, currency, locale, money(0, currency, locale)) : null;\n return (\n <dl className=\"grid gap-2.5\">\n <SummaryRow label=\"Subtotal\" value={money(subtotal, currency, locale)} />\n {discount ? (\n <div className=\"flex items-center justify-between gap-3 text-sm\">\n <dt className=\"flex min-w-0 items-center gap-1 text-muted-foreground\">\n <Icon name=\"tag\" size={14} className=\"shrink-0\" />\n <span className=\"truncate\">{discount.label || `Code ${discount.code}`}</span>\n {onRemoveCode ? (\n <IconButton icon=\"x\" size=\"sm\" label={`Remove code ${discount.code ?? \"\"}`.trim()} onClick={onRemoveCode} className=\"-my-2\" />\n ) : null}\n </dt>\n <dd className=\"font-medium tabular-nums text-success\">−{money(off, currency, locale)}</dd>\n </div>\n ) : null}\n {ship ? <SummaryRow label=\"Shipping\" {...ship} /> : null}\n {taxRow ? <SummaryRow label=\"Tax\" {...taxRow} /> : null}\n <SummaryRow label=\"Total\" value={money(total, currency, locale)} strong />\n </dl>\n );\n}\n\nfunction PromoCode({ onApplyCode }) {\n const [code, setCode] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState(null);\n const id = useId();\n async function apply(e) {\n e.preventDefault();\n const value = code.trim();\n if (!value) return;\n setBusy(true);\n setError(null);\n const failed = await attempt(() => onApplyCode(value));\n setBusy(false);\n if (failed) setError(failed);\n else setCode(\"\");\n }\n return (\n <form onSubmit={apply} className=\"grid gap-1.5\">\n <label htmlFor={id} className=\"text-sm font-medium\">\n Discount code\n </label>\n <div className=\"flex gap-2\">\n <Input\n id={id}\n value={code}\n onChange={(e) => setCode(e.target.value)}\n placeholder=\"Enter code\"\n autoCapitalize=\"characters\"\n invalid={Boolean(error)}\n aria-describedby={error ? `${id}-error` : undefined}\n className=\"min-w-0 flex-1\"\n />\n <Button type=\"submit\" loading={busy} disabled={!code.trim()}>\n Apply\n </Button>\n </div>\n {error ? (\n <p id={`${id}-error`} role=\"alert\" className=\"text-xs text-destructive\">\n {error}\n </p>\n ) : null}\n </form>\n );\n}\n\n/* ---------- states ---------- */\n\nfunction Loading({ step }) {\n return (\n <div aria-busy=\"true\" aria-label=\"Loading the cart\" className=\"grid gap-6 lg:grid-cols-3\">\n <Card padding=\"none\" className=\"lg:col-span-2\">\n {[0, 1].map((i) => (\n <div key={i} className=\"flex gap-4 border-b border-border p-4 last:border-b-0\">\n <Skeleton shape=\"rect\" className=\"size-20 sm:size-24\" />\n <div className=\"grid flex-1 content-start gap-2\">\n <Skeleton className=\"w-1/2\" />\n <Skeleton className=\"w-1/4\" />\n </div>\n </div>\n ))}\n </Card>\n <Card title={step === \"checkout\" ? \"Order summary\" : \"Summary\"}>\n <div className=\"grid gap-3\">\n <Skeleton className=\"w-full\" />\n <Skeleton className=\"w-full\" />\n <Skeleton className=\"w-2/3\" />\n </div>\n </Card>\n </div>\n );\n}\n\n/* ---------- the checkout step ---------- */\n\nfunction CheckoutForm({ total, requireAddress, terms, defaultDetails, payLabel, payNote, onPay, onBack, backLabel }) {\n const base = useId();\n const [details, setDetails] = useState(() => ({\n email: \"\",\n name: \"\",\n address: \"\",\n city: \"\",\n postal: \"\",\n country: \"\",\n ...defaultDetails,\n }));\n const [agreed, setAgreed] = useState(false);\n const [errors, setErrors] = useState({});\n const [busy, setBusy] = useState(false);\n const [failure, setFailure] = useState(null);\n const set = (key) => (e) => setDetails((d) => ({ ...d, [key]: e.target.value }));\n const id = (k) => `${base}-${k}`;\n\n function validate() {\n const next = {};\n if (!EMAIL.test(details.email.trim())) next.email = \"Enter an email address like name@company.com.\";\n if (requireAddress) {\n if (!details.name.trim()) next.name = \"Enter the name to deliver to.\";\n for (const k of ADDRESS_KEYS) if (!String(details[k] ?? \"\").trim()) next[k] = \"Required.\";\n }\n if (terms && !agreed) next.terms = \"Tick this to continue.\";\n return next;\n }\n\n async function submit(e) {\n e.preventDefault();\n const found = validate();\n setErrors(found);\n setFailure(null);\n if (Object.keys(found).length) {\n const first = Object.keys(found)[0];\n document.getElementById(id(first))?.focus();\n return;\n }\n const out = { email: details.email.trim() };\n if (requireAddress) for (const k of [\"name\", ...ADDRESS_KEYS]) out[k] = String(details[k]).trim();\n setBusy(true);\n const failed = await attempt(() => onPay(out));\n setBusy(false);\n if (failed) setFailure(failed);\n }\n\n const err = (k) => errors[k];\n const inputProps = (k, extra) => ({\n id: id(k),\n value: details[k] ?? \"\",\n onChange: set(k),\n invalid: Boolean(err(k)),\n \"aria-describedby\": err(k) ? `${id(k)}-error` : undefined,\n disabled: busy,\n ...extra,\n });\n\n return (\n <form onSubmit={submit} noValidate className=\"grid gap-6\">\n <section className=\"grid gap-4\">\n <SectionHead icon=\"mail\" title=\"Contact\" description=\"Where the receipt and order updates go.\" />\n <Field id={id(\"email\")} label=\"Email\" error={err(\"email\")} required>\n <Input {...inputProps(\"email\", { type: \"email\", autoComplete: \"email\", placeholder: \"you@company.com\" })} />\n </Field>\n </section>\n\n {requireAddress ? (\n <>\n <Separator />\n <section className=\"grid gap-4\">\n <SectionHead icon=\"truck\" title=\"Delivery\" description=\"Where the order should arrive.\" />\n <Field id={id(\"name\")} label=\"Full name\" error={err(\"name\")} required>\n <Input {...inputProps(\"name\", { autoComplete: \"name\" })} />\n </Field>\n <Field id={id(\"address\")} label=\"Street address\" error={err(\"address\")} required>\n <Input {...inputProps(\"address\", { autoComplete: \"street-address\" })} />\n </Field>\n <div className=\"grid gap-4 sm:grid-cols-3\">\n <Field id={id(\"city\")} label=\"City\" error={err(\"city\")} required>\n <Input {...inputProps(\"city\", { autoComplete: \"address-level2\" })} />\n </Field>\n <Field id={id(\"postal\")} label=\"Postal code\" error={err(\"postal\")} required>\n <Input {...inputProps(\"postal\", { autoComplete: \"postal-code\" })} />\n </Field>\n <Field id={id(\"country\")} label=\"Country\" error={err(\"country\")} required>\n <Input {...inputProps(\"country\", { autoComplete: \"country-name\" })} />\n </Field>\n </div>\n </section>\n </>\n ) : null}\n\n <Separator />\n\n <section className=\"grid gap-3\">\n {terms ? (\n <div className=\"grid gap-1\">\n <Checkbox\n id={id(\"terms\")}\n checked={agreed}\n onChange={(e) => setAgreed(e.target.checked)}\n label={terms}\n disabled={busy}\n aria-invalid={err(\"terms\") ? true : undefined}\n aria-describedby={err(\"terms\") ? `${id(\"terms\")}-error` : undefined}\n />\n {err(\"terms\") ? (\n <p id={`${id(\"terms\")}-error`} role=\"alert\" className=\"text-xs text-destructive\">\n {err(\"terms\")}\n </p>\n ) : null}\n </div>\n ) : null}\n {failure ? <Notice tone=\"danger\" title=\"The order did not go through\">{failure}</Notice> : null}\n <Button type=\"submit\" variant=\"primary\" icon=\"lock\" loading={busy} className=\"w-full\">\n {payLabel ?? `Continue to payment · ${total}`}\n </Button>\n {payNote ? <p className=\"text-center text-xs text-muted-foreground\">{payNote}</p> : null}\n {onBack ? (\n <Button variant=\"quiet\" icon=\"arrow-left\" onClick={onBack} disabled={busy} className=\"justify-self-center\">\n {backLabel}\n </Button>\n ) : null}\n </section>\n </form>\n );\n}\n\nfunction SectionHead({ icon, title, description }) {\n return (\n <div className=\"flex items-center gap-3\">\n <span className=\"grid size-9 shrink-0 place-items-center rounded-full bg-primary/10 text-primary\">\n <Icon name={icon} size={16} />\n </span>\n <div className=\"min-w-0\">\n <h3 className=\"font-semibold leading-tight\">{title}</h3>\n {description ? <p className=\"text-sm text-muted-foreground\">{description}</p> : null}\n </div>\n </div>\n );\n}\n\n/* ---------- the block ---------- */\n\nexport function Cart({\n items,\n step = \"cart\",\n currency = \"USD\",\n locale: localeProp,\n shipping,\n tax,\n discount,\n note,\n loading = false,\n error,\n title,\n onQuantityChange,\n onRemove,\n onApplyCode,\n onRemoveCode,\n onCheckout,\n checkoutLabel = \"Checkout\",\n onContinueShopping,\n continueHref,\n continueLabel = \"Continue shopping\",\n emptyTitle = \"Your cart is empty\",\n emptyBody = \"Anything you add shows up here.\",\n requireAddress = true,\n terms,\n defaultDetails,\n onPay,\n payLabel,\n payNote = \"You pay on the next screen. We never see your card.\",\n onBack,\n backLabel = \"Back to cart\",\n className,\n}) {\n const hydrated = useHydrated();\n const locale = localeProp ?? (hydrated ? undefined : \"en-US\");\n const [checkingOut, setCheckingOut] = useState(false);\n const [checkoutError, setCheckoutError] = useState(null);\n const checkout = step === \"checkout\";\n const list = (items ?? []).filter(Boolean);\n const count = countOf(list);\n const heading = title ?? (checkout ? \"Checkout\" : \"Cart\");\n\n const subtotal = subtotalOf(list);\n const off = discount ? Math.abs(asAmount(discount.amount)) : 0;\n const total = money(Math.max(0, subtotal - off + asAmount(shipping) + asAmount(tax)), currency, locale);\n\n async function goToCheckout() {\n if (!onCheckout) return;\n setCheckingOut(true);\n setCheckoutError(null);\n const failed = await attempt(() => onCheckout());\n setCheckingOut(false);\n if (failed) setCheckoutError(failed);\n }\n\n const header = (\n <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n <h2 className=\"text-xl font-semibold\">{heading}</h2>\n {!loading && list.length ? (\n <p className=\"text-sm text-muted-foreground\">\n {count} {count === 1 ? \"item\" : \"items\"}\n </p>\n ) : null}\n </div>\n );\n\n let body;\n if (loading) body = <Loading step={step} />;\n else if (!list.length) {\n const action =\n continueHref || onContinueShopping ? (\n <Button variant=\"primary\" href={continueHref} onClick={onContinueShopping}>\n {continueLabel}\n </Button>\n ) : null;\n body = <Empty icon=\"shopping-cart\" title={emptyTitle} body={emptyBody} action={action} className=\"py-16\" />;\n } else if (checkout) {\n body = (\n <div className=\"grid items-start gap-6 lg:grid-cols-5 lg:gap-10\">\n <div className=\"order-2 lg:order-1 lg:col-span-3\">\n <CheckoutForm\n total={total}\n requireAddress={requireAddress}\n terms={terms}\n defaultDetails={defaultDetails}\n payLabel={payLabel}\n payNote={payNote}\n onPay={onPay ?? (async () => ({ ok: true }))}\n onBack={onBack}\n backLabel={backLabel}\n />\n </div>\n <Card title=\"Order summary\" padding=\"none\" className=\"order-1 lg:sticky lg:top-4 lg:order-2 lg:col-span-2\">\n <ul className=\"divide-y divide-border border-y border-border\">\n {list.map((item) => (\n <LineItem key={item.key ?? item.id} item={item} currency={currency} locale={locale} editable={false} />\n ))}\n </ul>\n <div className=\"p-4\">\n <Totals items={list} currency={currency} locale={locale} shipping={shipping} tax={tax} discount={discount} />\n {note ? <SummaryNote note={note} className=\"mt-4\" /> : null}\n </div>\n </Card>\n </div>\n );\n } else {\n body = (\n <div className=\"grid items-start gap-6 lg:grid-cols-3\">\n <Card padding=\"none\" className=\"lg:col-span-2\">\n <ul aria-label=\"Items in the cart\" className=\"divide-y divide-border\">\n {list.map((item) => (\n <LineItem\n key={item.key ?? item.id}\n item={item}\n currency={currency}\n locale={locale}\n editable\n onQuantityChange={onQuantityChange}\n onRemove={onRemove}\n />\n ))}\n </ul>\n </Card>\n <Card title=\"Summary\" className=\"lg:sticky lg:top-4\">\n <div className=\"grid gap-4\">\n <Totals\n items={list}\n currency={currency}\n locale={locale}\n shipping={shipping}\n tax={tax}\n discount={discount}\n onRemoveCode={onRemoveCode}\n />\n {onApplyCode && !discount ? <PromoCode onApplyCode={onApplyCode} /> : null}\n {checkoutError ? <Notice tone=\"danger\">{checkoutError}</Notice> : null}\n {onCheckout ? (\n <Button variant=\"primary\" icon=\"lock\" onClick={goToCheckout} loading={checkingOut} className=\"w-full\">\n {checkoutLabel}\n </Button>\n ) : null}\n {continueHref || onContinueShopping ? (\n <Button variant=\"quiet\" href={continueHref} onClick={onContinueShopping} className=\"w-full\">\n {continueLabel}\n </Button>\n ) : null}\n {note ? <SummaryNote note={note} /> : null}\n </div>\n </Card>\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-4\", className)}>\n {header}\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n {body}\n </div>\n );\n}\n\nfunction SummaryNote({ note, className }) {\n return (\n <div className={cx(\"flex items-start gap-2 rounded-md bg-muted p-3 text-sm\", className)}>\n <Icon name=\"truck\" size={16} className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n <div className=\"min-w-0 text-muted-foreground\">{note}</div>\n </div>\n );\n}\n"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/blocks/cart/money.js",
|
|
26
|
+
"type": "registry:component",
|
|
27
|
+
"target": "@components/makersclaw/blocks/cart/money.js",
|
|
28
|
+
"content": "// Money in the reader's words, and the sums a cart and its summary share.\n// Prices are numbers in the currency's major unit (19.5 is $19.50).\n\nconst formats = new Map();\n\nexport function money(amount, currency = \"USD\", locale) {\n const key = `${locale ?? \"\"}|${currency}`;\n let f = formats.get(key);\n if (!f) {\n try {\n f = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n } catch {\n f = new Intl.NumberFormat(locale, { style: \"currency\", currency: \"USD\" });\n }\n formats.set(key, f);\n }\n return f.format(Number(amount) || 0);\n}\n\nexport const lineTotal = (item) => (Number(item.price) || 0) * (Number(item.quantity) || 0);\n\nexport const subtotalOf = (items) => (items ?? []).reduce((sum, item) => sum + lineTotal(item), 0);\n\nexport const countOf = (items) => (items ?? []).reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);\n\n// A charge row's amount: a number is added to the total; a string\n// (\"Calculated at payment\") is shown as written and adds nothing.\nexport const asAmount = (v) => (typeof v === \"number\" && Number.isFinite(v) ? v : 0);\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/blocks/cart/README.md",
|
|
32
|
+
"type": "registry:file",
|
|
33
|
+
"target": "@components/makersclaw/blocks/cart/README.md",
|
|
34
|
+
"content": "# Cart\n\nThe basket and the checkout page. The cart lists line items (picture,\nname, option, quantity, remove) beside a summary: subtotal, a discount\ncode, shipping, tax, the total and Checkout. `step=\"checkout\"` is the next\npage: the buyer's email and delivery address beside the same summary,\nread-only. It ends at `onPay`. **There are no card fields**: the app hands\noff to Paddle there, which takes the payment.\n\nEvery change goes out through a callback that returns `{ ok }`; a failed\none is shown on the row (or the summary) that asked for it.\n\n```jsx\nimport { Cart } from \"../components/blocks/cart\"; // a Makersclaw app (\"./blocks/cart\" from components/)\nimport { Cart } from \"@/components/makersclaw/blocks/cart\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the cart in the server surface (README §4).\n\n## The data\n\nOne line item per row of the cart:\n\n```js\n{ id: \"li_1\", name: \"Merino crew\", image: \"https://…\", price: 89, quantity: 2, variant: \"Charcoal · M\" }\n```\n\n- `price` is one unit's price, a number in the currency's major unit\n (89.5 is $89.50). The row shows `price × quantity`.\n- `variant` is the chosen option in words, or empty.\n- `maxQuantity` (optional) caps the quantity picker; default 10.\n- Optional: `key` (a line's own key when the same product sits on two\n lines, one per size; default `id`), `href` (the name links to the\n product), `compareAt` (the old unit price, struck through beside the\n line total), `currency` (over the `currency` prop).\n\nThe same shape is what `product-detail` and `product-grid` hand to their\nadd-to-cart callbacks, so a cart is those rows.\n\n## Props\n\n- `items` — the line items. An empty list shows the empty state.\n- `step` — `\"cart\"` (default) or `\"checkout\"`.\n- `currency` — an ISO code, default `\"USD\"`. `locale` — for the money\n format, default the reader's.\n- `shipping` · `tax` — a number is a row that adds to the total (0 reads\n \"Free\" for shipping); a string is shown as written and adds nothing\n (`tax=\"Calculated at payment\"` — Paddle works tax out). Leave one out\n and its row is not drawn.\n- `discount` — the code in force: `{ code, amount, label? }`; `amount` is\n taken off the total.\n- `note` — a line at the foot of the summary, beside a truck (\"Free\n delivery on orders over $100\").\n- `loading` — skeleton rows. `error` — a line above everything (the cart\n did not load).\n- `title` — the heading; default \"Cart\" or \"Checkout\".\n\nThe cart step:\n\n- `onQuantityChange(item, quantity)` — save the new quantity; return\n `{ ok: true }` or `{ ok: false, error }`. Without it the quantity is\n shown, not picked.\n- `onRemove(item)` — remove the row, same return. Without it no ×.\n- `onApplyCode(code)` — a discount code field appears; return\n `{ ok: false, error: \"That code has expired.\" }` to say why not. On\n success pass the new `discount` back in.\n- `onRemoveCode()` — an × beside the discount.\n- `onCheckout()` — the Checkout button; move to the checkout step (route\n or state). May return `{ ok: false, error }`.\n- `checkoutLabel` — default \"Checkout\".\n- `onContinueShopping()` or `continueHref` — \"Continue shopping\", in the\n summary and in the empty state. `continueLabel` renames it.\n- `emptyTitle` · `emptyBody` — the empty state's words.\n\nThe checkout step:\n\n- `onPay(details)` — required for checkout. `details` is `{ email }`, plus\n `{ name, address, city, postal, country }` when the address is asked\n for. Save the order, open Paddle's checkout with it, and return\n `{ ok: true }`; `{ ok: false, error }` shows the reason above the button.\n- `requireAddress` — default `true`; `false` for things that are not\n shipped (downloads, tickets): only the email is asked for.\n- `terms` — a checkbox label that must be ticked before paying (\"I agree\n to the terms of sale\"); a string or a node with links. Omit for none.\n- `defaultDetails` — prefill: `{ email, name, address, city, postal, country }`.\n- `payLabel` — the button; default \"Continue to payment · $123.00\".\n- `payNote` — the line under it; default says the card is entered on the\n next screen. `null` for none.\n- `onBack()` — \"Back to cart\". `backLabel` renames it.\n\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// components/Basket.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, updateRow } from \"../lib/actions\";\nimport { Cart } from \"./blocks/cart\";\n\nexport default function Basket({ items, error }) {\n const [step, setStep] = useState(\"cart\");\n return (\n <Cart\n items={items}\n error={error}\n step={step}\n shipping={0}\n tax=\"Calculated at payment\"\n note=\"Free delivery on every order this month.\"\n onQuantityChange={(item, quantity) => updateRow({ source: \"public.cart_items\", id: item.id, patch: { quantity } })}\n onRemove={(item) => deleteRow({ source: \"public.cart_items\", id: item.id })}\n onCheckout={() => setStep(\"checkout\")}\n onBack={() => setStep(\"cart\")}\n continueHref=\"/shop\"\n terms=\"I agree to the terms of sale\"\n onPay={async (details) => {\n const res = await fetch(\"/api/checkout\", { method: \"POST\", body: JSON.stringify(details) });\n if (!res.ok) return { ok: false, error: \"We could not start the payment. Try again.\" };\n const { transactionId } = await res.json();\n window.Paddle?.Checkout.open({ transactionId });\n return { ok: true };\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- Take a card number, expiry or CVC. Payment is Paddle's overlay or page,\n opened from `onPay`; the block never sees a card.\n- Work out tax or shipping: pass the numbers (or the words) in.\n- Keep the cart: `items` is the app's; every change is a callback.\n- Validate an address against a country's format: it checks that each\n field is filled.\n\nA cart it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add cart` restores them.\n\n## Credits\n\nShape and behaviour from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT; this is a rewrite on the Makersclaw kit.\n"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"categories": [
|
|
38
|
+
"cart",
|
|
39
|
+
"basket",
|
|
40
|
+
"checkout",
|
|
41
|
+
"shop",
|
|
42
|
+
"store",
|
|
43
|
+
"ecommerce",
|
|
44
|
+
"commerce",
|
|
45
|
+
"order",
|
|
46
|
+
"summary",
|
|
47
|
+
"quantity",
|
|
48
|
+
"discount",
|
|
49
|
+
"coupon",
|
|
50
|
+
"promo",
|
|
51
|
+
"shipping",
|
|
52
|
+
"paddle",
|
|
53
|
+
"pay"
|
|
54
|
+
],
|
|
55
|
+
"docs": "Installed to components/makersclaw/blocks/cart. Import: import { Cart } from \"@/components/makersclaw/blocks/cart\". Props and an example: components/makersclaw/blocks/cart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
56
|
+
"meta": {
|
|
57
|
+
"makersclaw": {
|
|
58
|
+
"version": "0.3.0",
|
|
59
|
+
"useWhen": "An app that sells things: the basket before paying, and the page that collects the buyer's email and address before the payment provider takes over. Not for a subscription plan picker (a pricing table) or for an order already placed (order-detail)."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|