From 69c7953aa046cf47e6ac72bfca4cc29699746d4a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 12:12:09 -0400 Subject: [PATCH 01/12] chore: update @btst/stack version to 2.6.0 in demo projects --- demos/ai-chat/pnpm-lock.yaml | 10 +++++----- demos/blog/pnpm-lock.yaml | 10 +++++----- demos/cms/pnpm-lock.yaml | 10 +++++----- demos/form-builder/pnpm-lock.yaml | 10 +++++----- demos/kanban/pnpm-lock.yaml | 10 +++++----- demos/ui-builder/pnpm-lock.yaml | 10 +++++----- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/demos/ai-chat/pnpm-lock.yaml b/demos/ai-chat/pnpm-lock.yaml index eaa59516..fc732402 100644 --- a/demos/ai-chat/pnpm-lock.yaml +++ b/demos/ai-chat/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(37ea7fbbdb1664cc3e54767443b15e09) + specifier: ^2.6.0 + version: 2.6.0(37ea7fbbdb1664cc3e54767443b15e09) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -355,8 +355,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4188,7 +4188,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(37ea7fbbdb1664cc3e54767443b15e09)': + '@btst/stack@2.6.0(37ea7fbbdb1664cc3e54767443b15e09)': dependencies: '@ai-sdk/react': 2.0.152(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) diff --git a/demos/blog/pnpm-lock.yaml b/demos/blog/pnpm-lock.yaml index 8c549975..602b275a 100644 --- a/demos/blog/pnpm-lock.yaml +++ b/demos/blog/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd) + specifier: ^2.6.0 + version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -342,8 +342,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4169,7 +4169,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)': + '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)': dependencies: '@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) diff --git a/demos/cms/pnpm-lock.yaml b/demos/cms/pnpm-lock.yaml index c202e5e4..26d6e042 100644 --- a/demos/cms/pnpm-lock.yaml +++ b/demos/cms/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd) + specifier: ^2.6.0 + version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -393,8 +393,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4493,7 +4493,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)': + '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)': dependencies: '@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) diff --git a/demos/form-builder/pnpm-lock.yaml b/demos/form-builder/pnpm-lock.yaml index 1ead7954..5c006f7b 100644 --- a/demos/form-builder/pnpm-lock.yaml +++ b/demos/form-builder/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd) + specifier: ^2.6.0 + version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -351,8 +351,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4178,7 +4178,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)': + '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)': dependencies: '@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) diff --git a/demos/kanban/pnpm-lock.yaml b/demos/kanban/pnpm-lock.yaml index b3f51ac0..b8811872 100644 --- a/demos/kanban/pnpm-lock.yaml +++ b/demos/kanban/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd) + specifier: ^2.6.0 + version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -330,8 +330,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4179,7 +4179,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)': + '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)': dependencies: '@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) diff --git a/demos/ui-builder/pnpm-lock.yaml b/demos/ui-builder/pnpm-lock.yaml index 4f9f9437..4a6edfd3 100644 --- a/demos/ui-builder/pnpm-lock.yaml +++ b/demos/ui-builder/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) '@btst/stack': - specifier: ^2.5.6 - version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd) + specifier: ^2.6.0 + version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd) '@btst/yar': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14) @@ -390,8 +390,8 @@ packages: '@btst/db@2.0.3': resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==} - '@btst/stack@2.5.6': - resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==} + '@btst/stack@2.6.0': + resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==} peerDependencies: '@ai-sdk/react': '>=2.0.0' '@btst/yar': '>=1.2.0' @@ -4503,7 +4503,7 @@ snapshots: - svelte - vue - '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)': + '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)': dependencies: '@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6) '@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)) From ace832f5502586ce8ef47f40a1a6d6ddab442649 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 13:55:35 -0400 Subject: [PATCH 02/12] feat: export useful components --- packages/stack/build.config.ts | 18 ++- packages/stack/package.json | 133 ++++++++++++++++++ packages/stack/scripts/postbuild.cjs | 93 +++++++----- .../stack/src/components/auto-form/index.ts | 12 ++ packages/stack/src/components/empty/index.ts | 8 ++ .../src/components/form-builder/index.ts | 23 +++ packages/stack/src/components/kanban/index.ts | 9 ++ .../stack/src/components/markdown/index.ts | 5 + .../markdown/markdown-editor-styles.css | 30 ++++ .../stack/src/components/markdown/style.css | 3 + .../src/components/minimal-tiptap/index.ts | 5 + .../src/components/minimal-tiptap/style.css | 1 + .../src/components/multi-select/index.ts | 5 + .../src/components/search-select/index.ts | 1 + .../src/components/stepped-auto-form/index.ts | 5 + .../stack/src/components/ui-builder/index.ts | 50 +++++++ .../stack/src/components/ui-builder/style.css | 5 + .../forms/markdown-editor-with-overrides.tsx | 29 ++++ .../components/forms/markdown-editor.tsx | 45 +++--- .../client/components/forms/post-forms.tsx | 4 +- packages/ui/package.json | 1 + 21 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 packages/stack/src/components/auto-form/index.ts create mode 100644 packages/stack/src/components/empty/index.ts create mode 100644 packages/stack/src/components/form-builder/index.ts create mode 100644 packages/stack/src/components/kanban/index.ts create mode 100644 packages/stack/src/components/markdown/index.ts create mode 100644 packages/stack/src/components/markdown/markdown-editor-styles.css create mode 100644 packages/stack/src/components/markdown/style.css create mode 100644 packages/stack/src/components/minimal-tiptap/index.ts create mode 100644 packages/stack/src/components/minimal-tiptap/style.css create mode 100644 packages/stack/src/components/multi-select/index.ts create mode 100644 packages/stack/src/components/search-select/index.ts create mode 100644 packages/stack/src/components/stepped-auto-form/index.ts create mode 100644 packages/stack/src/components/ui-builder/index.ts create mode 100644 packages/stack/src/components/ui-builder/style.css create mode 100644 packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index aa69d4ca..44f96013 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,6 +104,17 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", + // standalone component stubs are created by postbuild.cjs (no dts bundler overhead) + // "./src/components/auto-form/index.ts", + // "./src/components/stepped-auto-form/index.ts", + // "./src/components/kanban/index.ts", + // "./src/components/multi-select/index.ts", + // "./src/components/search-select/index.ts", + // "./src/components/empty/index.ts", + // "./src/components/markdown/index.ts", + // "./src/components/form-builder/index.ts", + // "./src/components/minimal-tiptap/index.ts", + // "./src/components/ui-builder/index.ts", ], hooks: { "rollup:options"(_ctx, options) { @@ -155,8 +166,13 @@ export default defineBuildConfig({ // Add preserve directives plugin last (must be after transform plugins) // Note: suppressPreserveModulesWarning is set because preserveModules IS set, // but the plugin checks it before outputs are finalized, causing false warnings + // Exclude .d.ts files — they contain TypeScript-only syntax that Rollup's + // built-in parser (used by this plugin for AST inspection) cannot handle. existingPlugins.push( - preserveDirectives({ suppressPreserveModulesWarning: true }), + preserveDirectives({ + suppressPreserveModulesWarning: true, + exclude: ["**/*.d.ts", "**/*.d.cts", "**/*.d.mts"], + }), ); (options as any).plugins = existingPlugins; diff --git a/packages/stack/package.json b/packages/stack/package.json index a05befc3..1f2b0353 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -374,6 +374,109 @@ } }, "./plugins/route-docs/css": "./dist/plugins/route-docs/style.css", + "./components/markdown": { + "import": { + "types": "./dist/components/markdown/index.d.ts", + "default": "./dist/components/markdown/index.mjs" + }, + "require": { + "types": "./dist/components/markdown/index.d.cts", + "default": "./dist/components/markdown/index.cjs" + } + }, + "./components/markdown/css": "./dist/components/markdown/style.css", + "./components/auto-form": { + "import": { + "types": "./dist/components/auto-form/index.d.ts", + "default": "./dist/components/auto-form/index.mjs" + }, + "require": { + "types": "./dist/components/auto-form/index.d.cts", + "default": "./dist/components/auto-form/index.cjs" + } + }, + "./components/stepped-auto-form": { + "import": { + "types": "./dist/components/stepped-auto-form/index.d.ts", + "default": "./dist/components/stepped-auto-form/index.mjs" + }, + "require": { + "types": "./dist/components/stepped-auto-form/index.d.cts", + "default": "./dist/components/stepped-auto-form/index.cjs" + } + }, + "./components/form-builder": { + "import": { + "types": "./dist/components/form-builder/index.d.ts", + "default": "./dist/components/form-builder/index.mjs" + }, + "require": { + "types": "./dist/components/form-builder/index.d.cts", + "default": "./dist/components/form-builder/index.cjs" + } + }, + "./components/kanban": { + "import": { + "types": "./dist/components/kanban/index.d.ts", + "default": "./dist/components/kanban/index.mjs" + }, + "require": { + "types": "./dist/components/kanban/index.d.cts", + "default": "./dist/components/kanban/index.cjs" + } + }, + "./components/minimal-tiptap": { + "import": { + "types": "./dist/components/minimal-tiptap/index.d.ts", + "default": "./dist/components/minimal-tiptap/index.mjs" + }, + "require": { + "types": "./dist/components/minimal-tiptap/index.d.cts", + "default": "./dist/components/minimal-tiptap/index.cjs" + } + }, + "./components/minimal-tiptap/css": "./dist/components/minimal-tiptap/style.css", + "./components/multi-select": { + "import": { + "types": "./dist/components/multi-select/index.d.ts", + "default": "./dist/components/multi-select/index.mjs" + }, + "require": { + "types": "./dist/components/multi-select/index.d.cts", + "default": "./dist/components/multi-select/index.cjs" + } + }, + "./components/search-select": { + "import": { + "types": "./dist/components/search-select/index.d.ts", + "default": "./dist/components/search-select/index.mjs" + }, + "require": { + "types": "./dist/components/search-select/index.d.cts", + "default": "./dist/components/search-select/index.cjs" + } + }, + "./components/empty": { + "import": { + "types": "./dist/components/empty/index.d.ts", + "default": "./dist/components/empty/index.mjs" + }, + "require": { + "types": "./dist/components/empty/index.d.cts", + "default": "./dist/components/empty/index.cjs" + } + }, + "./components/ui-builder": { + "import": { + "types": "./dist/components/ui-builder/index.d.ts", + "default": "./dist/components/ui-builder/index.mjs" + }, + "require": { + "types": "./dist/components/ui-builder/index.d.cts", + "default": "./dist/components/ui-builder/index.cjs" + } + }, + "./components/ui-builder/css": "./dist/components/ui-builder/style.css", "./dist/*": "./dist/*", "./ui/css": "./dist/ui/components.css", "./package.json": "./package.json" @@ -475,6 +578,36 @@ ], "plugins/route-docs/client": [ "./dist/plugins/route-docs/client/index.d.ts" + ], + "components/markdown": [ + "./dist/components/markdown/index.d.ts" + ], + "components/auto-form": [ + "./dist/components/auto-form/index.d.ts" + ], + "components/stepped-auto-form": [ + "./dist/components/stepped-auto-form/index.d.ts" + ], + "components/form-builder": [ + "./dist/components/form-builder/index.d.ts" + ], + "components/kanban": [ + "./dist/components/kanban/index.d.ts" + ], + "components/minimal-tiptap": [ + "./dist/components/minimal-tiptap/index.d.ts" + ], + "components/multi-select": [ + "./dist/components/multi-select/index.d.ts" + ], + "components/search-select": [ + "./dist/components/search-select/index.d.ts" + ], + "components/empty": [ + "./dist/components/empty/index.d.ts" + ], + "components/ui-builder": [ + "./dist/components/ui-builder/index.d.ts" ] } }, diff --git a/packages/stack/scripts/postbuild.cjs b/packages/stack/scripts/postbuild.cjs index 740b0b65..31ac9f50 100644 --- a/packages/stack/scripts/postbuild.cjs +++ b/packages/stack/scripts/postbuild.cjs @@ -2,6 +2,7 @@ /* Post-build step for BTST package. - Copies all .css files from src/plugins/** to dist/plugins/** preserving structure + - Copies all .css files from src/components/** to dist/components/** preserving structure - Resolves @workspace/ui/... CSS imports by inlining the referenced content directly into each dist CSS file — producing fully self-contained files with no workspace references, so npm consumers and StackBlitz never see unresolvable imports @@ -16,6 +17,8 @@ const { spawnSync } = require("child_process"); const ROOT = path.resolve(__dirname, ".."); const SRC_PLUGINS_DIR = path.join(ROOT, "src", "plugins"); const DIST_PLUGINS_DIR = path.join(ROOT, "dist", "plugins"); +const SRC_COMPONENTS_DIR = path.join(ROOT, "src", "components"); +const DIST_COMPONENTS_DIR = path.join(ROOT, "dist", "components"); function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { @@ -57,6 +60,20 @@ function copyAllPluginCss() { } } +function copyAllComponentCss() { + console.log(`@btst/stack: running copyAllComponentCss`); + if (!fs.existsSync(SRC_COMPONENTS_DIR)) return; + for (const componentName of fs.readdirSync(SRC_COMPONENTS_DIR)) { + const srcComponentDir = path.join(SRC_COMPONENTS_DIR, componentName); + if (!fs.statSync(srcComponentDir).isDirectory()) continue; + for (const filePath of walk(srcComponentDir)) { + if (filePath.endsWith(".css")) { + copyFilePreserveDirs(filePath, SRC_COMPONENTS_DIR, DIST_COMPONENTS_DIR); + } + } + } +} + /** * Recursively inline relative @import statements in a CSS file, producing a * single blob of CSS with no relative imports left. Non-relative imports @@ -118,48 +135,52 @@ function resolveWorkspaceCssImports() { // only read and processed once even if referenced by multiple dist files. const cache = new Map(); - if (!fs.existsSync(DIST_PLUGINS_DIR)) return; - - for (const filePath of walk(DIST_PLUGINS_DIR)) { - if (!filePath.endsWith(".css")) continue; - - let content = fs.readFileSync(filePath, "utf8"); - if (!WORKSPACE_IMPORT_RE.test(content)) continue; - WORKSPACE_IMPORT_RE.lastIndex = 0; - - let modified = false; - - content = content.replace(WORKSPACE_IMPORT_RE, (match, specifier) => { - if (!cache.has(specifier)) { - const resolvedPath = resolveUiSpecifier(specifier); - if (!resolvedPath || !fs.existsSync(resolvedPath)) { - console.warn( - `@btst/stack: could not resolve workspace import: ${specifier}`, + function processDistDir(distDir) { + if (!fs.existsSync(distDir)) return; + for (const filePath of walk(distDir)) { + if (!filePath.endsWith(".css")) continue; + + let content = fs.readFileSync(filePath, "utf8"); + if (!WORKSPACE_IMPORT_RE.test(content)) continue; + WORKSPACE_IMPORT_RE.lastIndex = 0; + + let modified = false; + + content = content.replace(WORKSPACE_IMPORT_RE, (match, specifier) => { + if (!cache.has(specifier)) { + const resolvedPath = resolveUiSpecifier(specifier); + if (!resolvedPath || !fs.existsSync(resolvedPath)) { + console.warn( + `@btst/stack: could not resolve workspace import: ${specifier}`, + ); + cache.set(specifier, null); + return match; + } + const raw = fs.readFileSync(resolvedPath, "utf8"); + // Recursively inline any relative sub-imports within the resolved file + const inlined = inlineCssImports(raw, path.dirname(resolvedPath)); + cache.set(specifier, inlined); + console.log( + `@btst/stack: inlined workspace import "${specifier}" into ${path.relative(distDir, filePath)}`, ); - cache.set(specifier, null); - return match; } - const raw = fs.readFileSync(resolvedPath, "utf8"); - // Recursively inline any relative sub-imports within the resolved file - const inlined = inlineCssImports(raw, path.dirname(resolvedPath)); - cache.set(specifier, inlined); + const inlined = cache.get(specifier); + if (inlined === null) return match; // could not resolve — keep original + modified = true; + return inlined; + }); + + if (modified) { + fs.writeFileSync(filePath, content); console.log( - `@btst/stack: inlined workspace import "${specifier}" into ${path.relative(DIST_PLUGINS_DIR, filePath)}`, + `@btst/stack: rewrote workspace imports in ${path.relative(distDir, filePath)}`, ); } - const inlined = cache.get(specifier); - if (inlined === null) return match; // could not resolve — keep original - modified = true; - return inlined; - }); - - if (modified) { - fs.writeFileSync(filePath, content); - console.log( - `@btst/stack: rewrote workspace imports in ${path.relative(DIST_PLUGINS_DIR, filePath)}`, - ); } } + + processDistDir(DIST_PLUGINS_DIR); + processDistDir(DIST_COMPONENTS_DIR); } function runPerPluginPostbuilds() { @@ -200,7 +221,9 @@ function runPerPluginPostbuilds() { function main() { ensureDir(DIST_PLUGINS_DIR); + ensureDir(DIST_COMPONENTS_DIR); copyAllPluginCss(); + copyAllComponentCss(); resolveWorkspaceCssImports(); runPerPluginPostbuilds(); } diff --git a/packages/stack/src/components/auto-form/index.ts b/packages/stack/src/components/auto-form/index.ts new file mode 100644 index 00000000..4a6f9ac3 --- /dev/null +++ b/packages/stack/src/components/auto-form/index.ts @@ -0,0 +1,12 @@ +export { + default as AutoForm, + AutoFormSubmit, +} from "@workspace/ui/components/auto-form"; +export type { + FieldConfig, + FieldConfigItem, + Dependency, + DependencyType, + AutoFormInputComponentProps, +} from "@workspace/ui/components/auto-form/types"; +export type { ZodObjectOrWrapped } from "@workspace/ui/components/auto-form/helpers"; diff --git a/packages/stack/src/components/empty/index.ts b/packages/stack/src/components/empty/index.ts new file mode 100644 index 00000000..14b7a68d --- /dev/null +++ b/packages/stack/src/components/empty/index.ts @@ -0,0 +1,8 @@ +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} from "@workspace/ui/components/empty"; diff --git a/packages/stack/src/components/form-builder/index.ts b/packages/stack/src/components/form-builder/index.ts new file mode 100644 index 00000000..2a0295d1 --- /dev/null +++ b/packages/stack/src/components/form-builder/index.ts @@ -0,0 +1,23 @@ +export { + FormBuilder, + defaultComponents, + objectFieldDefinition, + arrayFieldDefinition, + defineComponent, + baseMetaSchema, + baseMetaSchemaWithPlaceholder, +} from "@workspace/ui/components/form-builder"; +export type { + FormBuilderComponentDefinition, + FormBuilderField, + FormBuilderFieldProps, + JSONSchema, + JSONSchemaProperty, + StringFieldProps, +} from "@workspace/ui/components/form-builder"; +export type { + FormStep, + BackingType, + ComponentType, + TypedFieldProps, +} from "@workspace/ui/components/form-builder/types"; diff --git a/packages/stack/src/components/kanban/index.ts b/packages/stack/src/components/kanban/index.ts new file mode 100644 index 00000000..8e33c633 --- /dev/null +++ b/packages/stack/src/components/kanban/index.ts @@ -0,0 +1,9 @@ +export { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnHandle, + KanbanItem, + KanbanItemHandle, + KanbanOverlay, +} from "@workspace/ui/components/kanban"; diff --git a/packages/stack/src/components/markdown/index.ts b/packages/stack/src/components/markdown/index.ts new file mode 100644 index 00000000..b16a6c2c --- /dev/null +++ b/packages/stack/src/components/markdown/index.ts @@ -0,0 +1,5 @@ +export { MarkdownContent } from "@workspace/ui/components/markdown-content"; +export type { MarkdownContentProps } from "@workspace/ui/components/markdown-content"; + +export { MarkdownEditor } from "../../plugins/blog/client/components/forms/markdown-editor"; +export type { MarkdownEditorProps } from "../../plugins/blog/client/components/forms/markdown-editor"; diff --git a/packages/stack/src/components/markdown/markdown-editor-styles.css b/packages/stack/src/components/markdown/markdown-editor-styles.css new file mode 100644 index 00000000..75755c7b --- /dev/null +++ b/packages/stack/src/components/markdown/markdown-editor-styles.css @@ -0,0 +1,30 @@ +.milkdown-custom .milkdown { + border-radius: calc(var(--radius) - 2px); + --crepe-color-background: var(--background); + --crepe-color-surface: var(--background); + --crepe-color-surface-low: var(--muted); + --crepe-color-on-background: var(--foreground); + --crepe-color-on-surface: var(--secondary-foreground); + --crepe-color-on-surface-variant: var(--muted-foreground); + --crepe-color-primary: var(--primary); + --crepe-color-secondary: var(--secondary); + --crepe-color-on-secondary: var(--secondary-foreground); + --crepe-color-outline: var(--muted-foreground); + --crepe-color-inverse: var(--popover); + --crepe-color-on-inverse: var(--popover-foreground); + --crepe-color-inline-code: var(--secondary-foreground); + --crepe-color-error: var(--destructive); + --crepe-color-hover: var(--accent); + --crepe-color-selected: var(--secondary); + --crepe-color-inline-area: var(--secondary); + --crepe-font-title: var(--font-sans); + --crepe-font-default: var(--font-sans); + --crepe-font-code: var(--font-mono); + --crepe-shadow-1: + 0px 1px 3px 1px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px + 2px 0px color-mix(in oklch, var(--ring) 22%, transparent); + --crepe-shadow-2: + 0px 2px 6px 2px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px + 2px 0px color-mix(in oklch, var(--ring) 22%, transparent); + height: inherit; +} diff --git a/packages/stack/src/components/markdown/style.css b/packages/stack/src/components/markdown/style.css new file mode 100644 index 00000000..18f940c4 --- /dev/null +++ b/packages/stack/src/components/markdown/style.css @@ -0,0 +1,3 @@ +@import "@workspace/ui/markdown-content.css"; +@import "@milkdown/crepe/theme/common/style.css"; +@import "./markdown-editor-styles.css"; diff --git a/packages/stack/src/components/minimal-tiptap/index.ts b/packages/stack/src/components/minimal-tiptap/index.ts new file mode 100644 index 00000000..50de3ea2 --- /dev/null +++ b/packages/stack/src/components/minimal-tiptap/index.ts @@ -0,0 +1,5 @@ +export { + MinimalTiptapEditor, + MainMinimalTiptapEditor, +} from "@workspace/ui/components/minimal-tiptap"; +export type { MinimalTiptapProps } from "@workspace/ui/components/minimal-tiptap"; diff --git a/packages/stack/src/components/minimal-tiptap/style.css b/packages/stack/src/components/minimal-tiptap/style.css new file mode 100644 index 00000000..468432c3 --- /dev/null +++ b/packages/stack/src/components/minimal-tiptap/style.css @@ -0,0 +1 @@ +@import "@workspace/ui/components/minimal-tiptap/styles.css"; diff --git a/packages/stack/src/components/multi-select/index.ts b/packages/stack/src/components/multi-select/index.ts new file mode 100644 index 00000000..2a609f54 --- /dev/null +++ b/packages/stack/src/components/multi-select/index.ts @@ -0,0 +1,5 @@ +export { default as MultipleSelector } from "@workspace/ui/components/multi-select"; +export type { + Option, + MultipleSelectorRef, +} from "@workspace/ui/components/multi-select"; diff --git a/packages/stack/src/components/search-select/index.ts b/packages/stack/src/components/search-select/index.ts new file mode 100644 index 00000000..e7e0207d --- /dev/null +++ b/packages/stack/src/components/search-select/index.ts @@ -0,0 +1 @@ +export { default as SearchSelect } from "@workspace/ui/components/search-select"; diff --git a/packages/stack/src/components/stepped-auto-form/index.ts b/packages/stack/src/components/stepped-auto-form/index.ts new file mode 100644 index 00000000..ec59780e --- /dev/null +++ b/packages/stack/src/components/stepped-auto-form/index.ts @@ -0,0 +1,5 @@ +export { SteppedAutoForm } from "@workspace/ui/components/auto-form/stepped-auto-form"; +export type { + SteppedAutoFormProps, + StepperComponentProps, +} from "@workspace/ui/components/auto-form/stepped-auto-form"; diff --git a/packages/stack/src/components/ui-builder/index.ts b/packages/stack/src/components/ui-builder/index.ts new file mode 100644 index 00000000..0799ed7c --- /dev/null +++ b/packages/stack/src/components/ui-builder/index.ts @@ -0,0 +1,50 @@ +// Editor component +export { + default as UIBuilder, + PageConfigPanel, + defaultConfigTabsContent, + LoadingSkeleton, + getDefaultPanelConfigValues, +} from "@workspace/ui/components/ui-builder"; +export type { TabsContentConfig } from "@workspace/ui/components/ui-builder"; + +// Page renderers +export { default as LayerRenderer } from "@workspace/ui/components/ui-builder/layer-renderer"; +export { ServerLayerRenderer } from "@workspace/ui/components/ui-builder/server-layer-renderer"; +export type { ServerLayerRendererProps } from "@workspace/ui/components/ui-builder/server-layer-renderer"; + +// Types +export type { + ComponentLayer, + ComponentRegistry, + Variable, + VariableReference, + FunctionRegistry, + BlockRegistry, + LayerChangeHandler, + VariableChangeHandler, + RegistryEntry, + PropValue, + BlockDefinition, + FunctionDefinition, +} from "@workspace/ui/components/ui-builder/types"; +export { + isVariableReference, + createVariable, +} from "@workspace/ui/components/ui-builder/types"; + +// Registry utilities and field override helpers +export { + createComponentRegistry, + defaultComponentRegistry, + primitiveComponentDefinitions, + complexComponentDefinitions, +} from "../../plugins/ui-builder/client/registry"; +export { + classNameFieldOverrides, + childrenFieldOverrides, + iconNameFieldOverrides, + commonFieldOverrides, + childrenAsTipTapFieldOverrides, + childrenAsTextareaFieldOverrides, +} from "@workspace/ui/lib/ui-builder/registry/form-field-overrides"; diff --git a/packages/stack/src/components/ui-builder/style.css b/packages/stack/src/components/ui-builder/style.css new file mode 100644 index 00000000..152630da --- /dev/null +++ b/packages/stack/src/components/ui-builder/style.css @@ -0,0 +1,5 @@ +/* Import minimal-tiptap styles for rich text editor in form field overrides */ +@import "@workspace/ui/components/minimal-tiptap/styles.css"; + +/* Load Tailwind Typography plugin for prose classes used by Markdown component */ +@plugin "@tailwindcss/typography"; diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx new file mode 100644 index 00000000..385ac9e1 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx @@ -0,0 +1,29 @@ +"use client"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { BlogPluginOverrides } from "../../overrides"; +import { BLOG_LOCALIZATION } from "../../localization"; +import { MarkdownEditor, type MarkdownEditorProps } from "./markdown-editor"; + +type MarkdownEditorWithOverridesProps = Omit< + MarkdownEditorProps, + "uploadImage" | "placeholder" +>; + +export function MarkdownEditorWithOverrides( + props: MarkdownEditorWithOverridesProps, +) { + const { uploadImage, localization } = usePluginOverrides< + BlogPluginOverrides, + Partial + >("blog", { + localization: BLOG_LOCALIZATION, + }); + + return ( + + ); +} diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx index c5625ae2..1cebce5e 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx @@ -9,25 +9,24 @@ import { listener, listenerCtx } from "@milkdown/kit/plugin/listener"; import { Slice } from "@milkdown/kit/prose/model"; import { Selection } from "@milkdown/kit/prose/state"; import { useLayoutEffect, useRef, useState } from "react"; -import { usePluginOverrides } from "@btst/stack/context"; -import type { BlogPluginOverrides } from "../../overrides"; -import { BLOG_LOCALIZATION } from "../../localization"; + +export interface MarkdownEditorProps { + value?: string; + onChange?: (markdown: string) => void; + className?: string; + /** Optional image upload handler. When provided, enables image upload in the editor. */ + uploadImage?: (file: File) => Promise; + /** Placeholder text shown when the editor is empty. */ + placeholder?: string; +} export function MarkdownEditor({ value, onChange, className, -}: { - value?: string; - onChange?: (markdown: string) => void; - className?: string; -}) { - const { uploadImage, localization } = usePluginOverrides< - BlogPluginOverrides, - Partial - >("blog", { - localization: BLOG_LOCALIZATION, - }); + uploadImage, + placeholder = "Write something...", +}: MarkdownEditorProps) { const containerRef = useRef(null); const crepeRef = useRef(null); const isReadyRef = useRef(false); @@ -52,14 +51,18 @@ export function MarkdownEditor({ defaultValue: initialValueRef.current, featureConfigs: { [CrepeFeature.Placeholder]: { - text: localization.BLOG_FORMS_EDITOR_PLACEHOLDER, - }, - [CrepeFeature.ImageBlock]: { - onUpload: async (file) => { - const url = await uploadImage(file); - return url; - }, + text: placeholder, }, + ...(uploadImage + ? { + [CrepeFeature.ImageBlock]: { + onUpload: async (file: File) => { + const url = await uploadImage(file); + return url; + }, + }, + } + : {}), }, }); diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx index 9fa60717..a98ad202 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx @@ -52,8 +52,8 @@ import { z } from "zod"; import { FeaturedImageField } from "./image-field"; const MarkdownEditor = lazy(() => - import("./markdown-editor").then((module) => ({ - default: module.MarkdownEditor, + import("./markdown-editor-with-overrides").then((module) => ({ + default: module.MarkdownEditorWithOverrides, })), ); import { BLOG_LOCALIZATION } from "../../localization"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 3027dc46..e62f4df6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -135,6 +135,7 @@ "./components/ui-builder/internal/form-fields/iconname-field": "./src/components/ui-builder/internal/form-fields/iconname-field.tsx", "./components/shared-form-types": "./src/components/shared-form-types.ts", "./components/auto-form": "./src/components/auto-form/index.tsx", + "./components/auto-form/stepped-auto-form": "./src/components/auto-form/stepped-auto-form.tsx", "./components/auto-form/types": "./src/components/auto-form/types.ts", "./components/auto-form/helpers": "./src/components/auto-form/helpers.tsx", "./components/form-builder": "./src/components/form-builder/index.tsx", From fc95f39f7ea9f77d11dc4cbb4afedd25d29c5547 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 14:07:58 -0400 Subject: [PATCH 03/12] feat: add standalone components documentation --- docs/content/docs/meta.json | 1 + docs/content/docs/plugins/ui-builder.mdx | 21 +- docs/content/docs/standalone-components.mdx | 383 ++++++++++++++++++++ 3 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 docs/content/docs/standalone-components.mdx diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index caa39895..dddd0005 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -26,6 +26,7 @@ "---[BookOpenCheck]Concepts---", "cli", "api-reference", + "standalone-components", "shadcn-registry", "---[Play]Demos---", "demos/index", diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index 4343eba0..3c3396d2 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -373,7 +373,7 @@ Blocks are pre-built component compositions that users can insert as templates. Create a block registry with reusable templates: ```tsx -import type { BlockRegistry, ComponentLayer } from "@workspace/ui/components/ui-builder/types" +import type { BlockRegistry, ComponentLayer } from "@btst/stack/components/ui-builder" const myBlocks: BlockRegistry = { "hero-01": { @@ -461,8 +461,7 @@ const myBlocks: BlockRegistry = { Pass the block registry to the UIBuilder component: ```tsx -import UIBuilder from "@workspace/ui/components/ui-builder" -import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" +import { UIBuilder, defaultComponentRegistry } from "@btst/stack/components/ui-builder" function PageEditor() { return ( @@ -539,8 +538,7 @@ The `LayerRenderer` component renders pre-fetched ComponentLayer data. Use this ```tsx title="app/pages/[slug]/page.tsx" "use client" -import { LayerRenderer } from "@workspace/ui/components/ui-builder" -import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" +import { LayerRenderer, defaultComponentRegistry } from "@btst/stack/components/ui-builder" export default function DynamicPage({ page, variables }: { page: ComponentLayer, @@ -577,8 +575,7 @@ The `ServerLayerRenderer` is an SSR-friendly renderer that works in React Server ```tsx title="app/pages/[slug]/page.tsx" // No "use client" needed - this is a Server Component -import { ServerLayerRenderer } from "@workspace/ui/components/ui-builder" -import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" +import { ServerLayerRenderer, defaultComponentRegistry } from "@btst/stack/components/ui-builder" export default async function StaticPage({ params }: { params: { slug: string } }) { // Fetch page data on the server @@ -908,10 +905,10 @@ const registry = createComponentRegistry({ #### UIBuilder -The main visual editor component. Import from `@workspace/ui/components/ui-builder`: +The main visual editor component. Import from `@btst/stack/components/ui-builder`: ```tsx -import UIBuilder from "@workspace/ui/components/ui-builder" +import { UIBuilder } from "@btst/stack/components/ui-builder" ``` -### Types (`@workspace/ui/components/ui-builder/types`) +### Types (`@btst/stack/components/ui-builder`) #### BlockDefinition diff --git a/docs/content/docs/standalone-components.mdx b/docs/content/docs/standalone-components.mdx new file mode 100644 index 00000000..f6bf4561 --- /dev/null +++ b/docs/content/docs/standalone-components.mdx @@ -0,0 +1,383 @@ +--- +title: Standalone Components +description: Use BTST's UI components independently of any plugin — auto-form, markdown editor, kanban, form builder, UI builder, and more. +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; + +BTST ships a set of UI components that can be used **without setting up any plugin**. Each is available as its own import path from `@btst/stack`, so you only bundle what you use. + +| Import path | CSS export | What you get | +|---|---|---| +| `@btst/stack/components/auto-form` | — | `AutoForm`, `AutoFormSubmit` | +| `@btst/stack/components/stepped-auto-form` | — | `SteppedAutoForm` | +| `@btst/stack/components/form-builder` | — | `FormBuilder` and schema helpers | +| `@btst/stack/components/markdown` | `@btst/stack/components/markdown/css` | `MarkdownContent`, `MarkdownEditor` | +| `@btst/stack/components/minimal-tiptap` | `@btst/stack/components/minimal-tiptap/css` | `MinimalTiptap` | +| `@btst/stack/components/kanban` | — | `Kanban`, `KanbanBoard`, `KanbanColumn`, … | +| `@btst/stack/components/multi-select` | — | `MultipleSelector` | +| `@btst/stack/components/search-select` | — | `SearchSelect` | +| `@btst/stack/components/empty` | — | `Empty`, `EmptyHeader`, `EmptyTitle`, … | +| `@btst/stack/components/ui-builder` | `@btst/stack/components/ui-builder/css` | `UIBuilder`, `LayerRenderer`, `ServerLayerRenderer`, types | + +--- + +## AutoForm + +Generate forms automatically from Zod schemas. [Learn more →](https://github.com/better-stack-ai/form-builder) + +```ts +import { AutoForm, AutoFormSubmit } from "@btst/stack/components/auto-form" +import type { FieldConfig, Dependency } from "@btst/stack/components/auto-form" +``` + +```tsx + console.log(values)} +> + Submit + +``` + +### Stepped AutoForm + +Multi-step wizard variant. [Learn more →](https://github.com/better-stack-ai/form-builder) + +```ts +import { SteppedAutoForm } from "@btst/stack/components/stepped-auto-form" +import type { SteppedAutoFormProps, StepperComponentProps } from "@btst/stack/components/stepped-auto-form" +``` + +--- + +## FormBuilder + +A JSON-schema-driven form editor — lets users visually create form definitions that can be stored and later rendered. [Learn more →](https://github.com/better-stack-ai/form-builder) + +```ts +import { + FormBuilder, + defaultComponents, + objectFieldDefinition, + arrayFieldDefinition, + defineComponent, + baseMetaSchema, + baseMetaSchemaWithPlaceholder, +} from "@btst/stack/components/form-builder" +import type { + FormBuilderComponentDefinition, + FormBuilderField, + JSONSchema, + JSONSchemaProperty, +} from "@btst/stack/components/form-builder" +``` + +```tsx + setSchema(schema)} + components={defaultComponents} +/> +``` + + +The full **Form Builder plugin** (`@btst/stack/plugins/form-builder`) layers data persistence, API routes, and submissions on top of this component. Use the standalone import when you want to manage your own storage. + + +--- + +## Markdown + +### MarkdownContent + +Renders Markdown/MDX to HTML with syntax highlighting: + +```ts +import { MarkdownContent } from "@btst/stack/components/markdown" +import type { MarkdownContentProps } from "@btst/stack/components/markdown" +``` + +```tsx + +``` + +Import the stylesheet in your root CSS (or layout): + +```css +@import "@btst/stack/components/markdown/css"; +``` + +### MarkdownEditor + +A rich Markdown editor powered by Milkdown/Crepe with optional image upload: + +```ts +import { MarkdownEditor } from "@btst/stack/components/markdown" +import type { MarkdownEditorProps } from "@btst/stack/components/markdown" +``` + +```tsx + setContent(markdown)} + placeholder="Write something..." + uploadImage={async (file) => { + const url = await uploadToStorage(file) + return url + }} +/> +``` + +#### MarkdownEditor Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `value` | `string` | `""` | Current Markdown content | +| `onChange` | `(markdown: string) => void` | — | Called on every change | +| `className` | `string` | — | Additional CSS classes | +| `placeholder` | `string` | `"Write something..."` | Placeholder when empty | +| `uploadImage` | `(file: File) => Promise` | — | Enables image upload; must return the public URL | + +The stylesheet also covers the Markdown editor. Import once from `@btst/stack/components/markdown/css`. + +--- + +## MinimalTiptap + +A lightweight rich-text editor built on Tiptap. [Learn more →](https://github.com/Aslam97/minimal-tiptap) + +```ts +import { MinimalTiptap } from "@btst/stack/components/minimal-tiptap" +import type { MinimalTiptapProps } from "@btst/stack/components/minimal-tiptap" +``` + +```tsx + setContent(value)} + className="min-h-[200px]" +/> +``` + +Add the stylesheet: + +```css +@import "@btst/stack/components/minimal-tiptap/css"; +``` + +--- + +## Kanban + +A full drag-and-drop kanban board built with `@dnd-kit`. [Learn more →](https://www.diceui.com/docs/components/radix/kanban) + +```ts +import { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnHandle, + KanbanItem, + KanbanItemHandle, + KanbanOverlay, +} from "@btst/stack/components/kanban" +``` + +```tsx + + + {columns.map((col) => ( + + {col.name} + {col.items.map((item) => ( + + {item.title} + + ))} + + ))} + + + +``` + + +The full **Kanban plugin** (`@btst/stack/plugins/kanban`) adds a backend API and persistence on top of this component. + + +--- + +## MultipleSelector + +A multi-select combobox with search, async options, and tag-style values: + +```ts +import { MultipleSelector } from "@btst/stack/components/multi-select" +import type { Option, MultipleSelectorRef } from "@btst/stack/components/multi-select" +``` + +```tsx +const options: Option[] = [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + { value: "svelte", label: "Svelte" }, +] + + +``` + +--- + +## SearchSelect + +A searchable single-value select powered by Radix + cmdk: + +```ts +import { SearchSelect } from "@btst/stack/components/search-select" +``` + +```tsx + +``` + +--- + +## Empty + +Composable empty-state UI components: + +```ts +import { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} from "@btst/stack/components/empty" +``` + +```tsx + + + + + + No results + Try adjusting your search or filters. + + + + + +``` + +--- + +## UIBuilder + +The visual page-builder editor and its rendering primitives are all available without the plugin. [Learn more →](https://github.com/olliethedev/ui-builder) + +```ts +import { + UIBuilder, + LayerRenderer, + ServerLayerRenderer, + PageConfigPanel, + defaultConfigTabsContent, + LoadingSkeleton, + getDefaultPanelConfigValues, + // Registry utilities + createComponentRegistry, + defaultComponentRegistry, + primitiveComponentDefinitions, + complexComponentDefinitions, + // Field override helpers + classNameFieldOverrides, + childrenFieldOverrides, + commonFieldOverrides, + // Type guards + isVariableReference, + createVariable, +} from "@btst/stack/components/ui-builder" + +import type { + ComponentLayer, + ComponentRegistry, + Variable, + FunctionRegistry, + BlockRegistry, + PropValue, +} from "@btst/stack/components/ui-builder" +``` + +Add the stylesheet (includes Tiptap styles used by the rich-text field): + +```css +@import "@btst/stack/components/ui-builder/css"; +``` + +### UIBuilder (editor) + +```tsx + saveLayers(layers)} + onVariablesChange={(vars) => saveVariables(vars)} + navRightChildren={} +/> +``` + +See the [UI Builder plugin docs](/plugins/ui-builder#api-reference) for the full props reference. + +### LayerRenderer (client component) + +Renders a saved `ComponentLayer` tree on the client: + +```tsx +"use client" + + +``` + +### ServerLayerRenderer (server component) + +RSC/SSG-safe renderer — no `"use client"` needed: + +```tsx + +``` + + +The full **UI Builder plugin** (`@btst/stack/plugins/ui-builder`) layers CMS-backed persistence, admin routes, and SSR hooks on top of these primitives. Use the standalone imports when you manage your own storage or need the renderers outside of the plugin context. + From cf99ee378f5e3879b6ac91c0de3faf8b386f8ac3 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 14:18:21 -0400 Subject: [PATCH 04/12] fix: enhance error handling in PageErrorState to support unknown error types --- demos/ui-builder/app/view/[slug]/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/demos/ui-builder/app/view/[slug]/page.tsx b/demos/ui-builder/app/view/[slug]/page.tsx index 01796e50..0135a281 100644 --- a/demos/ui-builder/app/view/[slug]/page.tsx +++ b/demos/ui-builder/app/view/[slug]/page.tsx @@ -87,14 +87,15 @@ function PageLoadingState() { ); } -function PageErrorState({ error }: { error: Error }) { +function PageErrorState({ error }: { error: unknown }) { + const message = error instanceof Error ? error.message : undefined; return (

