convex-cms 0.0.7-alpha.0 → 0.0.8

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 (134) hide show
  1. package/README.md +22 -17
  2. package/admin/src/components/BreakingChangesWarningDialog.tsx +5 -5
  3. package/admin/src/components/BulkOperationModal.tsx +14 -14
  4. package/admin/src/components/ContentEntryEditor.tsx +6 -6
  5. package/admin/src/components/ContentTypeFormModal.tsx +1 -1
  6. package/admin/src/components/Header.tsx +5 -2
  7. package/admin/src/components/SchemaDriftWarning.tsx +126 -0
  8. package/admin/src/components/TaxonomyEditor.tsx +2 -2
  9. package/admin/src/components/TermTree.tsx +3 -3
  10. package/admin/src/components/UploadDropzone.tsx +7 -7
  11. package/admin/src/components/VersionCompare.tsx +13 -13
  12. package/admin/src/components/VersionHistory.tsx +2 -2
  13. package/admin/src/components/VersionRollbackModal.tsx +5 -5
  14. package/admin/src/components/cmsds/CmsButton.tsx +2 -2
  15. package/admin/src/components/cmsds/CmsDialog.tsx +4 -1
  16. package/admin/src/components/cmsds/CmsStatusBadge.tsx +5 -5
  17. package/admin/src/components/fields/JsonField.tsx +1 -1
  18. package/admin/src/components/fields/TagField.tsx +1 -1
  19. package/admin/src/contexts/SettingsConfigContext.tsx +10 -3
  20. package/admin/src/embed/index.tsx +29 -9
  21. package/admin/src/embed/pages/ContentTypeEntries.tsx +25 -0
  22. package/admin/src/embed/pages/Entry.tsx +114 -0
  23. package/admin/src/embed/pages/Media.tsx +3 -1
  24. package/admin/src/embed/pages/NewEntry.tsx +83 -0
  25. package/admin/src/embed/pages/index.ts +3 -0
  26. package/admin/src/pages/ContentPage.tsx +16 -1
  27. package/admin/src/pages/ContentTypeEntriesPage.tsx +466 -0
  28. package/admin/src/pages/ContentTypesPage.tsx +3 -3
  29. package/admin/src/pages/DashboardPage.tsx +3 -0
  30. package/admin/src/pages/SettingsPage.tsx +4 -4
  31. package/admin/src/pages/index.ts +1 -0
  32. package/admin/src/routes/__root.tsx +10 -10
  33. package/admin/src/styles/globals.css +31 -5
  34. package/admin/src/styles/tailwind-config.css +25 -0
  35. package/admin/src/styles/theme.css +50 -0
  36. package/admin-dist/nitro.json +1 -1
  37. package/admin-dist/public/assets/{CmsEmptyState-CXVkI3FZ.js → CmsEmptyState-6-PLaXtD.js} +1 -1
  38. package/admin-dist/public/assets/{CmsPageHeader-DU9fD34s.js → CmsPageHeader-SoF4Epu9.js} +1 -1
  39. package/admin-dist/public/assets/CmsStatusBadge-D7kYaohx.js +1 -0
  40. package/admin-dist/public/assets/{CmsSurface-DF7OcKg_.js → CmsSurface-BvksBm6W.js} +1 -1
  41. package/admin-dist/public/assets/{CmsToolbar-5S8FQrSx.js → CmsToolbar-DlZPMe2B.js} +1 -1
  42. package/admin-dist/public/assets/ContentEntryEditor-C6n9xLS9.js +4 -0
  43. package/admin-dist/public/assets/{TaxonomyFilter-DEN2Q9Lo.js → TaxonomyFilter-CFX1_g8s.js} +1 -1
  44. package/admin-dist/public/assets/{_contentTypeId-Ba5iowxH.js → _contentTypeId-DTv8UoTp.js} +1 -1
  45. package/admin-dist/public/assets/_entryId-D3lr5Dvy.js +1 -0
  46. package/admin-dist/public/assets/alert-BAHTL6ao.js +1 -0
  47. package/admin-dist/public/assets/badge-oJv4Eai8.js +1 -0
  48. package/admin-dist/public/assets/{circle-check-big-B7eCOM8r.js → circle-check-big-3OHxNDhO.js} +1 -1
  49. package/admin-dist/public/assets/{command-BIjzeKOv.js → command-DwgQs69u.js} +1 -1
  50. package/admin-dist/public/assets/content-CKQ4QwW2.js +1 -0
  51. package/admin-dist/public/assets/content-types-BrttaLpc.js +1 -0
  52. package/admin-dist/public/assets/globals-CoCRjt0K.css +1 -0
  53. package/admin-dist/public/assets/index-DOkgTSx0.js +1 -0
  54. package/admin-dist/public/assets/{main-BZB1uYTH.js → main-DV6oxWnU.js} +5 -5
  55. package/admin-dist/public/assets/media-B2i-mCbx.js +1 -0
  56. package/admin-dist/public/assets/new._contentTypeId-VF63rpic.js +1 -0
  57. package/admin-dist/public/assets/{pencil-BDQ1ZWRw.js → pencil-CX1CiTDD.js} +1 -1
  58. package/admin-dist/public/assets/refresh-cw-Cm-YOeFI.js +1 -0
  59. package/admin-dist/public/assets/{rotate-ccw-BWblSIsl.js → rotate-ccw-B45JsL5f.js} +1 -1
  60. package/admin-dist/public/assets/{scroll-area-BoaB6x8v.js → scroll-area-b3A1HHR7.js} +1 -1
  61. package/admin-dist/public/assets/{search-CYMIpd39.js → search-DKKh_DdH.js} +1 -1
  62. package/admin-dist/public/assets/settings-CGVDEV1r.js +1 -0
  63. package/admin-dist/public/assets/{switch-DN7TOCa5.js → switch-BTMY8Qnk.js} +1 -1
  64. package/admin-dist/public/assets/tabs-DUQwUoIb.js +1 -0
  65. package/admin-dist/public/assets/{tanstack-adapter-DQcKErwf.js → tanstack-adapter-f7AHmQ5L.js} +1 -1
  66. package/admin-dist/public/assets/taxonomies-DvMppdiD.js +1 -0
  67. package/admin-dist/public/assets/{trash-Dp_a2mpb.js → trash-D7e0uKd9.js} +1 -1
  68. package/admin-dist/public/assets/{useBreadcrumbLabel-BQ9dJI6T.js → useBreadcrumbLabel-CF2KYwsw.js} +1 -1
  69. package/admin-dist/public/assets/{usePermissions-WUBNg_Id.js → usePermissions-DWBImEOW.js} +1 -1
  70. package/admin-dist/server/_libs/lucide-react.mjs +50 -43
  71. package/admin-dist/server/_ssr/{CmsEmptyState-DYh_PPQE.mjs → CmsEmptyState-BM8DghTl.mjs} +1 -1
  72. package/admin-dist/server/_ssr/{CmsPageHeader-BcniLh49.mjs → CmsPageHeader-BHUmrIWD.mjs} +1 -1
  73. package/admin-dist/server/_ssr/{CmsStatusBadge-BShWDxwE.mjs → CmsStatusBadge-D0Zb0oRl.mjs} +7 -7
  74. package/admin-dist/server/_ssr/{CmsSurface-CHEv-Kba.mjs → CmsSurface-B2eBr-47.mjs} +1 -1
  75. package/admin-dist/server/_ssr/{CmsToolbar-Dqqb216_.mjs → CmsToolbar-BCrwg7OL.mjs} +1 -1
  76. package/admin-dist/server/_ssr/{ContentEntryEditor-DOIAyWME.mjs → ContentEntryEditor-Cjfm0uhr.mjs} +37 -37
  77. package/admin-dist/server/_ssr/{TaxonomyFilter-BfsPAZ-Y.mjs → TaxonomyFilter-C4pD0kfM.mjs} +3 -3
  78. package/admin-dist/server/_ssr/{_contentTypeId-CPjmri90.mjs → _contentTypeId-CiDiX-p7.mjs} +11 -11
  79. package/admin-dist/server/_ssr/{_entryId-D0yu8HuP.mjs → _entryId-9GxatOkL.mjs} +11 -11
  80. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-CC7UrHKE.mjs +4 -0
  81. package/admin-dist/server/_ssr/{badge-Cp61aQNc.mjs → badge-EI998zba.mjs} +1 -1
  82. package/admin-dist/server/_ssr/{command-BfjE1WJf.mjs → command-BLAWQhUw.mjs} +1 -1
  83. package/admin-dist/server/_ssr/{content-DrODe6sA.mjs → content-BHX39L4D.mjs} +31 -22
  84. package/admin-dist/server/_ssr/{content-types-BPgMwxiT.mjs → content-types-DCzrBhTH.mjs} +9 -9
  85. package/admin-dist/server/_ssr/{index-BTHmIC9W.mjs → index-DwM_5VNP.mjs} +92 -6
  86. package/admin-dist/server/_ssr/index.mjs +2 -2
  87. package/admin-dist/server/_ssr/{media-DkvBfmD9.mjs → media-CbzgTRRQ.mjs} +9 -9
  88. package/admin-dist/server/_ssr/{new._contentTypeId-Co_73sDJ.mjs → new._contentTypeId-6Ph-Gtlw.mjs} +10 -10
  89. package/admin-dist/server/_ssr/{router-CaDgRHfQ.mjs → router-vd1nySeP.mjs} +45 -35
  90. package/admin-dist/server/_ssr/{scroll-area-D3v-O_jk.mjs → scroll-area--B9snFTJ.mjs} +1 -1
  91. package/admin-dist/server/_ssr/{settings-MaEXh2Hz.mjs → settings-DlTO2JSj.mjs} +11 -11
  92. package/admin-dist/server/_ssr/{switch-DmbR03dm.mjs → switch-C05NgNW0.mjs} +1 -1
  93. package/admin-dist/server/_ssr/{tabs-5oFlAGLz.mjs → tabs-DAk2J5xy.mjs} +8 -8
  94. package/admin-dist/server/_ssr/{tanstack-adapter-DNaUioIZ.mjs → tanstack-adapter-DWbaPByn.mjs} +15 -1
  95. package/admin-dist/server/_ssr/{taxonomies-D3xMK23a.mjs → taxonomies-B8nqce6u.mjs} +12 -12
  96. package/admin-dist/server/_ssr/{trash-CNw1mtF1.mjs → trash-zdlZgpTo.mjs} +7 -7
  97. package/admin-dist/server/_ssr/{useBreadcrumbLabel-BQGjOTcy.mjs → useBreadcrumbLabel-DpEKyG1h.mjs} +1 -1
  98. package/admin-dist/server/_ssr/{usePermissions-D0qtvmNi.mjs → usePermissions-olYRd9S9.mjs} +1 -1
  99. package/admin-dist/server/index.mjs +164 -157
  100. package/dist/client/admin/contentTypes.d.ts +25 -0
  101. package/dist/client/admin/contentTypes.d.ts.map +1 -1
  102. package/dist/client/admin/contentTypes.js +212 -6
  103. package/dist/client/admin/contentTypes.js.map +1 -1
  104. package/dist/client/admin/entries.d.ts.map +1 -1
  105. package/dist/client/admin/entries.js +27 -0
  106. package/dist/client/admin/entries.js.map +1 -1
  107. package/dist/client/admin/index.d.ts +4 -0
  108. package/dist/client/admin/index.d.ts.map +1 -1
  109. package/dist/client/admin/index.js +16 -0
  110. package/dist/client/admin/index.js.map +1 -1
  111. package/dist/client/admin/types.d.ts +4 -0
  112. package/dist/client/admin/types.d.ts.map +1 -1
  113. package/dist/client/schema/defineContentType.d.ts.map +1 -1
  114. package/dist/client/schema/defineContentType.js +99 -80
  115. package/dist/client/schema/defineContentType.js.map +1 -1
  116. package/dist/component/contentTypeMutations.d.ts.map +1 -1
  117. package/dist/component/contentTypeMutations.js +5 -4
  118. package/dist/component/contentTypeMutations.js.map +1 -1
  119. package/package.json +2 -2
  120. package/admin-dist/public/assets/CmsStatusBadge-nZ9TeLBL.js +0 -1
  121. package/admin-dist/public/assets/ContentEntryEditor-BDb44eTo.js +0 -4
  122. package/admin-dist/public/assets/_entryId-OY3sLz6O.js +0 -1
  123. package/admin-dist/public/assets/alert-BbW1Q9CR.js +0 -1
  124. package/admin-dist/public/assets/badge-DdM8Eua8.js +0 -1
  125. package/admin-dist/public/assets/content-BV3YeSSW.js +0 -1
  126. package/admin-dist/public/assets/content-types-Bm4b2tf8.js +0 -1
  127. package/admin-dist/public/assets/globals-D41WzvyZ.css +0 -1
  128. package/admin-dist/public/assets/index-DnJ5Twlv.js +0 -1
  129. package/admin-dist/public/assets/media-BIMN5jXt.js +0 -1
  130. package/admin-dist/public/assets/new._contentTypeId-DTWb8ZDl.js +0 -1
  131. package/admin-dist/public/assets/settings-DaNDUtr5.js +0 -1
  132. package/admin-dist/public/assets/tabs-RN__emeJ.js +0 -1
  133. package/admin-dist/public/assets/taxonomies-DylY9HE1.js +0 -1
  134. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-DgCpSt_y.mjs +0 -4
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Shared Content Type Entries Page Component
3
+ *
4
+ * Lists entries for a specific content type with search, filtering, and actions.
5
+ * Used by both CLI routes and embed pages.
6
+ */
7
+
8
+ import { useState, useMemo, useEffect, useCallback } from "react";
9
+ import { useQuery, useMutation } from "convex/react";
10
+ import { usePermissions } from "~/hooks";
11
+ import { CmsPageHeader } from "~/components/cmsds/CmsPageHeader";
12
+ import { CmsToolbar } from "~/components/cmsds/CmsToolbar";
13
+ import { CmsButton } from "~/components/cmsds/CmsButton";
14
+ import { CmsStatusBadge, type ContentStatus } from "~/components/cmsds/CmsStatusBadge";
15
+ import { CmsEmptyState } from "~/components/cmsds/CmsEmptyState";
16
+ import { CmsConfirmDialog } from "~/components/cmsds/CmsDialog";
17
+ import { Input } from "~/components/ui/input";
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from "~/components/ui/select";
25
+ import {
26
+ Search,
27
+ Plus,
28
+ FileText,
29
+ ChevronUp,
30
+ ChevronDown,
31
+ ArrowUpDown,
32
+ } from "lucide-react";
33
+ import type { AdminNavigation } from "~/lib/navigation";
34
+ import type { CmsAdminApi } from "~/embed/contexts/ApiContext";
35
+
36
+ type SortField = "title" | "status" | "updatedAt" | "createdAt";
37
+ type SortDirection = "asc" | "desc";
38
+
39
+ export interface ContentTypeEntriesPageProps {
40
+ api: CmsAdminApi;
41
+ navigation: AdminNavigation;
42
+ contentTypeId: string;
43
+ }
44
+
45
+ export function ContentTypeEntriesPage({
46
+ api,
47
+ navigation,
48
+ contentTypeId,
49
+ }: ContentTypeEntriesPageProps) {
50
+ const [selectedStatus, setSelectedStatus] = useState<ContentStatus | "all">("all");
51
+ const [searchQuery, setSearchQuery] = useState("");
52
+ const [debouncedSearch, setDebouncedSearch] = useState("");
53
+ const [sortField, setSortField] = useState<SortField>("updatedAt");
54
+ const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
55
+ const [currentPage, setCurrentPage] = useState(0);
56
+ const pageSize = 25;
57
+
58
+ const { canCreate, canUpdate, canDelete } = usePermissions();
59
+
60
+ const deleteEntry = useMutation(api.deleteEntry);
61
+
62
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
63
+ const [entryToDelete, setEntryToDelete] = useState<{ _id: string; title: string } | null>(null);
64
+ const [isDeleting, setIsDeleting] = useState(false);
65
+ const [deleteError, setDeleteError] = useState<string | null>(null);
66
+
67
+ useEffect(() => {
68
+ const timer = setTimeout(() => {
69
+ setDebouncedSearch(searchQuery);
70
+ setCurrentPage(0);
71
+ }, 300);
72
+ return () => clearTimeout(timer);
73
+ }, [searchQuery]);
74
+
75
+ const contentType = useQuery(api.getContentType, { id: contentTypeId });
76
+
77
+ const entriesResult = useQuery(api.listEntries, {
78
+ contentTypeName: contentType?.name,
79
+ status: selectedStatus === "all" ? undefined : selectedStatus,
80
+ search: debouncedSearch || undefined,
81
+ paginationOpts: { numItems: 250, cursor: null },
82
+ });
83
+ const allEntries = entriesResult?.page ?? [];
84
+
85
+ const getEntryTitle = useCallback(
86
+ (entry: { data: Record<string, unknown> }) => {
87
+ const titleField = contentType?.titleField ?? "title";
88
+ const title = entry.data[titleField];
89
+ return typeof title === "string" && title ? title : "Untitled";
90
+ },
91
+ [contentType?.titleField]
92
+ );
93
+
94
+ const sortedEntries = useMemo(() => {
95
+ const entries = [...allEntries];
96
+
97
+ entries.sort((a, b) => {
98
+ let comparison = 0;
99
+
100
+ switch (sortField) {
101
+ case "title": {
102
+ const titleA = getEntryTitle(a).toLowerCase();
103
+ const titleB = getEntryTitle(b).toLowerCase();
104
+ comparison = titleA.localeCompare(titleB);
105
+ break;
106
+ }
107
+ case "status":
108
+ comparison = a.status.localeCompare(b.status);
109
+ break;
110
+ case "updatedAt": {
111
+ const updatedA = a.lastPublishedAt ?? a._creationTime ?? 0;
112
+ const updatedB = b.lastPublishedAt ?? b._creationTime ?? 0;
113
+ comparison = updatedA - updatedB;
114
+ break;
115
+ }
116
+ case "createdAt":
117
+ comparison = (a._creationTime ?? 0) - (b._creationTime ?? 0);
118
+ break;
119
+ default:
120
+ comparison = 0;
121
+ }
122
+
123
+ return sortDirection === "desc" ? -comparison : comparison;
124
+ });
125
+
126
+ return entries;
127
+ }, [allEntries, sortField, sortDirection, getEntryTitle]);
128
+
129
+ const paginatedEntries = useMemo(() => {
130
+ const start = currentPage * pageSize;
131
+ return sortedEntries.slice(start, start + pageSize);
132
+ }, [sortedEntries, currentPage, pageSize]);
133
+
134
+ const totalPages = Math.ceil(sortedEntries.length / pageSize);
135
+
136
+ const formatDate = (timestamp: number) => {
137
+ return new Date(timestamp).toLocaleDateString("en-US", {
138
+ year: "numeric",
139
+ month: "short",
140
+ day: "numeric",
141
+ hour: "2-digit",
142
+ minute: "2-digit",
143
+ });
144
+ };
145
+
146
+ const handleSort = (field: SortField) => {
147
+ if (sortField === field) {
148
+ setSortDirection(sortDirection === "asc" ? "desc" : "asc");
149
+ } else {
150
+ setSortField(field);
151
+ setSortDirection("desc");
152
+ }
153
+ };
154
+
155
+ const getSortIcon = (field: SortField) => {
156
+ if (sortField !== field) {
157
+ return <ArrowUpDown className="size-3.5 text-muted-foreground/50" />;
158
+ }
159
+ return sortDirection === "asc" ? (
160
+ <ChevronUp className="size-3.5" />
161
+ ) : (
162
+ <ChevronDown className="size-3.5" />
163
+ );
164
+ };
165
+
166
+ const handleDeleteClick = useCallback(
167
+ (entry: { _id: string; data: Record<string, unknown> }) => {
168
+ const title = getEntryTitle(entry);
169
+ setEntryToDelete({ _id: entry._id, title });
170
+ setDeleteError(null);
171
+ setDeleteModalOpen(true);
172
+ },
173
+ [getEntryTitle]
174
+ );
175
+
176
+ const handleDeleteConfirm = useCallback(async () => {
177
+ if (!entryToDelete) return;
178
+
179
+ setIsDeleting(true);
180
+ setDeleteError(null);
181
+
182
+ try {
183
+ await deleteEntry({
184
+ id: entryToDelete._id,
185
+ hardDelete: false,
186
+ });
187
+ setDeleteModalOpen(false);
188
+ setEntryToDelete(null);
189
+ } catch (error) {
190
+ const message = error instanceof Error ? error.message : "Failed to delete entry";
191
+ setDeleteError(message);
192
+ } finally {
193
+ setIsDeleting(false);
194
+ }
195
+ }, [entryToDelete, deleteEntry]);
196
+
197
+ const handleDeleteModalClose = useCallback(
198
+ (open: boolean) => {
199
+ if (!open && !isDeleting) {
200
+ setDeleteModalOpen(false);
201
+ setEntryToDelete(null);
202
+ setDeleteError(null);
203
+ }
204
+ },
205
+ [isDeleting]
206
+ );
207
+
208
+ const clearFilters = useCallback(() => {
209
+ setSearchQuery("");
210
+ setDebouncedSearch("");
211
+ setSelectedStatus("all");
212
+ setCurrentPage(0);
213
+ }, []);
214
+
215
+ if (contentType === undefined || entriesResult === undefined) {
216
+ return (
217
+ <div className="space-y-6 p-6">
218
+ <div className="flex flex-col items-center justify-center py-12">
219
+ <div className="size-8 animate-spin rounded-full border-2 border-muted border-t-primary" />
220
+ <p className="mt-4 text-sm text-muted-foreground">Loading entries...</p>
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ if (contentType === null) {
227
+ return (
228
+ <div className="space-y-6 p-6">
229
+ <CmsEmptyState
230
+ icon={<FileText className="size-6" />}
231
+ title="Content Type Not Found"
232
+ description="The content type you're looking for doesn't exist or has been deleted."
233
+ action={{
234
+ label: "Back to Content Types",
235
+ onClick: () => navigation.navigate("/content-types"),
236
+ }}
237
+ />
238
+ </div>
239
+ );
240
+ }
241
+
242
+ const hasFilters = searchQuery || selectedStatus !== "all";
243
+
244
+ return (
245
+ <div className="space-y-6 p-6">
246
+ <CmsPageHeader
247
+ title={contentType.displayName}
248
+ description={contentType.description}
249
+ actions={
250
+ canCreate("contentEntries") && (
251
+ <CmsButton onClick={() => navigation.navigateToNewEntry(contentTypeId)}>
252
+ <Plus className="size-4" />
253
+ Create {contentType.displayName}
254
+ </CmsButton>
255
+ )
256
+ }
257
+ />
258
+
259
+ <CmsToolbar
260
+ left={
261
+ <div className="flex items-center gap-3">
262
+ <div className="relative">
263
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
264
+ <Input
265
+ type="search"
266
+ placeholder="Search entries..."
267
+ value={searchQuery}
268
+ onChange={(e) => setSearchQuery(e.target.value)}
269
+ className="w-64 pl-9"
270
+ />
271
+ </div>
272
+ <Select
273
+ value={selectedStatus}
274
+ onValueChange={(value) => {
275
+ setSelectedStatus(value as ContentStatus | "all");
276
+ setCurrentPage(0);
277
+ }}
278
+ >
279
+ <SelectTrigger className="w-36">
280
+ <SelectValue placeholder="All Statuses" />
281
+ </SelectTrigger>
282
+ <SelectContent>
283
+ <SelectItem value="all">All Statuses</SelectItem>
284
+ <SelectItem value="draft">Draft</SelectItem>
285
+ <SelectItem value="published">Published</SelectItem>
286
+ <SelectItem value="scheduled">Scheduled</SelectItem>
287
+ <SelectItem value="archived">Archived</SelectItem>
288
+ </SelectContent>
289
+ </Select>
290
+ </div>
291
+ }
292
+ right={
293
+ <span className="text-sm text-muted-foreground">
294
+ {sortedEntries.length} {sortedEntries.length === 1 ? "entry" : "entries"}
295
+ </span>
296
+ }
297
+ />
298
+
299
+ {sortedEntries.length === 0 ? (
300
+ <CmsEmptyState
301
+ icon={<FileText className="size-6" />}
302
+ title={hasFilters ? "No matching entries" : `No ${contentType.displayName} entries yet`}
303
+ description={
304
+ hasFilters
305
+ ? "Try adjusting your search or filter criteria."
306
+ : `Click "Create ${contentType.displayName}" to add your first entry.`
307
+ }
308
+ action={
309
+ hasFilters
310
+ ? { label: "Clear Filters", onClick: clearFilters, variant: "secondary" }
311
+ : undefined
312
+ }
313
+ />
314
+ ) : (
315
+ <>
316
+ <div className="rounded-lg border bg-card">
317
+ <table className="w-full">
318
+ <thead>
319
+ <tr className="border-b">
320
+ <th className="p-3 text-left">
321
+ <button
322
+ className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
323
+ onClick={() => handleSort("title")}
324
+ >
325
+ Title
326
+ {getSortIcon("title")}
327
+ </button>
328
+ </th>
329
+ <th className="p-3 text-left">
330
+ <button
331
+ className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
332
+ onClick={() => handleSort("status")}
333
+ >
334
+ Status
335
+ {getSortIcon("status")}
336
+ </button>
337
+ </th>
338
+ <th className="p-3 text-left">
339
+ <button
340
+ className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
341
+ onClick={() => handleSort("updatedAt")}
342
+ >
343
+ Updated
344
+ {getSortIcon("updatedAt")}
345
+ </button>
346
+ </th>
347
+ <th className="p-3 text-left text-sm font-medium text-muted-foreground">
348
+ Actions
349
+ </th>
350
+ </tr>
351
+ </thead>
352
+ <tbody>
353
+ {paginatedEntries.map((entry) => (
354
+ <tr
355
+ key={entry._id}
356
+ className="border-b last:border-0 transition-colors hover:bg-muted/50"
357
+ >
358
+ <td className="p-3">
359
+ <button
360
+ type="button"
361
+ onClick={() => navigation.navigateToEntry(entry._id)}
362
+ className="text-left font-medium text-foreground hover:text-primary hover:underline"
363
+ >
364
+ {getEntryTitle(entry)}
365
+ </button>
366
+ <p className="text-xs text-muted-foreground">{entry.slug}</p>
367
+ </td>
368
+ <td className="p-3">
369
+ <CmsStatusBadge status={entry.status as ContentStatus} />
370
+ </td>
371
+ <td className="p-3 text-sm text-muted-foreground">
372
+ {formatDate(entry.lastPublishedAt ?? entry._creationTime)}
373
+ </td>
374
+ <td className="p-3">
375
+ <div className="flex items-center gap-2">
376
+ <CmsButton
377
+ variant="outline"
378
+ size="sm"
379
+ onClick={() => navigation.navigateToEntry(entry._id)}
380
+ >
381
+ {canUpdate("contentEntries") ? "Edit" : "View"}
382
+ </CmsButton>
383
+ {canDelete("contentEntries") && (
384
+ <CmsButton
385
+ variant="danger"
386
+ size="sm"
387
+ onClick={() => handleDeleteClick(entry)}
388
+ >
389
+ Delete
390
+ </CmsButton>
391
+ )}
392
+ </div>
393
+ </td>
394
+ </tr>
395
+ ))}
396
+ </tbody>
397
+ </table>
398
+ </div>
399
+
400
+ {totalPages > 1 && (
401
+ <div className="flex items-center justify-center gap-2">
402
+ <CmsButton
403
+ variant="outline"
404
+ size="sm"
405
+ onClick={() => setCurrentPage(0)}
406
+ disabled={currentPage === 0}
407
+ >
408
+ First
409
+ </CmsButton>
410
+ <CmsButton
411
+ variant="outline"
412
+ size="sm"
413
+ onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
414
+ disabled={currentPage === 0}
415
+ >
416
+ Previous
417
+ </CmsButton>
418
+ <span className="px-3 text-sm text-muted-foreground">
419
+ Page {currentPage + 1} of {totalPages}
420
+ </span>
421
+ <CmsButton
422
+ variant="outline"
423
+ size="sm"
424
+ onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
425
+ disabled={currentPage >= totalPages - 1}
426
+ >
427
+ Next
428
+ </CmsButton>
429
+ <CmsButton
430
+ variant="outline"
431
+ size="sm"
432
+ onClick={() => setCurrentPage(totalPages - 1)}
433
+ disabled={currentPage >= totalPages - 1}
434
+ >
435
+ Last
436
+ </CmsButton>
437
+ </div>
438
+ )}
439
+ </>
440
+ )}
441
+
442
+ {sortedEntries.length > 0 && (
443
+ <p className="text-center text-sm text-muted-foreground">
444
+ Showing {paginatedEntries.length} of {sortedEntries.length}{" "}
445
+ {sortedEntries.length === 1 ? "entry" : "entries"}
446
+ </p>
447
+ )}
448
+
449
+ <CmsConfirmDialog
450
+ open={deleteModalOpen}
451
+ onOpenChange={handleDeleteModalClose}
452
+ title="Delete Entry"
453
+ description={
454
+ entryToDelete
455
+ ? `Are you sure you want to delete "${entryToDelete.title}"? It will be moved to the trash and can be restored within the retention period.`
456
+ : "Are you sure you want to delete this entry?"
457
+ }
458
+ confirmLabel="Delete"
459
+ variant="danger"
460
+ onConfirm={handleDeleteConfirm}
461
+ isLoading={isDeleting}
462
+ error={deleteError}
463
+ />
464
+ </div>
465
+ );
466
+ }
@@ -273,7 +273,7 @@ export function ContentTypesPage({ api, navigation }: ContentTypesPageProps) {
273
273
  {contentType.singleton && (
274
274
  <Badge
275
275
  variant="secondary"
276
- className="border-amber-200 bg-amber-50 text-xs font-normal text-amber-700"
276
+ className="border-diff-modified-border bg-diff-modified-bg text-xs font-normal text-diff-modified-foreground"
277
277
  >
278
278
  Singleton
279
279
  </Badge>
@@ -422,7 +422,7 @@ export function ContentTypesPage({ api, navigation }: ContentTypesPageProps) {
422
422
  className={cn(
423
423
  "text-xs font-normal",
424
424
  contentType.isActive &&
425
- "border-emerald-200 bg-emerald-50 text-emerald-700",
425
+ "border-diff-added-border bg-diff-added-bg text-diff-added-foreground",
426
426
  )}
427
427
  >
428
428
  {contentType.isActive ? "Active" : "Inactive"}
@@ -430,7 +430,7 @@ export function ContentTypesPage({ api, navigation }: ContentTypesPageProps) {
430
430
  {contentType.singleton && (
431
431
  <Badge
432
432
  variant="secondary"
433
- className="border-amber-200 bg-amber-50 text-xs font-normal text-amber-700"
433
+ className="border-diff-modified-border bg-diff-modified-bg text-xs font-normal text-diff-modified-foreground"
434
434
  >
435
435
  Singleton
436
436
  </Badge>
@@ -15,6 +15,7 @@ import {
15
15
  } from "~/components/ui/card";
16
16
  import { Skeleton } from "~/components/ui/skeleton";
17
17
  import { CmsPageHeader } from "~/components/cmsds";
18
+ import { SchemaDriftWarning } from "~/components/SchemaDriftWarning";
18
19
  import { FileText, Image, Layers, Settings, TrendingUp } from "lucide-react";
19
20
  import type { AdminNavigation } from "~/lib/navigation";
20
21
  import type { CmsAdminApi } from "~/embed/contexts/ApiContext";
@@ -36,6 +37,8 @@ export function DashboardPage({ api, navigation }: DashboardPageProps) {
36
37
  description="Welcome to Convex CMS Admin. Manage your content, media, and publishing workflows."
37
38
  />
38
39
 
40
+ <SchemaDriftWarning api={api} />
41
+
39
42
  <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
40
43
  <DashboardCard
41
44
  title="Content Entries"
@@ -135,7 +135,7 @@ export function SettingsPage({
135
135
  const adminConfig = useAdminConfig();
136
136
 
137
137
  const isConfigured = useMemo(() => {
138
- return typeof api.getSettings === "function";
138
+ return api.getSettings != null;
139
139
  }, [api]);
140
140
 
141
141
  const settings = useQuery(
@@ -371,7 +371,7 @@ export function SettingsPage({
371
371
  {feedbackStatus === "saved" && (
372
372
  <Badge
373
373
  variant="secondary"
374
- className="gap-1 bg-emerald-100 text-emerald-700"
374
+ className="gap-1 bg-diff-added-bg text-diff-added-foreground"
375
375
  >
376
376
  <Check className="size-3" />
377
377
  Settings saved successfully
@@ -517,9 +517,9 @@ export function SettingsPage({
517
517
  {canEdit && api.resetSettings && (
518
518
  <CmsSurface
519
519
  elevation="base"
520
- className="border-red-200 p-6 dark:border-red-900"
520
+ className="border-destructive/50 p-6"
521
521
  >
522
- <h2 className="mb-4 text-lg font-semibold text-red-600">
522
+ <h2 className="mb-4 text-lg font-semibold text-destructive">
523
523
  Danger Zone
524
524
  </h2>
525
525
  <div className="flex items-center justify-between">
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  export { ContentPage, type ContentPageProps } from "./ContentPage";
9
+ export { ContentTypeEntriesPage, type ContentTypeEntriesPageProps } from "./ContentTypeEntriesPage";
9
10
  export { ContentTypesPage, type ContentTypesPageProps } from "./ContentTypesPage";
10
11
  export { DashboardPage } from "./DashboardPage";
11
12
  export { MediaPage, type MediaPageProps } from "./MediaPage";
@@ -185,40 +185,40 @@ function ConvexProviderWrapper({
185
185
  if (!convex) {
186
186
  return (
187
187
  <div className="flex min-h-screen items-center justify-center bg-background p-6">
188
- <div className="max-w-lg space-y-4 rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
189
- <h2 className="text-xl font-semibold text-amber-900">
188
+ <div className="diff-modified max-w-lg space-y-4 rounded-lg border p-6 text-center">
189
+ <h2 className="text-xl font-semibold text-diff-modified">
190
190
  Convex Configuration Required
191
191
  </h2>
192
- <p className="text-sm text-amber-800">
192
+ <p className="text-sm text-diff-modified-foreground">
193
193
  Please provide a Convex deployment URL to connect to your backend.
194
194
  </p>
195
- <div className="space-y-2 text-left text-sm text-amber-700">
195
+ <div className="space-y-2 text-left text-sm text-diff-modified-foreground">
196
196
  <p className="font-medium">Options:</p>
197
197
  <ul className="list-inside list-disc space-y-1">
198
198
  <li>
199
199
  Run with URL:{" "}
200
- <code className="rounded bg-amber-100 px-1">
200
+ <code className="rounded bg-diff-modified-bg/50 px-1">
201
201
  npx convex-cms admin --url YOUR_URL
202
202
  </code>
203
203
  </li>
204
204
  <li>
205
205
  Set environment variable:{" "}
206
- <code className="rounded bg-amber-100 px-1">
206
+ <code className="rounded bg-diff-modified-bg/50 px-1">
207
207
  CONVEX_URL=YOUR_URL
208
208
  </code>
209
209
  </li>
210
210
  <li>
211
211
  Add to{" "}
212
- <code className="rounded bg-amber-100 px-1">.env.local</code>:{" "}
213
- <code className="rounded bg-amber-100 px-1">
212
+ <code className="rounded bg-diff-modified-bg/50 px-1">.env.local</code>:{" "}
213
+ <code className="rounded bg-diff-modified-bg/50 px-1">
214
214
  CONVEX_URL=YOUR_URL
215
215
  </code>
216
216
  </li>
217
217
  </ul>
218
218
  </div>
219
- <p className="text-sm text-amber-700">
219
+ <p className="text-sm text-diff-modified-foreground">
220
220
  Run{" "}
221
- <code className="rounded bg-amber-100 px-1">npx convex dev</code> to
221
+ <code className="rounded bg-diff-modified-bg/50 px-1">npx convex dev</code> to
222
222
  start Convex and get your URL.
223
223
  </p>
224
224
  </div>
@@ -21,21 +21,47 @@
21
21
  }
22
22
 
23
23
  @layer utilities {
24
- /* Status badge colors */
24
+ /* Status badge colors - using semantic variables */
25
25
  .status-draft {
26
- @apply bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300;
26
+ @apply bg-muted text-muted-foreground;
27
27
  }
28
28
 
29
29
  .status-published {
30
- @apply bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400;
30
+ @apply bg-diff-added-bg text-diff-added-foreground;
31
31
  }
32
32
 
33
33
  .status-scheduled {
34
- @apply bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400;
34
+ @apply bg-info-bg text-info-foreground;
35
35
  }
36
36
 
37
37
  .status-archived {
38
- @apply bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400;
38
+ @apply bg-diff-modified-bg text-diff-modified-foreground;
39
+ }
40
+
41
+ /* Diff change indicators */
42
+ .diff-added {
43
+ @apply bg-diff-added-bg border-diff-added-border text-diff-added-foreground;
44
+ }
45
+
46
+ .diff-removed {
47
+ @apply bg-diff-removed-bg border-diff-removed-border text-diff-removed-foreground;
48
+ }
49
+
50
+ .diff-modified {
51
+ @apply bg-diff-modified-bg border-diff-modified-border text-diff-modified-foreground;
52
+ }
53
+
54
+ /* Icon badges for diff */
55
+ .diff-icon-added {
56
+ @apply bg-diff-added/20 text-diff-added;
57
+ }
58
+
59
+ .diff-icon-removed {
60
+ @apply bg-diff-removed/20 text-diff-removed;
61
+ }
62
+
63
+ .diff-icon-modified {
64
+ @apply bg-diff-modified/20 text-diff-modified;
39
65
  }
40
66
 
41
67
  /* Surface elevations */
@@ -71,4 +71,29 @@
71
71
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
72
72
  --color-sidebar-border: var(--sidebar-border);
73
73
  --color-sidebar-ring: var(--sidebar-ring);
74
+
75
+ /* Diff colors */
76
+ --color-diff-added: var(--diff-added);
77
+ --color-diff-added-bg: var(--diff-added-bg);
78
+ --color-diff-added-border: var(--diff-added-border);
79
+ --color-diff-added-foreground: var(--diff-added-foreground);
80
+
81
+ --color-diff-removed: var(--diff-removed);
82
+ --color-diff-removed-bg: var(--diff-removed-bg);
83
+ --color-diff-removed-border: var(--diff-removed-border);
84
+ --color-diff-removed-foreground: var(--diff-removed-foreground);
85
+
86
+ --color-diff-modified: var(--diff-modified);
87
+ --color-diff-modified-bg: var(--diff-modified-bg);
88
+ --color-diff-modified-border: var(--diff-modified-border);
89
+ --color-diff-modified-foreground: var(--diff-modified-foreground);
90
+
91
+ /* Semantic states */
92
+ --color-success: var(--success);
93
+ --color-success-foreground: var(--success-foreground);
94
+ --color-warning: var(--warning);
95
+ --color-warning-foreground: var(--warning-foreground);
96
+ --color-info: var(--info);
97
+ --color-info-bg: var(--info-bg);
98
+ --color-info-foreground: var(--info-foreground);
74
99
  }