@btst/stack 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.cjs +6 -2
  5. package/dist/client/index.d.cts +2 -1
  6. package/dist/client/index.d.mts +2 -1
  7. package/dist/client/index.d.ts +2 -1
  8. package/dist/client/index.mjs +6 -2
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  13. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  14. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
  15. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
  16. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  17. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  18. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  19. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  20. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  21. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  22. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  23. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  24. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  25. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  26. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
  27. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
  28. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
  29. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
  30. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
  31. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
  32. package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
  33. package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
  34. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
  35. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
  36. package/dist/packages/ui/src/components/dialog.cjs +6 -0
  37. package/dist/packages/ui/src/components/dialog.mjs +6 -1
  38. package/dist/packages/ui/src/components/sheet.cjs +25 -0
  39. package/dist/packages/ui/src/components/sheet.mjs +24 -1
  40. package/dist/plugins/api/index.d.cts +2 -2
  41. package/dist/plugins/api/index.d.mts +2 -2
  42. package/dist/plugins/api/index.d.ts +2 -2
  43. package/dist/plugins/blog/api/index.d.cts +1 -1
  44. package/dist/plugins/blog/api/index.d.mts +1 -1
  45. package/dist/plugins/blog/api/index.d.ts +1 -1
  46. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  47. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  48. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  49. package/dist/plugins/blog/client/index.d.cts +1 -1
  50. package/dist/plugins/blog/client/index.d.mts +1 -1
  51. package/dist/plugins/blog/client/index.d.ts +1 -1
  52. package/dist/plugins/blog/query-keys.d.cts +2 -2
  53. package/dist/plugins/blog/query-keys.d.mts +2 -2
  54. package/dist/plugins/blog/query-keys.d.ts +2 -2
  55. package/dist/plugins/client/index.d.cts +2 -2
  56. package/dist/plugins/client/index.d.mts +2 -2
  57. package/dist/plugins/client/index.d.ts +2 -2
  58. package/dist/plugins/cms/api/index.d.cts +67 -3
  59. package/dist/plugins/cms/api/index.d.mts +67 -3
  60. package/dist/plugins/cms/api/index.d.ts +67 -3
  61. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  62. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  63. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  64. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  65. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  66. package/dist/plugins/cms/query-keys.d.cts +1 -1
  67. package/dist/plugins/cms/query-keys.d.mts +1 -1
  68. package/dist/plugins/cms/query-keys.d.ts +1 -1
  69. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  70. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  71. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  72. package/dist/plugins/open-api/api/index.d.cts +1 -1
  73. package/dist/plugins/open-api/api/index.d.mts +1 -1
  74. package/dist/plugins/open-api/api/index.d.ts +1 -1
  75. package/dist/plugins/route-docs/client/index.cjs +10 -0
  76. package/dist/plugins/route-docs/client/index.d.cts +126 -0
  77. package/dist/plugins/route-docs/client/index.d.mts +126 -0
  78. package/dist/plugins/route-docs/client/index.d.ts +126 -0
  79. package/dist/plugins/route-docs/client/index.mjs +1 -0
  80. package/dist/plugins/route-docs/client.css +3 -0
  81. package/dist/plugins/route-docs/style.css +19 -0
  82. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
  83. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.mts} +27 -5
  84. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.ts} +27 -5
  85. package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
  86. package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
  87. package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
  88. package/package.json +15 -1
  89. package/src/client/index.ts +11 -4
  90. package/src/plugins/cms/api/plugin.ts +667 -21
  91. package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
  92. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  93. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  94. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  95. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  96. package/src/plugins/cms/db.ts +38 -0
  97. package/src/plugins/cms/types.ts +99 -10
  98. package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
  99. package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
  100. package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
  101. package/src/plugins/route-docs/client/index.ts +7 -0
  102. package/src/plugins/route-docs/client/plugin.tsx +187 -0
  103. package/src/plugins/route-docs/client.css +3 -0
  104. package/src/plugins/route-docs/generator.ts +385 -0
  105. package/src/plugins/route-docs/index.ts +12 -0
  106. package/src/plugins/route-docs/style.css +19 -0
  107. package/src/types.ts +19 -1
  108. package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
  109. package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
  110. package/dist/shared/{stack.CcI4sYJP.d.cts → stack.DLhzx1-D.d.ts} +1 -1