Failed to load page

- {error?.message || "An unexpected error occurred."} + {message || "An unexpected error occurred."}

From 034495fe6c70c9159b8ce05f38fb3bce47ddff4c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 14:19:16 -0400 Subject: [PATCH 05/12] chore: update build configuration to include standalone component barrel entries for compilation --- packages/stack/build.config.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 44f96013..4cd1cf18 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,17 +104,17 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", - // standalone component stubs are created by postbuild.cjs (no dts bundler overhead) - // "./src/components/auto-form/index.ts", - // "./src/components/stepped-auto-form/index.ts", - // "./src/components/kanban/index.ts", - // "./src/components/multi-select/index.ts", - // "./src/components/search-select/index.ts", - // "./src/components/empty/index.ts", - // "./src/components/markdown/index.ts", - // "./src/components/form-builder/index.ts", - // "./src/components/minimal-tiptap/index.ts", - // "./src/components/ui-builder/index.ts", + // standalone component barrel entries — compiled by unbuild like every other entry + "./src/components/auto-form/index.ts", + "./src/components/stepped-auto-form/index.ts", + "./src/components/kanban/index.ts", + "./src/components/multi-select/index.ts", + "./src/components/search-select/index.ts", + "./src/components/empty/index.ts", + "./src/components/markdown/index.ts", + "./src/components/form-builder/index.ts", + "./src/components/minimal-tiptap/index.ts", + "./src/components/ui-builder/index.ts", ], hooks: { "rollup:options"(_ctx, options) { From ad59d8cf57dc5e47c68a01d7c5aa1a4c947e89fc Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 14:49:10 -0400 Subject: [PATCH 06/12] chore: remove minimal-tiptap component from documentation and package exports --- .github/workflows/ci.yml | 4 +++- docs/content/docs/standalone-components.mdx | 26 --------------------- packages/stack/build.config.ts | 5 ++-- packages/stack/package.json | 18 ++------------ 4 files changed, 8 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784b0871..4a5a3f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install dependencies @@ -40,6 +40,8 @@ jobs: - name: Build run: pnpm -w build + env: + NODE_OPTIONS: --max-old-space-size=8192 - name: Knip run: pnpm --filter @btst/stack run knip --reporter github-actions diff --git a/docs/content/docs/standalone-components.mdx b/docs/content/docs/standalone-components.mdx index f6bf4561..d8e9fac2 100644 --- a/docs/content/docs/standalone-components.mdx +++ b/docs/content/docs/standalone-components.mdx @@ -14,7 +14,6 @@ BTST ships a set of UI components that can be used **without setting up any plug | `@btst/stack/components/stepped-auto-form` | — | `SteppedAutoForm` | | `@btst/stack/components/form-builder` | — | `FormBuilder` and schema helpers | | `@btst/stack/components/markdown` | `@btst/stack/components/markdown/css` | `MarkdownContent`, `MarkdownEditor` | -| `@btst/stack/components/minimal-tiptap` | `@btst/stack/components/minimal-tiptap/css` | `MinimalTiptap` | | `@btst/stack/components/kanban` | — | `Kanban`, `KanbanBoard`, `KanbanColumn`, … | | `@btst/stack/components/multi-select` | — | `MultipleSelector` | | `@btst/stack/components/search-select` | — | `SearchSelect` | @@ -147,31 +146,6 @@ The stylesheet also covers the Markdown editor. Import once from `@btst/stack/co --- -## MinimalTiptap - -A lightweight rich-text editor built on Tiptap. [Learn more →](https://github.com/Aslam97/minimal-tiptap) - -```ts -import { MinimalTiptap } from "@btst/stack/components/minimal-tiptap" -import type { MinimalTiptapProps } from "@btst/stack/components/minimal-tiptap" -``` - -```tsx - setContent(value)} - className="min-h-[200px]" -/> -``` - -Add the stylesheet: - -```css -@import "@btst/stack/components/minimal-tiptap/css"; -``` - ---- - ## Kanban A full drag-and-drop kanban board built with `@dnd-kit`. [Learn more →](https://www.diceui.com/docs/components/radix/kanban) diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 4cd1cf18..24bb5549 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,7 +104,8 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", - // standalone component barrel entries — compiled by unbuild like every other entry + // standalone component barrel entries (re-export from @workspace/ui, bundled at build time) + // NOTE: these require ~8 GB heap; the build script sets NODE_OPTIONS accordingly. "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", "./src/components/kanban/index.ts", @@ -113,7 +114,7 @@ export default defineBuildConfig({ "./src/components/empty/index.ts", "./src/components/markdown/index.ts", "./src/components/form-builder/index.ts", - "./src/components/minimal-tiptap/index.ts", + // minimal-tiptap omitted — pulls in @tiptap/* + react-syntax-highlighter and OOMs the build "./src/components/ui-builder/index.ts", ], hooks: { diff --git a/packages/stack/package.json b/packages/stack/package.json index 1f2b0353..3a35079b 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -21,8 +21,8 @@ "access": "public" }, "scripts": { - "build": "unbuild --clean && node ./scripts/postbuild.cjs", - "build:analyze": "ANALYZE=1 unbuild --clean && node ./scripts/postbuild.cjs", + "build": "NODE_OPTIONS='--max-old-space-size=8192' unbuild --clean && node ./scripts/postbuild.cjs", + "build:analyze": "NODE_OPTIONS='--max-old-space-size=8192' ANALYZE=1 unbuild --clean && node ./scripts/postbuild.cjs", "stub": "unbuild --stub", "test": "vitest", "typecheck": "tsc --project tsconfig.json", @@ -425,17 +425,6 @@ "default": "./dist/components/kanban/index.cjs" } }, - "./components/minimal-tiptap": { - "import": { - "types": "./dist/components/minimal-tiptap/index.d.ts", - "default": "./dist/components/minimal-tiptap/index.mjs" - }, - "require": { - "types": "./dist/components/minimal-tiptap/index.d.cts", - "default": "./dist/components/minimal-tiptap/index.cjs" - } - }, - "./components/minimal-tiptap/css": "./dist/components/minimal-tiptap/style.css", "./components/multi-select": { "import": { "types": "./dist/components/multi-select/index.d.ts", @@ -594,9 +583,6 @@ "components/kanban": [ "./dist/components/kanban/index.d.ts" ], - "components/minimal-tiptap": [ - "./dist/components/minimal-tiptap/index.d.ts" - ], "components/multi-select": [ "./dist/components/multi-select/index.d.ts" ], From cfd1570162de7983c3175ca62ebaefd9eb692e97 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 15:02:17 -0400 Subject: [PATCH 07/12] chore: remove Kanban and UIBuilder components from documentation and package exports --- docs/content/docs/standalone-components.mdx | 136 +------------------- packages/stack/build.config.ts | 9 +- packages/stack/package.json | 27 ---- 3 files changed, 5 insertions(+), 167 deletions(-) diff --git a/docs/content/docs/standalone-components.mdx b/docs/content/docs/standalone-components.mdx index d8e9fac2..9534b841 100644 --- a/docs/content/docs/standalone-components.mdx +++ b/docs/content/docs/standalone-components.mdx @@ -14,11 +14,9 @@ BTST ships a set of UI components that can be used **without setting up any plug | `@btst/stack/components/stepped-auto-form` | — | `SteppedAutoForm` | | `@btst/stack/components/form-builder` | — | `FormBuilder` and schema helpers | | `@btst/stack/components/markdown` | `@btst/stack/components/markdown/css` | `MarkdownContent`, `MarkdownEditor` | -| `@btst/stack/components/kanban` | — | `Kanban`, `KanbanBoard`, `KanbanColumn`, … | | `@btst/stack/components/multi-select` | — | `MultipleSelector` | | `@btst/stack/components/search-select` | — | `SearchSelect` | | `@btst/stack/components/empty` | — | `Empty`, `EmptyHeader`, `EmptyTitle`, … | -| `@btst/stack/components/ui-builder` | `@btst/stack/components/ui-builder/css` | `UIBuilder`, `LayerRenderer`, `ServerLayerRenderer`, types | --- @@ -146,46 +144,6 @@ The stylesheet also covers the Markdown editor. Import once from `@btst/stack/co --- -## Kanban - -A full drag-and-drop kanban board built with `@dnd-kit`. [Learn more →](https://www.diceui.com/docs/components/radix/kanban) - -```ts -import { - Kanban, - KanbanBoard, - KanbanColumn, - KanbanColumnHandle, - KanbanItem, - KanbanItemHandle, - KanbanOverlay, -} from "@btst/stack/components/kanban" -``` - -```tsx - - - {columns.map((col) => ( - - {col.name} - {col.items.map((item) => ( - - {item.title} - - ))} - - ))} - - - -``` - - -The full **Kanban plugin** (`@btst/stack/plugins/kanban`) adds a backend API and persistence on top of this component. - - ---- - ## MultipleSelector A multi-select combobox with search, async options, and tag-style values: @@ -262,96 +220,4 @@ import { -``` - ---- - -## UIBuilder - -The visual page-builder editor and its rendering primitives are all available without the plugin. [Learn more →](https://github.com/olliethedev/ui-builder) - -```ts -import { - UIBuilder, - LayerRenderer, - ServerLayerRenderer, - PageConfigPanel, - defaultConfigTabsContent, - LoadingSkeleton, - getDefaultPanelConfigValues, - // Registry utilities - createComponentRegistry, - defaultComponentRegistry, - primitiveComponentDefinitions, - complexComponentDefinitions, - // Field override helpers - classNameFieldOverrides, - childrenFieldOverrides, - commonFieldOverrides, - // Type guards - isVariableReference, - createVariable, -} from "@btst/stack/components/ui-builder" - -import type { - ComponentLayer, - ComponentRegistry, - Variable, - FunctionRegistry, - BlockRegistry, - PropValue, -} from "@btst/stack/components/ui-builder" -``` - -Add the stylesheet (includes Tiptap styles used by the rich-text field): - -```css -@import "@btst/stack/components/ui-builder/css"; -``` - -### UIBuilder (editor) - -```tsx - saveLayers(layers)} - onVariablesChange={(vars) => saveVariables(vars)} - navRightChildren={} -/> -``` - -See the [UI Builder plugin docs](/plugins/ui-builder#api-reference) for the full props reference. - -### LayerRenderer (client component) - -Renders a saved `ComponentLayer` tree on the client: - -```tsx -"use client" - - -``` - -### ServerLayerRenderer (server component) - -RSC/SSG-safe renderer — no `"use client"` needed: - -```tsx - -``` - - -The full **UI Builder plugin** (`@btst/stack/plugins/ui-builder`) layers CMS-backed persistence, admin routes, and SSR hooks on top of these primitives. Use the standalone imports when you manage your own storage or need the renderers outside of the plugin context. - +``` \ No newline at end of file diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 24bb5549..693ba112 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -104,18 +104,17 @@ export default defineBuildConfig({ "./src/plugins/kanban/client/components/index.tsx", "./src/plugins/kanban/client/hooks/index.tsx", "./src/plugins/kanban/query-keys.ts", - // standalone component barrel entries (re-export from @workspace/ui, bundled at build time) - // NOTE: these require ~8 GB heap; the build script sets NODE_OPTIONS accordingly. "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", - "./src/components/kanban/index.ts", "./src/components/multi-select/index.ts", "./src/components/search-select/index.ts", "./src/components/empty/index.ts", "./src/components/markdown/index.ts", "./src/components/form-builder/index.ts", - // minimal-tiptap omitted — pulls in @tiptap/* + react-syntax-highlighter and OOMs the build - "./src/components/ui-builder/index.ts", + // standalone component these require >8 GB heap to build if included; + // "./src/components/kanban/index.ts", + // "./src/components/ui-builder/index.ts", + // "./src/components/minimal-tiptap/index.ts", ], hooks: { "rollup:options"(_ctx, options) { diff --git a/packages/stack/package.json b/packages/stack/package.json index 3a35079b..223f7aff 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -415,16 +415,6 @@ "default": "./dist/components/form-builder/index.cjs" } }, - "./components/kanban": { - "import": { - "types": "./dist/components/kanban/index.d.ts", - "default": "./dist/components/kanban/index.mjs" - }, - "require": { - "types": "./dist/components/kanban/index.d.cts", - "default": "./dist/components/kanban/index.cjs" - } - }, "./components/multi-select": { "import": { "types": "./dist/components/multi-select/index.d.ts", @@ -455,17 +445,6 @@ "default": "./dist/components/empty/index.cjs" } }, - "./components/ui-builder": { - "import": { - "types": "./dist/components/ui-builder/index.d.ts", - "default": "./dist/components/ui-builder/index.mjs" - }, - "require": { - "types": "./dist/components/ui-builder/index.d.cts", - "default": "./dist/components/ui-builder/index.cjs" - } - }, - "./components/ui-builder/css": "./dist/components/ui-builder/style.css", "./dist/*": "./dist/*", "./ui/css": "./dist/ui/components.css", "./package.json": "./package.json" @@ -580,9 +559,6 @@ "components/form-builder": [ "./dist/components/form-builder/index.d.ts" ], - "components/kanban": [ - "./dist/components/kanban/index.d.ts" - ], "components/multi-select": [ "./dist/components/multi-select/index.d.ts" ], @@ -591,9 +567,6 @@ ], "components/empty": [ "./dist/components/empty/index.d.ts" - ], - "components/ui-builder": [ - "./dist/components/ui-builder/index.d.ts" ] } }, From ebd58dd721b3b12e689e7194c7a52f21b34ad81e Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 15:03:35 -0400 Subject: [PATCH 08/12] chore: update GitHub Actions workflow to include all demo checks and trigger on changes to examples.yml --- .github/workflows/examples.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index a33deda4..51d894be 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -8,6 +8,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'demos/**' + - '.github/workflows/examples.yml' concurrency: group: demos-${{ github.ref }} @@ -45,3 +46,16 @@ jobs: run: pnpm build env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + all-demos: + name: Demo Projects + runs-on: ubuntu-latest + needs: build + if: always() + steps: + - name: Check all demo builds passed + run: | + if [[ "${{ needs.build.result }}" != "success" ]]; then + echo "One or more demo builds failed." + exit 1 + fi From 9d9d69ea216a50e56d6bfcd6dc88a4144fa50f56 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 15:07:33 -0400 Subject: [PATCH 09/12] chore: refactor GitHub Actions workflow to build and install all demo projects individually --- .github/workflows/examples.yml | 66 ++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 51d894be..a2e7ab4e 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -16,12 +16,8 @@ concurrency: jobs: build: - name: Build — ${{ matrix.demo }} + name: Demo Projects runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - demo: [ai-chat, blog, cms, form-builder, kanban, ui-builder] steps: - name: Checkout @@ -35,27 +31,53 @@ jobs: with: node-version: 22 cache: 'pnpm' - cache-dependency-path: demos/${{ matrix.demo }}/pnpm-lock.yaml - - name: Install dependencies - working-directory: demos/${{ matrix.demo }} + - name: Install — ai-chat + working-directory: demos/ai-chat run: pnpm install - - name: Build - working-directory: demos/${{ matrix.demo }} + - name: Build — ai-chat + working-directory: demos/ai-chat run: pnpm build env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - all-demos: - name: Demo Projects - runs-on: ubuntu-latest - needs: build - if: always() - steps: - - name: Check all demo builds passed - run: | - if [[ "${{ needs.build.result }}" != "success" ]]; then - echo "One or more demo builds failed." - exit 1 - fi + - name: Install — blog + working-directory: demos/blog + run: pnpm install + + - name: Build — blog + working-directory: demos/blog + run: pnpm build + + - name: Install — cms + working-directory: demos/cms + run: pnpm install + + - name: Build — cms + working-directory: demos/cms + run: pnpm build + + - name: Install — form-builder + working-directory: demos/form-builder + run: pnpm install + + - name: Build — form-builder + working-directory: demos/form-builder + run: pnpm build + + - name: Install — kanban + working-directory: demos/kanban + run: pnpm install + + - name: Build — kanban + working-directory: demos/kanban + run: pnpm build + + - name: Install — ui-builder + working-directory: demos/ui-builder + run: pnpm install + + - name: Build — ui-builder + working-directory: demos/ui-builder + run: pnpm build From 4a67825e1b2636ee02bda52943776745c40a48ad Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 15:09:26 -0400 Subject: [PATCH 10/12] chore: remove deprecated markdown-editor-styles.css and update import path in style.css --- .../markdown/markdown-editor-styles.css | 30 ------------------- .../stack/src/components/markdown/style.css | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 packages/stack/src/components/markdown/markdown-editor-styles.css diff --git a/packages/stack/src/components/markdown/markdown-editor-styles.css b/packages/stack/src/components/markdown/markdown-editor-styles.css deleted file mode 100644 index 75755c7b..00000000 --- a/packages/stack/src/components/markdown/markdown-editor-styles.css +++ /dev/null @@ -1,30 +0,0 @@ -.milkdown-custom .milkdown { - border-radius: calc(var(--radius) - 2px); - --crepe-color-background: var(--background); - --crepe-color-surface: var(--background); - --crepe-color-surface-low: var(--muted); - --crepe-color-on-background: var(--foreground); - --crepe-color-on-surface: var(--secondary-foreground); - --crepe-color-on-surface-variant: var(--muted-foreground); - --crepe-color-primary: var(--primary); - --crepe-color-secondary: var(--secondary); - --crepe-color-on-secondary: var(--secondary-foreground); - --crepe-color-outline: var(--muted-foreground); - --crepe-color-inverse: var(--popover); - --crepe-color-on-inverse: var(--popover-foreground); - --crepe-color-inline-code: var(--secondary-foreground); - --crepe-color-error: var(--destructive); - --crepe-color-hover: var(--accent); - --crepe-color-selected: var(--secondary); - --crepe-color-inline-area: var(--secondary); - --crepe-font-title: var(--font-sans); - --crepe-font-default: var(--font-sans); - --crepe-font-code: var(--font-mono); - --crepe-shadow-1: - 0px 1px 3px 1px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px - 2px 0px color-mix(in oklch, var(--ring) 22%, transparent); - --crepe-shadow-2: - 0px 2px 6px 2px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px - 2px 0px color-mix(in oklch, var(--ring) 22%, transparent); - height: inherit; -} diff --git a/packages/stack/src/components/markdown/style.css b/packages/stack/src/components/markdown/style.css index 18f940c4..0ff238e8 100644 --- a/packages/stack/src/components/markdown/style.css +++ b/packages/stack/src/components/markdown/style.css @@ -1,3 +1,3 @@ @import "@workspace/ui/markdown-content.css"; @import "@milkdown/crepe/theme/common/style.css"; -@import "./markdown-editor-styles.css"; +@import "../../plugins/blog/client/components/forms/markdown-editor-styles.css"; From 892d2edc49e7742395f1cd9077d04ab89e0232f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 19:22:44 +0000 Subject: [PATCH 11/12] chore: update shadcn registry [skip ci] --- packages/stack/registry/btst-blog.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 93c232e2..e8ebe3d5 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -68,16 +68,22 @@ "content": ".milkdown-custom .milkdown {\n\tborder-radius: calc(var(--radius) - 2px);\n\t--crepe-color-background: var(--background);\n\t--crepe-color-surface: var(--background);\n\t--crepe-color-surface-low: var(--muted);\n\t--crepe-color-on-background: var(--foreground);\n\t--crepe-color-on-surface: var(--secondary-foreground);\n\t--crepe-color-on-surface-variant: var(--muted-foreground);\n\t--crepe-color-primary: var(--primary);\n\t--crepe-color-secondary: var(--secondary);\n\t--crepe-color-on-secondary: var(--secondary-foreground);\n\t--crepe-color-outline: var(--muted-foreground);\n\t--crepe-color-inverse: var(--popover);\n\t--crepe-color-on-inverse: var(--popover-foreground);\n\t--crepe-color-inline-code: var(--secondary-foreground);\n\t--crepe-color-error: var(--destructive);\n\t--crepe-color-hover: var(--accent);\n\t--crepe-color-selected: var(--secondary);\n\t--crepe-color-inline-area: var(--secondary);\n\t--crepe-font-title: var(--font-sans);\n\t--crepe-font-default: var(--font-sans);\n\t--crepe-font-code: var(--font-mono);\n\t--crepe-shadow-1:\n\t\t0px 1px 3px 1px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px\n\t\t2px 0px color-mix(in oklch, var(--ring) 22%, transparent);\n\t--crepe-shadow-2:\n\t\t0px 2px 6px 2px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px\n\t\t2px 0px color-mix(in oklch, var(--ring) 22%, transparent);\n\theight: inherit;\n}\n", "target": "src/components/btst/blog/client/components/forms/markdown-editor-styles.css" }, + { + "path": "btst/blog/client/components/forms/markdown-editor-with-overrides.tsx", + "type": "registry:component", + "content": "\"use client\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t\"uploadImage\" | \"placeholder\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\treturn (\n\t\t\n\t);\n}\n", + "target": "src/components/btst/blog/client/components/forms/markdown-editor-with-overrides.tsx" + }, { "path": "btst/blog/client/components/forms/markdown-editor.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n}: {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n}) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: localization.BLOG_FORMS_EDITOR_PLACEHOLDER,\n\t\t\t\t},\n\t\t\t\t[CrepeFeature.ImageBlock]: {\n\t\t\t\t\tonUpload: async (file) => {\n\t\t\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\t\t\treturn url;\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t});\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\treturn (\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nexport interface MarkdownEditorProps {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n\t/** Optional image upload handler. When provided, enables image upload in the editor. */\n\tuploadImage?: (file: File) => Promise;\n\t/** Placeholder text shown when the editor is empty. */\n\tplaceholder?: string;\n}\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n\tuploadImage,\n\tplaceholder = \"Write something...\",\n}: MarkdownEditorProps) {\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: placeholder,\n\t\t\t\t},\n\t\t\t\t...(uploadImage\n\t\t\t\t\t? {\n\t\t\t\t\t\t\t[CrepeFeature.ImageBlock]: {\n\t\t\t\t\t\t\t\tonUpload: async (file: File) => {\n\t\t\t\t\t\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\t\t\t\t\t\treturn url;\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t});\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\treturn (\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/markdown-editor.tsx" }, { "path": "btst/blog/client/components/forms/post-forms.tsx", "type": "registry:component", - "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseCreatePost,\n\tuseSuspensePost,\n\tuseUpdatePost,\n\tuseDeletePost,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useMemo, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor\").then((module) => ({\n\t\tdefault: module.MarkdownEditor,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\tconst {\n\t\tmutateAsync: createPost,\n\t\tisPending: isCreatingPost,\n\t\terror: createPostError,\n\t} = useCreatePost();\n\n\ttype AddPostFormValues = z.input;\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\t// Auto-generate slug from title if not provided\n\t\tconst slug = data.slug || slugify(data.title);\n\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst createdPost = await createPost({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\tslug,\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({ published: createdPost?.published ?? false });\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\n\tconst initialData = useMemo(() => {\n\t\tif (!post) return {};\n\t\treturn {\n\t\t\ttitle: post.title,\n\t\t\tcontent: post.content,\n\t\t\texcerpt: post.excerpt,\n\t\t\tslug: post.slug,\n\t\t\tpublished: post.published,\n\t\t\timage: post.image || \"\",\n\t\t\ttags: post.tags.map((tag) => ({\n\t\t\t\tid: tag.id,\n\t\t\t\tname: tag.name,\n\t\t\t\tslug: tag.slug,\n\t\t\t})),\n\t\t};\n\t}, [post]);\n\n\tconst schema = CustomPostUpdateSchema;\n\n\tconst {\n\t\tmutateAsync: updatePost,\n\t\tisPending: isUpdatingPost,\n\t\terror: updatePostError,\n\t} = useUpdatePost();\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\ttype EditPostFormValues = z.input;\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst updatedPost = await updatePost({\n\t\t\tid: post!.id,\n\t\t\tdata: {\n\t\t\t\tid: post!.id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !post?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: post?.publishedAt\n\t\t\t\t\t\t\t? new Date(post.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({\n\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\tpublished: updatedPost?.published ?? false,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\tawait deletePost({ id: post.id });\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: initialData as z.input,\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", + "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseCreatePost,\n\tuseSuspensePost,\n\tuseUpdatePost,\n\tuseDeletePost,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useMemo, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\tconst {\n\t\tmutateAsync: createPost,\n\t\tisPending: isCreatingPost,\n\t\terror: createPostError,\n\t} = useCreatePost();\n\n\ttype AddPostFormValues = z.input;\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\t// Auto-generate slug from title if not provided\n\t\tconst slug = data.slug || slugify(data.title);\n\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst createdPost = await createPost({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\tslug,\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({ published: createdPost?.published ?? false });\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\n\tconst initialData = useMemo(() => {\n\t\tif (!post) return {};\n\t\treturn {\n\t\t\ttitle: post.title,\n\t\t\tcontent: post.content,\n\t\t\texcerpt: post.excerpt,\n\t\t\tslug: post.slug,\n\t\t\tpublished: post.published,\n\t\t\timage: post.image || \"\",\n\t\t\ttags: post.tags.map((tag) => ({\n\t\t\t\tid: tag.id,\n\t\t\t\tname: tag.name,\n\t\t\t\tslug: tag.slug,\n\t\t\t})),\n\t\t};\n\t}, [post]);\n\n\tconst schema = CustomPostUpdateSchema;\n\n\tconst {\n\t\tmutateAsync: updatePost,\n\t\tisPending: isUpdatingPost,\n\t\terror: updatePostError,\n\t} = useUpdatePost();\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\ttype EditPostFormValues = z.input;\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst updatedPost = await updatePost({\n\t\t\tid: post!.id,\n\t\t\tdata: {\n\t\t\t\tid: post!.id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !post?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: post?.publishedAt\n\t\t\t\t\t\t\t? new Date(post.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({\n\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\tpublished: updatedPost?.published ?? false,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\tawait deletePost({ id: post.id });\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: initialData as z.input,\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", "target": "src/components/btst/blog/client/components/forms/post-forms.tsx" }, { From ce25983ab8704145632122239d935614f86453ff Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 9 Mar 2026 15:25:34 -0400 Subject: [PATCH 12/12] chore: bump version to 2.6.1 in package.json --- packages/stack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack/package.json b/packages/stack/package.json index 223f7aff..59c4cddd 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.6.0", + "version": "2.6.1", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git",