@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.
- package/dist/api/index.d.cts +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/client/index.cjs +6 -2
- package/dist/client/index.d.cts +2 -1
- package/dist/client/index.d.mts +2 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.mjs +6 -2
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
- package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
- package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
- package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
- package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
- package/dist/packages/ui/src/components/dialog.cjs +6 -0
- package/dist/packages/ui/src/components/dialog.mjs +6 -1
- package/dist/packages/ui/src/components/sheet.cjs +25 -0
- package/dist/packages/ui/src/components/sheet.mjs +24 -1
- package/dist/plugins/api/index.d.cts +2 -2
- package/dist/plugins/api/index.d.mts +2 -2
- package/dist/plugins/api/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/client/index.d.cts +2 -2
- package/dist/plugins/client/index.d.mts +2 -2
- package/dist/plugins/client/index.d.ts +2 -2
- package/dist/plugins/cms/api/index.d.cts +67 -3
- package/dist/plugins/cms/api/index.d.mts +67 -3
- package/dist/plugins/cms/api/index.d.ts +67 -3
- package/dist/plugins/cms/client/hooks/index.cjs +4 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
- package/dist/plugins/cms/client/hooks/index.mjs +1 -1
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/open-api/api/index.d.cts +1 -1
- package/dist/plugins/open-api/api/index.d.mts +1 -1
- package/dist/plugins/open-api/api/index.d.ts +1 -1
- package/dist/plugins/route-docs/client/index.cjs +10 -0
- package/dist/plugins/route-docs/client/index.d.cts +126 -0
- package/dist/plugins/route-docs/client/index.d.mts +126 -0
- package/dist/plugins/route-docs/client/index.d.ts +126 -0
- package/dist/plugins/route-docs/client/index.mjs +1 -0
- package/dist/plugins/route-docs/client.css +3 -0
- package/dist/plugins/route-docs/style.css +19 -0
- package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.mts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.ts} +27 -5
- package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
- package/package.json +15 -1
- package/src/client/index.ts +11 -4
- package/src/plugins/cms/api/plugin.ts +667 -21
- package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
- package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
- package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
- package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
- package/src/plugins/cms/db.ts +38 -0
- package/src/plugins/cms/types.ts +99 -10
- package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
- package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
- package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
- package/src/plugins/route-docs/client/index.ts +7 -0
- package/src/plugins/route-docs/client/plugin.tsx +187 -0
- package/src/plugins/route-docs/client.css +3 -0
- package/src/plugins/route-docs/generator.ts +385 -0
- package/src/plugins/route-docs/index.ts +12 -0
- package/src/plugins/route-docs/style.css +19 -0
- package/src/types.ts +19 -1
- package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
- 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
|
+
}
|
package/src/plugins/cms/db.ts
CHANGED
|
@@ -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
|
});
|
package/src/plugins/cms/types.ts
CHANGED
|
@@ -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";
|