@@ -722,3 +722,347 @@ export function useDeleteContent(typeSlug: string) {
722
722
  },
723
723
  });
724
724
  }
725
+
726
+ // ========== Relation Hooks ==========
727
+
728
+ /**
729
+ * Content item with populated relations
730
+ */
731
+ export interface ContentItemWithRelations<TData = Record<string, unknown>>
732
+ extends SerializedContentItemWithType<TData> {
733
+ _relations?: Record<string, SerializedContentItemWithType[]>;
734
+ }
735
+
736
+ /**
737
+ * Hook for fetching a content item with its relations populated.
738
+ * Use this when you need to display related items alongside the main item.
739
+ *
740
+ * @template TMap - A type map of content type slugs to their data types
741
+ * @template TSlug - The content type slug (inferred from typeSlug parameter)
742
+ *
743
+ * @example
744
+ * ```typescript
745
+ * type MyCMSTypes = {
746
+ * resource: { name: string; categoryIds: Array<{ id: string }> }
747
+ * category: { name: string }
748
+ * }
749
+ * const { item } = useContentItemPopulated<MyCMSTypes, "resource">("resource", "some-id")
750
+ * // item?._relations?.categoryIds contains populated category items
751
+ * ```
752
+ */
753
+ export function useContentItemPopulated<
754
+ TMap extends Record<string, Record<string, unknown>> = Record<
755
+ string,
756
+ Record<string, unknown>
757
+ >,
758
+ TSlug extends keyof TMap = keyof TMap,
759
+ >(
760
+ typeSlug: TSlug & string,
761
+ id: string,
762
+ ): {
763
+ item: ContentItemWithRelations<TMap[TSlug]> | null;
764
+ isLoading: boolean;
765
+ error: Error | null;
766
+ refetch: () => void;
767
+ } {
768
+ const { apiBaseURL, apiBasePath, headers } =
769
+ usePluginOverrides<CMSPluginOverrides>("cms");
770
+ const client = createApiClient<CMSApiRouter>({
771
+ baseURL: apiBaseURL,
772
+ basePath: apiBasePath,
773
+ });
774
+
775
+ const { data, isLoading, error, refetch } = useQuery({
776
+ queryKey: ["cmsContent", typeSlug, id, "populated"],
777
+ queryFn: async () => {
778
+ const response: unknown = await client(
779
+ "/content/:typeSlug/:id/populated",
780
+ {
781
+ method: "GET",
782
+ params: { typeSlug, id },
783
+ headers,
784
+ },
785
+ );
786
+ if (isErrorResponse(response)) {
787
+ throw toError(response.error);
788
+ }
789
+ return (response as { data?: unknown }).data as ContentItemWithRelations<
790
+ TMap[TSlug]
791
+ >;
792
+ },
793
+ ...SHARED_QUERY_CONFIG,
794
+ enabled: !!typeSlug && !!id,
795
+ });
796
+
797
+ return {
798
+ item: data ?? null,
799
+ isLoading,
800
+ error,
801
+ refetch,
802
+ };
803
+ }
804
+
805
+ /**
806
+ * Suspense variant of useContentItemPopulated
807
+ */
808
+ export function useSuspenseContentItemPopulated<
809
+ TMap extends Record<string, Record<string, unknown>> = Record<
810
+ string,
811
+ Record<string, unknown>
812
+ >,
813
+ TSlug extends keyof TMap = keyof TMap,
814
+ >(
815
+ typeSlug: TSlug & string,
816
+ id: string,
817
+ ): {
818
+ item: ContentItemWithRelations<TMap[TSlug]> | null;
819
+ refetch: () => Promise<unknown>;
820
+ } {
821
+ const { apiBaseURL, apiBasePath, headers } =
822
+ usePluginOverrides<CMSPluginOverrides>("cms");
823
+ const client = createApiClient<CMSApiRouter>({
824
+ baseURL: apiBaseURL,
825
+ basePath: apiBasePath,
826
+ });
827
+
828
+ const { data, refetch, error, isFetching } = useSuspenseQuery({
829
+ queryKey: ["cmsContent", typeSlug, id, "populated"],
830
+ queryFn: async () => {
831
+ const response: unknown = await client(
832
+ "/content/:typeSlug/:id/populated",
833
+ {
834
+ method: "GET",
835
+ params: { typeSlug, id },
836
+ headers,
837
+ },
838
+ );
839
+ if (isErrorResponse(response)) {
840
+ throw toError(response.error);
841
+ }
842
+ return (response as { data?: unknown }).data as ContentItemWithRelations<
843
+ TMap[TSlug]
844
+ >;
845
+ },
846
+ ...SHARED_QUERY_CONFIG,
847
+ });
848
+
849
+ if (error && !isFetching) {
850
+ throw error;
851
+ }
852
+
853
+ return {
854
+ item: data ?? null,
855
+ refetch,
856
+ };
857
+ }
858
+
859
+ /**
860
+ * Options for useContentByRelation hook
861
+ */
862
+ export interface UseContentByRelationOptions {
863
+ /** Number of items per page (default: 20) */
864
+ limit?: number;
865
+ /** Whether to enable the query (default: true) */
866
+ enabled?: boolean;
867
+ }
868
+
869
+ /**
870
+ * Hook for fetching content items that have a specific relation.
871
+ * Useful for "filter by category" functionality.
872
+ *
873
+ * @template TMap - A type map of content type slugs to their data types
874
+ * @template TSlug - The content type slug (inferred from typeSlug parameter)
875
+ *
876
+ * @example
877
+ * ```typescript
878
+ * // Get all resources in a specific category
879
+ * const { items } = useContentByRelation("resource", "categoryIds", categoryId)
880
+ * ```
881
+ */
882
+ export function useContentByRelation<
883
+ TMap extends Record<string, Record<string, unknown>> = Record<
884
+ string,
885
+ Record<string, unknown>
886
+ >,
887
+ TSlug extends keyof TMap = keyof TMap,
888
+ >(
889
+ typeSlug: TSlug & string,
890
+ fieldName: string,
891
+ targetId: string,
892
+ options: UseContentByRelationOptions = {},
893
+ ): {
894
+ items: SerializedContentItemWithType<TMap[TSlug]>[];
895
+ total: number;
896
+ isLoading: boolean;
897
+ error: Error | null;
898
+ loadMore: () => void;
899
+ hasMore: boolean;
900
+ isLoadingMore: boolean;
901
+ refetch: () => void;
902
+ } {
903
+ const { apiBaseURL, apiBasePath, headers } =
904
+ usePluginOverrides<CMSPluginOverrides>("cms");
905
+ const client = createApiClient<CMSApiRouter>({
906
+ baseURL: apiBaseURL,
907
+ basePath: apiBasePath,
908
+ });
909
+ const { limit = 20, enabled = true } = options;
910
+
911
+ const {
912
+ data,
913
+ isLoading,
914
+ error,
915
+ fetchNextPage,
916
+ hasNextPage,
917
+ isFetchingNextPage,
918
+ refetch,
919
+ } = useInfiniteQuery({
920
+ queryKey: ["cmsContent", typeSlug, "by-relation", fieldName, targetId],
921
+ queryFn: async ({ pageParam = 0 }) => {
922
+ const response: unknown = await client("/content/:typeSlug/by-relation", {
923
+ method: "GET",
924
+ params: { typeSlug },
925
+ query: { field: fieldName, targetId, limit, offset: pageParam },
926
+ headers,
927
+ });
928
+ if (isErrorResponse(response)) {
929
+ throw toError(response.error);
930
+ }
931
+ return (response as { data?: unknown }).data as PaginatedContentItems<
932
+ TMap[TSlug]
933
+ >;
934
+ },
935
+ ...SHARED_QUERY_CONFIG,
936
+ initialPageParam: 0,
937
+ getNextPageParam: (lastPage, allPages) => {
938
+ if (!lastPage || typeof lastPage !== "object") return undefined;
939
+ const items = (lastPage as PaginatedContentItems)?.items;
940
+ if (!Array.isArray(items) || items.length < limit) return undefined;
941
+ const loadedCount = (allPages || []).reduce(
942
+ (sum, page) =>
943
+ sum +
944
+ (Array.isArray((page as PaginatedContentItems)?.items)
945
+ ? (page as PaginatedContentItems).items.length
946
+ : 0),
947
+ 0,
948
+ );
949
+ const total = (lastPage as PaginatedContentItems)?.total ?? 0;
950
+ if (loadedCount >= total) return undefined;
951
+ return loadedCount;
952
+ },
953
+ enabled: enabled && !!typeSlug && !!fieldName && !!targetId,
954
+ });
955
+
956
+ const pages = (
957
+ data as InfiniteData<PaginatedContentItems<TMap[TSlug]>, number> | undefined
958
+ )?.pages;
959
+ const items = (pages?.flatMap((page) =>
960
+ Array.isArray(page?.items) ? page.items : [],
961
+ ) ?? []) as SerializedContentItemWithType<TMap[TSlug]>[];
962
+ const total = pages?.[0]?.total ?? 0;
963
+
964
+ return {
965
+ items,
966
+ total,
967
+ isLoading,
968
+ error,
969
+ loadMore: fetchNextPage,
970
+ hasMore: !!hasNextPage,
971
+ isLoadingMore: isFetchingNextPage,
972
+ refetch,
973
+ };
974
+ }
975
+
976
+ /**
977
+ * Suspense variant of useContentByRelation
978
+ */
979
+ export function useSuspenseContentByRelation<
980
+ TMap extends Record<string, Record<string, unknown>> = Record<
981
+ string,
982
+ Record<string, unknown>
983
+ >,
984
+ TSlug extends keyof TMap = keyof TMap,
985
+ >(
986
+ typeSlug: TSlug & string,
987
+ fieldName: string,
988
+ targetId: string,
989
+ options: UseContentByRelationOptions = {},
990
+ ): {
991
+ items: SerializedContentItemWithType<TMap[TSlug]>[];
992
+ total: number;
993
+ loadMore: () => Promise<unknown>;
994
+ hasMore: boolean;
995
+ isLoadingMore: boolean;
996
+ refetch: () => Promise<unknown>;
997
+ } {
998
+ const { apiBaseURL, apiBasePath, headers } =
999
+ usePluginOverrides<CMSPluginOverrides>("cms");
1000
+ const client = createApiClient<CMSApiRouter>({
1001
+ baseURL: apiBaseURL,
1002
+ basePath: apiBasePath,
1003
+ });
1004
+ const { limit = 20 } = options;
1005
+
1006
+ const {
1007
+ data,
1008
+ fetchNextPage,
1009
+ hasNextPage,
1010
+ isFetchingNextPage,
1011
+ refetch,
1012
+ error,
1013
+ isFetching,
1014
+ } = useSuspenseInfiniteQuery({
1015
+ queryKey: ["cmsContent", typeSlug, "by-relation", fieldName, targetId],
1016
+ queryFn: async ({ pageParam = 0 }) => {
1017
+ const response: unknown = await client("/content/:typeSlug/by-relation", {
1018
+ method: "GET",
1019
+ params: { typeSlug },
1020
+ query: { field: fieldName, targetId, limit, offset: pageParam },
1021
+ headers,
1022
+ });
1023
+ if (isErrorResponse(response)) {
1024
+ throw toError(response.error);
1025
+ }
1026
+ return (response as { data?: unknown }).data as PaginatedContentItems<
1027
+ TMap[TSlug]
1028
+ >;
1029
+ },
1030
+ ...SHARED_QUERY_CONFIG,
1031
+ initialPageParam: 0,
1032
+ getNextPageParam: (lastPage, allPages) => {
1033
+ if (!lastPage || typeof lastPage !== "object") return undefined;
1034
+ const items = (lastPage as PaginatedContentItems)?.items;
1035
+ if (!Array.isArray(items) || items.length < limit) return undefined;
1036
+ const loadedCount = (allPages || []).reduce(
1037
+ (sum, page) =>
1038
+ sum +
1039
+ (Array.isArray((page as PaginatedContentItems)?.items)
1040
+ ? (page as PaginatedContentItems).items.length
1041
+ : 0),
1042
+ 0,
1043
+ );
1044
+ const total = (lastPage as PaginatedContentItems)?.total ?? 0;
1045
+ if (loadedCount >= total) return undefined;
1046
+ return loadedCount;
1047
+ },
1048
+ });
1049
+
1050
+ if (error && !isFetching) {
1051
+ throw error;
1052
+ }
1053
+
1054
+ const pages = data.pages as PaginatedContentItems<TMap[TSlug]>[];
1055
+ const items = (pages?.flatMap((page) =>
1056
+ Array.isArray(page?.items) ? page.items : [],
1057
+ ) ?? []) as SerializedContentItemWithType<TMap[TSlug]>[];
1058
+ const total = pages?.[0]?.total ?? 0;
1059
+
1060
+ return {
1061
+ items,
1062
+ total,
1063
+ loadMore: fetchNextPage,
1064
+ hasMore: !!hasNextPage,
1065
+ isLoadingMore: isFetchingNextPage,
1066
+ refetch,
1067
+ };
1068
+ }
@@ -77,4 +77,42 @@ export const cmsSchema = createDbPlugin("cms", {
77
77
  },
78
78
  },
79
79
  },
80
+ /**
81
+ * Junction table for content item relationships
82
+ * Stores many-to-many and one-to-many relations between content items
83
+ */
84
+ contentRelation: {
85
+ modelName: "contentRelation",
86
+ fields: {
87
+ /** The content item that has the relation field */
88
+ sourceId: {
89
+ type: "string",
90
+ required: true,
91
+ references: {
92
+ model: "contentItem",
93
+ field: "id",
94
+ onDelete: "cascade",
95
+ },
96
+ },
97
+ /** The content item being referenced */
98
+ targetId: {
99
+ type: "string",
100
+ required: true,
101
+ references: {
102
+ model: "contentItem",
103
+ field: "id",
104
+ onDelete: "cascade",
105
+ },
106
+ },
107
+ /** The field name in the source content type schema (e.g., "categoryIds") */
108
+ fieldName: {
109
+ type: "string",
110
+ required: true,
111
+ },
112
+ createdAt: {
113
+ type: "date",
114
+ defaultValue: () => new Date(),
115
+ },
116
+ },
117
+ },
80
118
  });
@@ -68,6 +68,92 @@ export type ContentItemWithType = ContentItem & {
68
68
  contentType?: ContentType;
69
69
  };
70
70
 
71
+ /**
72
+ * Content relation stored in the database (junction table)
73
+ * Links source content items to target content items for relationship fields
74
+ */
75
+ export type ContentRelation = {
76
+ id: string;
77
+ /** The content item that has the relation field */
78
+ sourceId: string;
79
+ /** The content item being referenced */
80
+ targetId: string;
81
+ /** The field name in the source content type schema (e.g., "categoryIds") */
82
+ fieldName: string;
83
+ createdAt: Date;
84
+ };
85
+
86
+ // ========== Relation Field Types ==========
87
+
88
+ /**
89
+ * Configuration for a relation field in schema metadata.
90
+ * Use with .meta({ fieldType: "relation", relation: {...} })
91
+ *
92
+ * The schema stores relation values as simple `{ id: string }` references.
93
+ * When `creatable: true`, the frontend sends `{ _new: true, data: {...} }`
94
+ * which the API processes before validation - creating new items and
95
+ * converting them to ID references.
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * const ResourceSchema = z.object({
100
+ * // Simple array of ID references - API handles _new items before validation
101
+ * categoryIds: z.array(z.object({ id: z.string() })).default([]).meta({
102
+ * fieldType: "relation",
103
+ * relation: {
104
+ * type: "manyToMany",
105
+ * targetType: "category",
106
+ * displayField: "name",
107
+ * creatable: true,
108
+ * },
109
+ * }),
110
+ * });
111
+ * ```
112
+ */
113
+ export interface RelationConfig {
114
+ /** Relation type */
115
+ type: "belongsTo" | "hasMany" | "manyToMany";
116
+ /** Target content type slug */
117
+ targetType: string;
118
+ /** Field to display in the dropdown (e.g., "name", "title") */
119
+ displayField: string;
120
+ /** Allow creating new items inline via modal (default: false) */
121
+ creatable?: boolean;
122
+ }
123
+
124
+ /**
125
+ * Value for a relation field - either a reference to existing item or a new item to create.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * // Reference to existing item
130
+ * const existing: RelationValue = { id: "abc123" };
131
+ *
132
+ * // New item to create on save
133
+ * const newItem: RelationValue = {
134
+ * _new: true,
135
+ * data: { name: "New Category", description: "..." }
136
+ * };
137
+ * ```
138
+ */
139
+ export type RelationValue =
140
+ | { id: string }
141
+ | { _new: true; data: Record<string, unknown> };
142
+
143
+ /**
144
+ * Represents an inverse relation (content types that reference this type via belongsTo)
145
+ */
146
+ export interface InverseRelation {
147
+ /** The content type slug that has the belongsTo relation */
148
+ sourceType: string;
149
+ /** Display name of the source content type */
150
+ sourceTypeName: string;
151
+ /** The field name that contains the belongsTo relation */
152
+ fieldName: string;
153
+ /** Count of items with this relation (when itemId is provided) */
154
+ count: number;
155
+ }
156
+
71
157
  /**
72
158
  * Serialized content type for API responses (dates as strings)
73
159
  */
@@ -96,6 +182,11 @@ export interface SerializedContentItemWithType<TData = Record<string, unknown>>
96
182
  parsedData: TData;
97
183
  /** Joined content type */
98
184
  contentType?: SerializedContentType;
185
+ /**
186
+ * Populated relation data (only present when using populated endpoints/hooks).
187
+ * Keys are field names, values are arrays of related content items.
188
+ */
189
+ _relations?: Record<string, SerializedContentItemWithType[]>;
99
190
  }
100
191
 
101
192
  /**
@@ -156,16 +247,17 @@ export interface CMSHookContext {
156
247
 
157
248
  /**
158
249
  * Hooks for customizing CMS backend behavior
250
+ *
251
+ * Note: Before hooks can only deny operations by returning `false`.
252
+ * They cannot modify the data being saved. This ensures consistency
253
+ * between the stored content item data and relation junction tables.
159
254
  */
160
255
  export interface CMSBackendHooks {
161
- /** Called before creating a content item */
256
+ /** Called before creating a content item. Return false to deny the operation. */
162
257
  onBeforeCreate?: (
163
258
  data: Record<string, unknown>,
164
259
  context: CMSHookContext,
165
- ) =>
166
- | Promise<Record<string, unknown> | false>
167
- | Record<string, unknown>
168
- | false;
260
+ ) => Promise<false | void> | false | void;
169
261
 
170
262
  /** Called after creating a content item */
171
263
  onAfterCreate?: (
@@ -173,15 +265,12 @@ export interface CMSBackendHooks {
173
265
  context: CMSHookContext,
174
266
  ) => Promise<void> | void;
175
267
 
176
- /** Called before updating a content item */
268
+ /** Called before updating a content item. Return false to deny the operation. */
177
269
  onBeforeUpdate?: (
178
270
  id: string,
179
271
  data: Record<string, unknown>,
180
272
  context: CMSHookContext,
181
- ) =>
182
- | Promise<Record<string, unknown> | false>
183
- | Record<string, unknown>
184
- | false;
273
+ ) => Promise<false | void> | false | void;
185
274
 
186
275
  /** Called after updating a content item */
187
276
  onAfterUpdate?: (
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { Skeleton } from "@workspace/ui/components/skeleton";
4
+ import { ScrollArea } from "@workspace/ui/components/scroll-area";
5
+ import { Card, CardContent } from "@workspace/ui/components/card";
6
+
7
+ export function DocsPageSkeleton() {
8
+ return (
9
+ <div className="flex min-h-screen bg-background">
10
+ {/* Desktop Sidebar skeleton */}
11
+ <aside className="hidden md:block w-72 border-r bg-card shrink-0">
12
+ <div className="p-4 border-b">
13
+ <Skeleton className="h-4 w-16" />
14
+ </div>
15
+ <ScrollArea className="h-[calc(100vh-57px)]">
16
+ <div className="p-3 space-y-4">
17
+ {/* Plugin groups */}
18
+ {[1, 2, 3].map((i) => (
19
+ <div key={i} className="space-y-2">
20
+ <Skeleton className="h-8 w-full" />
21
+ <div className="ml-2 space-y-1">
22
+ {[1, 2, 3].map((j) => (
23
+ <Skeleton key={j} className="h-7 w-full" />
24
+ ))}
25
+ </div>
26
+ </div>
27
+ ))}
28
+ </div>
29
+ </ScrollArea>
30
+ </aside>
31
+
32
+ {/* Mobile header skeleton */}
33
+ <div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-card border-b">
34
+ <div className="flex items-center justify-between p-4">
35
+ <Skeleton className="h-4 w-24" />
36
+ <Skeleton className="h-8 w-20" />
37
+ </div>
38
+ </div>
39
+
40
+ {/* Main content skeleton */}
41
+ <main className="flex-1 overflow-auto pt-16 md:pt-0">
42
+ <div className="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8 space-y-4 sm:space-y-6">
43
+ {/* Title */}
44
+ <div className="space-y-2">
45
+ <Skeleton className="h-8 sm:h-9 w-48 sm:w-64" />
46
+ <Skeleton className="h-4 sm:h-5 w-72 sm:w-96" />
47
+ </div>
48
+
49
+ {/* Separator */}
50
+ <div className="h-px bg-border" />
51
+
52
+ {/* Badges */}
53
+ <div className="flex gap-2">
54
+ <Skeleton className="h-6 w-24" />
55
+ <Skeleton className="h-6 w-20" />
56
+ </div>
57
+
58
+ {/* Routes card */}
59
+ <Card>
60
+ <div className="p-4 sm:p-6">
61
+ <Skeleton className="h-6 w-32 mb-4" />
62
+ </div>
63
+ <CardContent className="pt-0 space-y-4">
64
+ {/* Desktop table skeleton */}
65
+ <div className="hidden md:block space-y-2">
66
+ {[1, 2, 3, 4].map((i) => (
67
+ <Skeleton key={i} className="h-12 w-full" />
68
+ ))}
69
+ </div>
70
+ {/* Mobile cards skeleton */}
71
+ <div className="md:hidden space-y-3">
72
+ {[1, 2, 3].map((i) => (
73
+ <Skeleton key={i} className="h-28 w-full rounded-lg" />
74
+ ))}
75
+ </div>
76
+ </CardContent>
77
+ </Card>
78
+ </div>
79
+ </main>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1 @@
1
+ export { DocsPageSkeleton } from "./docs-skeleton";