@tower_74/cms-app 0.1.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 (189) hide show
  1. package/README.md +102 -0
  2. package/package.json +49 -0
  3. package/src/components/AppContent.vue +21 -0
  4. package/src/components/AppLogoIcon.vue +24 -0
  5. package/src/components/AppShell.vue +37 -0
  6. package/src/components/AppearanceTabs.vue +37 -0
  7. package/src/components/AuthBar.vue +58 -0
  8. package/src/components/BlockEditor.vue +95 -0
  9. package/src/components/DeleteUser.vue +87 -0
  10. package/src/components/FieldBuilder.vue +105 -0
  11. package/src/components/Heading.vue +20 -0
  12. package/src/components/HeadingSmall.vue +17 -0
  13. package/src/components/Icon.vue +30 -0
  14. package/src/components/InputError.vue +13 -0
  15. package/src/components/MenuItemsEditor.vue +59 -0
  16. package/src/components/NavUser.vue +30 -0
  17. package/src/components/Pagination.vue +28 -0
  18. package/src/components/PlaceholderPattern.vue +16 -0
  19. package/src/components/Seo.vue +28 -0
  20. package/src/components/TextLink.vue +24 -0
  21. package/src/components/UserInfo.vue +34 -0
  22. package/src/components/UserMenuContent.vue +37 -0
  23. package/src/components/commerce/OptionsEditor.vue +55 -0
  24. package/src/components/commerce/VariantsEditor.vue +71 -0
  25. package/src/components/ui/avatar/Avatar.vue +24 -0
  26. package/src/components/ui/avatar/AvatarFallback.vue +11 -0
  27. package/src/components/ui/avatar/AvatarImage.vue +9 -0
  28. package/src/components/ui/avatar/index.ts +24 -0
  29. package/src/components/ui/breadcrumb/Breadcrumb.vue +13 -0
  30. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +18 -0
  31. package/src/components/ui/breadcrumb/BreadcrumbItem.vue +14 -0
  32. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +15 -0
  33. package/src/components/ui/breadcrumb/BreadcrumbList.vue +14 -0
  34. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +14 -0
  35. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +17 -0
  36. package/src/components/ui/breadcrumb/index.ts +7 -0
  37. package/src/components/ui/button/Button.vue +22 -0
  38. package/src/components/ui/button/index.ts +31 -0
  39. package/src/components/ui/card/Card.vue +14 -0
  40. package/src/components/ui/card/CardContent.vue +14 -0
  41. package/src/components/ui/card/CardDescription.vue +14 -0
  42. package/src/components/ui/card/CardFooter.vue +14 -0
  43. package/src/components/ui/card/CardHeader.vue +14 -0
  44. package/src/components/ui/card/CardTitle.vue +14 -0
  45. package/src/components/ui/card/index.ts +6 -0
  46. package/src/components/ui/checkbox/Checkbox.vue +36 -0
  47. package/src/components/ui/checkbox/index.ts +1 -0
  48. package/src/components/ui/collapsible/Collapsible.vue +15 -0
  49. package/src/components/ui/collapsible/CollapsibleContent.vue +14 -0
  50. package/src/components/ui/collapsible/CollapsibleTrigger.vue +11 -0
  51. package/src/components/ui/collapsible/index.ts +3 -0
  52. package/src/components/ui/dialog/Dialog.vue +14 -0
  53. package/src/components/ui/dialog/DialogClose.vue +11 -0
  54. package/src/components/ui/dialog/DialogContent.vue +51 -0
  55. package/src/components/ui/dialog/DialogDescription.vue +21 -0
  56. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  57. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  58. package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
  59. package/src/components/ui/dialog/DialogTitle.vue +21 -0
  60. package/src/components/ui/dialog/DialogTrigger.vue +11 -0
  61. package/src/components/ui/dialog/index.ts +9 -0
  62. package/src/components/ui/dropdown-menu/DropdownMenu.vue +14 -0
  63. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +42 -0
  64. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
  65. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +11 -0
  66. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
  67. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +21 -0
  68. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +14 -0
  69. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +43 -0
  70. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +21 -0
  71. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +14 -0
  72. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +14 -0
  73. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +30 -0
  74. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
  75. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +13 -0
  76. package/src/components/ui/dropdown-menu/index.ts +16 -0
  77. package/src/components/ui/input/Input.vue +32 -0
  78. package/src/components/ui/input/index.ts +1 -0
  79. package/src/components/ui/label/Label.vue +22 -0
  80. package/src/components/ui/label/index.ts +1 -0
  81. package/src/components/ui/navigation-menu/NavigationMenu.vue +25 -0
  82. package/src/components/ui/navigation-menu/NavigationMenuContent.vue +31 -0
  83. package/src/components/ui/navigation-menu/NavigationMenuIndicator.vue +29 -0
  84. package/src/components/ui/navigation-menu/NavigationMenuItem.vue +11 -0
  85. package/src/components/ui/navigation-menu/NavigationMenuLink.vue +14 -0
  86. package/src/components/ui/navigation-menu/NavigationMenuList.vue +21 -0
  87. package/src/components/ui/navigation-menu/NavigationMenuTrigger.vue +24 -0
  88. package/src/components/ui/navigation-menu/NavigationMenuViewport.vue +29 -0
  89. package/src/components/ui/navigation-menu/index.ts +14 -0
  90. package/src/components/ui/separator/Separator.vue +31 -0
  91. package/src/components/ui/separator/index.ts +1 -0
  92. package/src/components/ui/sheet/Sheet.vue +14 -0
  93. package/src/components/ui/sheet/SheetClose.vue +11 -0
  94. package/src/components/ui/sheet/SheetContent.vue +53 -0
  95. package/src/components/ui/sheet/SheetDescription.vue +19 -0
  96. package/src/components/ui/sheet/SheetFooter.vue +12 -0
  97. package/src/components/ui/sheet/SheetHeader.vue +12 -0
  98. package/src/components/ui/sheet/SheetTitle.vue +19 -0
  99. package/src/components/ui/sheet/SheetTrigger.vue +11 -0
  100. package/src/components/ui/sheet/index.ts +29 -0
  101. package/src/components/ui/sidebar/Sidebar.vue +99 -0
  102. package/src/components/ui/sidebar/SidebarContent.vue +17 -0
  103. package/src/components/ui/sidebar/SidebarFooter.vue +14 -0
  104. package/src/components/ui/sidebar/SidebarGroup.vue +14 -0
  105. package/src/components/ui/sidebar/SidebarGroupAction.vue +31 -0
  106. package/src/components/ui/sidebar/SidebarGroupContent.vue +14 -0
  107. package/src/components/ui/sidebar/SidebarGroupLabel.vue +29 -0
  108. package/src/components/ui/sidebar/SidebarHeader.vue +14 -0
  109. package/src/components/ui/sidebar/SidebarInput.vue +15 -0
  110. package/src/components/ui/sidebar/SidebarInset.vue +22 -0
  111. package/src/components/ui/sidebar/SidebarMenu.vue +14 -0
  112. package/src/components/ui/sidebar/SidebarMenuAction.vue +41 -0
  113. package/src/components/ui/sidebar/SidebarMenuBadge.vue +27 -0
  114. package/src/components/ui/sidebar/SidebarMenuButton.vue +52 -0
  115. package/src/components/ui/sidebar/SidebarMenuButtonChild.vue +33 -0
  116. package/src/components/ui/sidebar/SidebarMenuItem.vue +14 -0
  117. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +22 -0
  118. package/src/components/ui/sidebar/SidebarMenuSub.vue +23 -0
  119. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +42 -0
  120. package/src/components/ui/sidebar/SidebarMenuSubItem.vue +7 -0
  121. package/src/components/ui/sidebar/SidebarProvider.vue +89 -0
  122. package/src/components/ui/sidebar/SidebarRail.vue +34 -0
  123. package/src/components/ui/sidebar/SidebarSeparator.vue +15 -0
  124. package/src/components/ui/sidebar/SidebarTrigger.vue +20 -0
  125. package/src/components/ui/sidebar/index.ts +51 -0
  126. package/src/components/ui/sidebar/utils.ts +19 -0
  127. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  128. package/src/components/ui/skeleton/index.ts +1 -0
  129. package/src/components/ui/tooltip/Tooltip.vue +14 -0
  130. package/src/components/ui/tooltip/TooltipContent.vue +39 -0
  131. package/src/components/ui/tooltip/TooltipProvider.vue +11 -0
  132. package/src/components/ui/tooltip/TooltipTrigger.vue +11 -0
  133. package/src/components/ui/tooltip/index.ts +4 -0
  134. package/src/composables/useAppearance.ts +53 -0
  135. package/src/composables/useInitials.ts +14 -0
  136. package/src/index.ts +22 -0
  137. package/src/layouts/AdminLayout.vue +170 -0
  138. package/src/layouts/AuthLayout.vue +14 -0
  139. package/src/layouts/PublicLayout.vue +53 -0
  140. package/src/layouts/auth/AuthCardLayout.vue +36 -0
  141. package/src/layouts/auth/AuthSimpleLayout.vue +31 -0
  142. package/src/layouts/auth/AuthSplitLayout.vue +40 -0
  143. package/src/layouts/settings/Layout.vue +56 -0
  144. package/src/lib/utils.ts +6 -0
  145. package/src/pages/Admin/Appearance/Theme.vue +58 -0
  146. package/src/pages/Admin/Appearance/Widgets.vue +48 -0
  147. package/src/pages/Admin/Commerce/Orders/Index.vue +80 -0
  148. package/src/pages/Admin/Commerce/Orders/Show.vue +200 -0
  149. package/src/pages/Admin/Commerce/Products/Edit.vue +167 -0
  150. package/src/pages/Admin/Commerce/Products/Index.vue +65 -0
  151. package/src/pages/Admin/Content/Edit.vue +170 -0
  152. package/src/pages/Admin/Content/Index.vue +88 -0
  153. package/src/pages/Admin/Content/Preview.vue +25 -0
  154. package/src/pages/Admin/Dashboard.vue +26 -0
  155. package/src/pages/Admin/Forms/Edit.vue +98 -0
  156. package/src/pages/Admin/Forms/Index.vue +68 -0
  157. package/src/pages/Admin/Forms/Submissions/Index.vue +68 -0
  158. package/src/pages/Admin/Forms/Submissions/Show.vue +47 -0
  159. package/src/pages/Admin/Media/Index.vue +75 -0
  160. package/src/pages/Admin/Menus/Create.vue +37 -0
  161. package/src/pages/Admin/Menus/Edit.vue +54 -0
  162. package/src/pages/Admin/Menus/Index.vue +52 -0
  163. package/src/pages/Admin/Settings/Index.vue +184 -0
  164. package/src/pages/Admin/Taxonomy/Edit.vue +83 -0
  165. package/src/pages/Admin/Taxonomy/Index.vue +68 -0
  166. package/src/pages/Admin/Users/Edit.vue +82 -0
  167. package/src/pages/Admin/Users/Index.vue +74 -0
  168. package/src/pages/Public/Cart/Index.vue +108 -0
  169. package/src/pages/Public/Checkout/Confirmation.vue +110 -0
  170. package/src/pages/Public/Checkout/Index.vue +174 -0
  171. package/src/pages/Public/Index.vue +54 -0
  172. package/src/pages/Public/Shop/Index.vue +39 -0
  173. package/src/pages/Public/Shop/Show.vue +46 -0
  174. package/src/pages/Public/Show.vue +41 -0
  175. package/src/pages/Setup/Complete.vue +53 -0
  176. package/src/pages/Setup/Index.vue +85 -0
  177. package/src/pages/Welcome.vue +787 -0
  178. package/src/pages/auth/ConfirmPassword.vue +53 -0
  179. package/src/pages/auth/ForgotPassword.vue +54 -0
  180. package/src/pages/auth/Login.vue +91 -0
  181. package/src/pages/auth/Register.vue +83 -0
  182. package/src/pages/auth/ResetPassword.vue +81 -0
  183. package/src/pages/auth/VerifyEmail.vue +36 -0
  184. package/src/pages/settings/Appearance.vue +23 -0
  185. package/src/pages/settings/Password.vue +120 -0
  186. package/src/pages/settings/Profile.vue +105 -0
  187. package/src/pages.ts +9 -0
  188. package/src/types/index.ts +42 -0
  189. package/src/types/ziggy.ts +12 -0
@@ -0,0 +1,167 @@
1
+ <script setup lang="ts">
2
+ import BlockEditor from '@/components/BlockEditor.vue';
3
+ import OptionsEditor from '@/components/commerce/OptionsEditor.vue';
4
+ import VariantsEditor from '@/components/commerce/VariantsEditor.vue';
5
+ import AdminLayout from '@/layouts/AdminLayout.vue';
6
+ import { Link, useForm } from '@inertiajs/vue3';
7
+ import { Button, FormBuilder, FormField, MediaPicker, type MediaItem, type MediaSelection } from '@tower_74/cms-ui';
8
+ import axios from 'axios';
9
+ import { computed, ref } from 'vue';
10
+
11
+ interface Option {
12
+ name: string;
13
+ values: string[];
14
+ }
15
+ interface Variant {
16
+ sku: string;
17
+ price: string;
18
+ stock: number | string;
19
+ options: Record<string, string>;
20
+ }
21
+ interface Block {
22
+ type: string;
23
+ data: Record<string, unknown>;
24
+ }
25
+ interface BlockDef {
26
+ type: string;
27
+ label: string;
28
+ fields: Array<Record<string, unknown>>;
29
+ defaults: Record<string, unknown>;
30
+ }
31
+ interface ProductData {
32
+ id: number;
33
+ name: string;
34
+ description: string | null;
35
+ status: string;
36
+ tax_class: string;
37
+ featured_media_id: number | null;
38
+ options: Option[];
39
+ variants: Variant[];
40
+ }
41
+
42
+ const props = defineProps<{
43
+ product: ProductData | null;
44
+ currencySymbol: string;
45
+ featuredMedia: MediaItem | null;
46
+ mediaItems: MediaItem[];
47
+ blocks: Block[];
48
+ blockRegistry: BlockDef[];
49
+ }>();
50
+
51
+ const isEdit = computed(() => props.product !== null);
52
+
53
+ const fields = [
54
+ { name: 'name', label: 'Name', required: true },
55
+ {
56
+ name: 'status',
57
+ label: 'Status',
58
+ type: 'select' as const,
59
+ options: [
60
+ { label: 'Draft', value: 'draft' },
61
+ { label: 'Published', value: 'published' },
62
+ ],
63
+ },
64
+ {
65
+ name: 'tax_class',
66
+ label: 'Tax class',
67
+ type: 'select' as const,
68
+ options: [
69
+ { label: 'Standard', value: 'standard' },
70
+ { label: 'Reduced', value: 'reduced' },
71
+ { label: 'Zero-rated', value: 'zero' },
72
+ ],
73
+ },
74
+ { name: 'description', label: 'Description', type: 'textarea' as const },
75
+ ];
76
+
77
+ const form = useForm({
78
+ name: props.product?.name ?? '',
79
+ status: props.product?.status ?? 'draft',
80
+ tax_class: props.product?.tax_class ?? 'standard',
81
+ description: props.product?.description ?? '',
82
+ featured_media_id: props.product?.featured_media_id ?? null,
83
+ options: (props.product?.options ?? []) as Option[],
84
+ variants: (props.product?.variants ?? []) as Variant[],
85
+ blocks: (props.blocks ?? []) as Block[],
86
+ });
87
+
88
+ const model = computed({
89
+ get: () => ({ name: form.name, status: form.status, tax_class: form.tax_class, description: form.description }),
90
+ set: (value: Record<string, unknown>) => {
91
+ form.name = (value.name as string) ?? '';
92
+ form.status = (value.status as string) ?? 'draft';
93
+ form.tax_class = (value.tax_class as string) ?? 'standard';
94
+ form.description = (value.description as string) ?? '';
95
+ },
96
+ });
97
+
98
+ const optionsModel = computed({ get: () => form.options, set: (value: Option[]) => (form.options = value) });
99
+ const variantsModel = computed({ get: () => form.variants, set: (value: Variant[]) => (form.variants = value) });
100
+ const blocksModel = computed({ get: () => form.blocks, set: (value: Block[]) => (form.blocks = value) });
101
+
102
+ // Shared media library for the featured-image picker and any block galleries.
103
+ const mediaItems = ref<MediaItem[]>([...props.mediaItems]);
104
+ const onUpload = async (file: File) => {
105
+ const data = new FormData();
106
+ data.append('file', file);
107
+ const { data: res } = await axios.post('/admin/media/upload', data);
108
+ mediaItems.value = [res.item, ...mediaItems.value];
109
+ };
110
+
111
+ const featured = ref<MediaSelection | null>(
112
+ props.featuredMedia ? { id: props.featuredMedia.id, src: props.featuredMedia.url, alt: props.featuredMedia.name ?? '' } : null,
113
+ );
114
+ const onFeatured = (value: MediaSelection | MediaSelection[] | null) => {
115
+ featured.value = (value as MediaSelection | null) ?? null;
116
+ form.featured_media_id = featured.value?.id ?? null;
117
+ };
118
+
119
+ const submit = () => {
120
+ if (isEdit.value && props.product) {
121
+ form.put(`/admin/commerce/products/${props.product.id}`);
122
+ } else {
123
+ form.post('/admin/commerce/products');
124
+ }
125
+ };
126
+ </script>
127
+
128
+ <template>
129
+ <AdminLayout>
130
+ <template #title>{{ isEdit ? 'Edit product' : 'New product' }}</template>
131
+
132
+ <form class="max-w-4xl" @submit.prevent="submit">
133
+ <div class="max-w-xl">
134
+ <FormBuilder v-model="model" :fields="fields" :errors="form.errors" />
135
+
136
+ <div class="mt-4">
137
+ <FormField label="Featured image" :error="form.errors.featured_media_id" help="Shown on the product card and page.">
138
+ <MediaPicker :model-value="featured" :items="mediaItems" @update:model-value="onFeatured" @upload="onUpload" />
139
+ </FormField>
140
+ </div>
141
+ </div>
142
+
143
+ <div class="mt-8">
144
+ <h3 class="mb-3 text-base font-semibold">Options</h3>
145
+ <OptionsEditor v-model="optionsModel" />
146
+ </div>
147
+
148
+ <div class="mt-8">
149
+ <h3 class="mb-3 text-base font-semibold">Variants</h3>
150
+ <p v-if="form.errors.variants" class="mb-2 text-sm text-danger">{{ form.errors.variants }}</p>
151
+ <VariantsEditor v-model="variantsModel" :options="form.options" :currency-symbol="currencySymbol" />
152
+ </div>
153
+
154
+ <div class="mt-8">
155
+ <h3 class="mb-3 text-base font-semibold">Product page blocks</h3>
156
+ <BlockEditor v-model="blocksModel" :registry="blockRegistry" :media-items="mediaItems" @upload="onUpload" />
157
+ </div>
158
+
159
+ <div class="mt-8 flex items-center gap-2">
160
+ <Button type="submit" :disabled="form.processing">
161
+ {{ isEdit ? 'Save changes' : 'Create product' }}
162
+ </Button>
163
+ <Link href="/admin/commerce/products"><Button variant="ghost" type="button">Cancel</Button></Link>
164
+ </div>
165
+ </form>
166
+ </AdminLayout>
167
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import Pagination from '@/components/Pagination.vue';
3
+ import AdminLayout from '@/layouts/AdminLayout.vue';
4
+ import { Link, router } from '@inertiajs/vue3';
5
+ import { Badge, Button, DataTable } from '@tower_74/cms-ui';
6
+
7
+ interface ProductRow {
8
+ id: number;
9
+ name: string;
10
+ status: string;
11
+ variants_count: number;
12
+ price_from: string | null;
13
+ }
14
+ interface PageLink {
15
+ url: string | null;
16
+ label: string;
17
+ active: boolean;
18
+ }
19
+
20
+ defineProps<{ products: { data: ProductRow[]; links: PageLink[] } }>();
21
+
22
+ const columns = [
23
+ { key: 'name', label: 'Name' },
24
+ { key: 'status', label: 'Status' },
25
+ { key: 'variants_count', label: 'Variants', align: 'right' as const },
26
+ { key: 'price_from', label: 'From', align: 'right' as const },
27
+ { key: 'actions', label: '', align: 'right' as const },
28
+ ];
29
+
30
+ const destroy = (row: ProductRow) => {
31
+ if (confirm(`Delete “${row.name}”?`)) {
32
+ router.delete(`/admin/commerce/products/${row.id}`, { preserveScroll: true });
33
+ }
34
+ };
35
+ </script>
36
+
37
+ <template>
38
+ <AdminLayout>
39
+ <template #title>Products</template>
40
+
41
+ <div class="mb-4 flex items-center justify-between">
42
+ <h2 class="text-lg font-semibold">Products</h2>
43
+ <Link href="/admin/commerce/products/create"><Button>New product</Button></Link>
44
+ </div>
45
+
46
+ <DataTable :columns="columns" :rows="products.data" empty="No products yet.">
47
+ <template #cell-status="{ value }">
48
+ <Badge :variant="value === 'published' ? 'success' : 'neutral'">{{ value }}</Badge>
49
+ </template>
50
+ <template #cell-price_from="{ value }">
51
+ <span class="text-muted">{{ value ?? '—' }}</span>
52
+ </template>
53
+ <template #cell-actions="{ row }">
54
+ <div class="flex justify-end gap-2">
55
+ <Link :href="`/admin/commerce/products/${(row as ProductRow).id}/edit`">
56
+ <Button variant="ghost" size="sm">Edit</Button>
57
+ </Link>
58
+ <Button variant="danger" size="sm" @click="destroy(row as ProductRow)">Delete</Button>
59
+ </div>
60
+ </template>
61
+ </DataTable>
62
+
63
+ <Pagination :links="products.links" />
64
+ </AdminLayout>
65
+ </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import BlockEditor from '@/components/BlockEditor.vue';
3
+ import AdminLayout from '@/layouts/AdminLayout.vue';
4
+ import { Link, useForm } from '@inertiajs/vue3';
5
+ import { Button, Checkbox, FormBuilder, FormField, MediaPicker, type MediaItem, type MediaSelection } from '@tower_74/cms-ui';
6
+ import axios from 'axios';
7
+ import { computed, ref } from 'vue';
8
+
9
+ interface PostData {
10
+ id: number;
11
+ title: string;
12
+ slug: string;
13
+ status: string;
14
+ excerpt: string | null;
15
+ content: string | null;
16
+ showTitle?: boolean;
17
+ featured_media_id?: number | null;
18
+ }
19
+ interface Block {
20
+ type: string;
21
+ data: Record<string, unknown>;
22
+ }
23
+ interface BlockDef {
24
+ type: string;
25
+ label: string;
26
+ fields: Array<Record<string, unknown>>;
27
+ defaults: Record<string, unknown>;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ type: { slug: string; label: string; singular: string };
32
+ post: PostData | null;
33
+ blocks: Block[];
34
+ blockRegistry: BlockDef[];
35
+ featuredMedia: MediaItem | null;
36
+ mediaItems: MediaItem[];
37
+ }>();
38
+
39
+ const isEdit = computed(() => props.post !== null);
40
+
41
+ // Shared library state for every picker on this screen (featured image + block galleries).
42
+ // Uploads go through one handler, which prepends the new item so it's instantly selectable.
43
+ const mediaItems = ref<MediaItem[]>([...props.mediaItems]);
44
+
45
+ const onUpload = async (file: File) => {
46
+ const data = new FormData();
47
+ data.append('file', file);
48
+ const { data: res } = await axios.post('/admin/media/upload', data);
49
+ mediaItems.value = [res.item, ...mediaItems.value];
50
+ };
51
+
52
+ // Featured image: the picker speaks MediaSelection ({id, src, alt}); the form persists the id.
53
+ const featured = ref<MediaSelection | null>(
54
+ props.featuredMedia ? { id: props.featuredMedia.id, src: props.featuredMedia.url, alt: props.featuredMedia.name ?? '' } : null,
55
+ );
56
+ const onFeatured = (value: MediaSelection | MediaSelection[] | null) => {
57
+ featured.value = (value as MediaSelection | null) ?? null;
58
+ form.featured_media_id = featured.value?.id ?? null;
59
+ };
60
+
61
+ const fields = [
62
+ { name: 'title', label: 'Title', required: true, placeholder: 'Title' },
63
+ { name: 'slug', label: 'Slug', help: 'Leave blank to auto-generate from the title.' },
64
+ {
65
+ name: 'status',
66
+ label: 'Status',
67
+ type: 'select' as const,
68
+ options: [
69
+ { label: 'Draft', value: 'draft' },
70
+ { label: 'Published', value: 'published' },
71
+ { label: 'Scheduled', value: 'scheduled' },
72
+ { label: 'Private', value: 'private' },
73
+ ],
74
+ },
75
+ { name: 'excerpt', label: 'Excerpt', type: 'textarea' as const },
76
+ { name: 'content', label: 'Content', type: 'textarea' as const },
77
+ ];
78
+
79
+ const isPage = computed(() => props.type.slug === 'page');
80
+
81
+ const form = useForm({
82
+ title: props.post?.title ?? '',
83
+ slug: props.post?.slug ?? '',
84
+ status: props.post?.status ?? 'draft',
85
+ excerpt: props.post?.excerpt ?? '',
86
+ content: props.post?.content ?? '',
87
+ featured_media_id: props.post?.featured_media_id ?? null,
88
+ blocks: (props.blocks ?? []) as Block[],
89
+ meta: { show_title: props.post?.showTitle ?? false },
90
+ });
91
+
92
+ const model = computed({
93
+ get: () => ({
94
+ title: form.title,
95
+ slug: form.slug,
96
+ status: form.status,
97
+ excerpt: form.excerpt,
98
+ content: form.content,
99
+ }),
100
+ set: (value: Record<string, unknown>) => {
101
+ form.title = (value.title as string) ?? '';
102
+ form.slug = (value.slug as string) ?? '';
103
+ form.status = (value.status as string) ?? 'draft';
104
+ form.excerpt = (value.excerpt as string) ?? '';
105
+ form.content = (value.content as string) ?? '';
106
+ },
107
+ });
108
+
109
+ const blocksModel = computed({
110
+ get: () => form.blocks,
111
+ set: (value: Block[]) => {
112
+ form.blocks = value;
113
+ },
114
+ });
115
+
116
+ const submit = () => {
117
+ if (isEdit.value && props.post) {
118
+ form.put(`/admin/content/${props.type.slug}/${props.post.id}`);
119
+ } else {
120
+ form.post(`/admin/content/${props.type.slug}`);
121
+ }
122
+ };
123
+ </script>
124
+
125
+ <template>
126
+ <AdminLayout>
127
+ <template #title>{{ isEdit ? 'Edit' : 'New' }} {{ type.singular.toLowerCase() }}</template>
128
+
129
+ <form class="max-w-3xl" @submit.prevent="submit">
130
+ <FormBuilder v-model="model" :fields="fields" :errors="form.errors" />
131
+
132
+ <div class="mt-4">
133
+ <FormField
134
+ label="Featured image"
135
+ :error="form.errors.featured_media_id"
136
+ help="Used for social/SEO previews and at the top of articles."
137
+ >
138
+ <MediaPicker :model-value="featured" :items="mediaItems" @update:model-value="onFeatured" @upload="onUpload" />
139
+ </FormField>
140
+ </div>
141
+
142
+ <div v-if="isPage" class="mt-4">
143
+ <Checkbox v-model="form.meta.show_title" label="Show the page title on the public site" />
144
+ <p class="mt-1 text-xs text-muted">Ignored when the page starts with a hero or page-header block (that already shows a heading).</p>
145
+ </div>
146
+
147
+ <div class="mt-8">
148
+ <div class="mb-3 flex items-center justify-between">
149
+ <h3 class="text-base font-semibold">Page blocks</h3>
150
+ <div class="flex items-center gap-3 text-sm">
151
+ <Link :href="route('admin.media.index')" class="text-muted underline hover:text-text">Manage media</Link>
152
+ <Link v-if="isEdit && post" :href="`/admin/content/${type.slug}/${post.id}/preview`" class="text-primary underline">
153
+ Preview
154
+ </Link>
155
+ </div>
156
+ </div>
157
+ <BlockEditor v-model="blocksModel" :registry="blockRegistry" :media-items="mediaItems" @upload="onUpload" />
158
+ </div>
159
+
160
+ <div class="mt-8 flex items-center gap-2">
161
+ <Button type="submit" :disabled="form.processing">
162
+ {{ isEdit ? 'Save changes' : `Create ${type.singular.toLowerCase()}` }}
163
+ </Button>
164
+ <Link :href="`/admin/content/${type.slug}`">
165
+ <Button variant="ghost" type="button">Cancel</Button>
166
+ </Link>
167
+ </div>
168
+ </form>
169
+ </AdminLayout>
170
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import Pagination from '@/components/Pagination.vue';
3
+ import AdminLayout from '@/layouts/AdminLayout.vue';
4
+ import { Link, router } from '@inertiajs/vue3';
5
+ import { Badge, Button, DataTable } from '@tower_74/cms-ui';
6
+
7
+ interface PageLink {
8
+ url: string | null;
9
+ label: string;
10
+ active: boolean;
11
+ }
12
+
13
+ interface PostRow {
14
+ id: number;
15
+ title: string;
16
+ slug: string;
17
+ status: string;
18
+ author: string | null;
19
+ updated_at: string | null;
20
+ }
21
+
22
+ const props = defineProps<{
23
+ type: { slug: string; label: string; singular: string };
24
+ posts: { data: PostRow[]; total: number; links: PageLink[] };
25
+ }>();
26
+
27
+ const columns = [
28
+ { key: 'title', label: 'Title' },
29
+ { key: 'status', label: 'Status' },
30
+ { key: 'author', label: 'Author' },
31
+ { key: 'updated_at', label: 'Updated' },
32
+ { key: 'actions', label: '', align: 'right' as const },
33
+ ];
34
+
35
+ type BadgeVariant = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
36
+
37
+ const statusVariant = (status: string): BadgeVariant =>
38
+ (
39
+ ({
40
+ published: 'success',
41
+ draft: 'neutral',
42
+ scheduled: 'info',
43
+ private: 'warning',
44
+ trash: 'danger',
45
+ }) as Record<string, BadgeVariant>
46
+ )[status] ?? 'neutral';
47
+
48
+ const destroy = (row: PostRow) => {
49
+ if (confirm(`Delete “${row.title}”?`)) {
50
+ router.delete(`/admin/content/${props.type.slug}/${row.id}`, { preserveScroll: true });
51
+ }
52
+ };
53
+ </script>
54
+
55
+ <template>
56
+ <AdminLayout>
57
+ <template #title>{{ type.label }}</template>
58
+
59
+ <div class="mb-4 flex items-center justify-between">
60
+ <h2 class="text-lg font-semibold">{{ type.label }}</h2>
61
+ <Link :href="`/admin/content/${type.slug}/create`">
62
+ <Button>New {{ type.singular.toLowerCase() }}</Button>
63
+ </Link>
64
+ </div>
65
+
66
+ <DataTable :columns="columns" :rows="posts.data" :empty="`No ${type.label.toLowerCase()} yet.`">
67
+ <template #cell-status="{ value }">
68
+ <Badge :variant="statusVariant(value as string)">{{ value }}</Badge>
69
+ </template>
70
+ <template #cell-author="{ value }">
71
+ <span class="text-muted">{{ value ?? '—' }}</span>
72
+ </template>
73
+ <template #cell-updated_at="{ value }">
74
+ <span class="text-muted">{{ value ?? '—' }}</span>
75
+ </template>
76
+ <template #cell-actions="{ row }">
77
+ <div class="flex justify-end gap-2">
78
+ <Link :href="`/admin/content/${type.slug}/${(row as PostRow).id}/edit`">
79
+ <Button variant="ghost" size="sm">Edit</Button>
80
+ </Link>
81
+ <Button variant="danger" size="sm" @click="destroy(row as PostRow)">Delete</Button>
82
+ </div>
83
+ </template>
84
+ </DataTable>
85
+
86
+ <Pagination :links="posts.links" />
87
+ </AdminLayout>
88
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { Link } from '@inertiajs/vue3';
3
+ import { type Block, Button, LandingPageTemplate } from '@tower_74/cms-ui';
4
+
5
+ defineProps<{
6
+ type: { slug: string; label: string; singular: string };
7
+ post: { id: number; title: string };
8
+ blocks: Block[];
9
+ }>();
10
+ </script>
11
+
12
+ <template>
13
+ <div class="min-h-screen bg-background">
14
+ <!-- Lightweight preview chrome; the page itself renders via the cms-ui renderer. -->
15
+ <div class="flex items-center justify-between border-b border-border bg-surface px-4 py-2 text-sm">
16
+ <span class="text-muted">Preview — {{ post.title }}</span>
17
+ <Link :href="`/admin/content/${type.slug}/${post.id}/edit`">
18
+ <Button variant="secondary" size="sm">Back to editor</Button>
19
+ </Link>
20
+ </div>
21
+
22
+ <LandingPageTemplate v-if="blocks.length" :blocks="blocks" />
23
+ <p v-else class="px-4 py-20 text-center text-muted">This page has no blocks yet. Add some in the editor.</p>
24
+ </div>
25
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import AdminLayout from '@/layouts/AdminLayout.vue';
3
+ import { Badge, Button, Card } from '@tower_74/cms-ui';
4
+ </script>
5
+
6
+ <template>
7
+ <AdminLayout>
8
+ <template #title>Dashboard</template>
9
+
10
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
11
+ <Card title="Welcome">
12
+ Manage your site content from the sidebar.
13
+ <template #footer>
14
+ <Badge variant="success">cms-ui connected</Badge>
15
+ </template>
16
+ </Card>
17
+
18
+ <Card title="Content">
19
+ Posts and Pages are available now. More screens land as later phases ship.
20
+ <template #footer>
21
+ <Button variant="secondary">View posts</Button>
22
+ </template>
23
+ </Card>
24
+ </div>
25
+ </AdminLayout>
26
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ import FieldBuilder from '@/components/FieldBuilder.vue';
3
+ import AdminLayout from '@/layouts/AdminLayout.vue';
4
+ import { Link, useForm } from '@inertiajs/vue3';
5
+ import { Button, Input } from '@tower_74/cms-ui';
6
+ import { computed } from 'vue';
7
+
8
+ interface Option {
9
+ label: string;
10
+ value: string;
11
+ }
12
+ interface Field {
13
+ name?: string;
14
+ label: string;
15
+ type: string;
16
+ required?: boolean;
17
+ placeholder?: string;
18
+ options?: Option[];
19
+ }
20
+ interface FormData {
21
+ id: number;
22
+ slug: string;
23
+ name: string;
24
+ fields: Field[];
25
+ recipient_email: string | null;
26
+ submit_label: string;
27
+ success_message: string;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ form: FormData | null;
32
+ fieldTypes: Array<{ value: string; label: string }>;
33
+ }>();
34
+
35
+ const isEdit = computed(() => props.form !== null);
36
+
37
+ const form = useForm<{
38
+ name: string;
39
+ recipient_email: string;
40
+ submit_label: string;
41
+ success_message: string;
42
+ fields: Field[];
43
+ }>({
44
+ name: props.form?.name ?? '',
45
+ recipient_email: props.form?.recipient_email ?? '',
46
+ submit_label: props.form?.submit_label ?? 'Send',
47
+ success_message: props.form?.success_message ?? 'Thanks — your message has been sent.',
48
+ fields: props.form?.fields ?? [{ label: 'Name', type: 'text', required: true }],
49
+ });
50
+
51
+ const submit = () => {
52
+ if (isEdit.value && props.form) {
53
+ form.put(`/admin/forms/${props.form.slug}`);
54
+ } else {
55
+ form.post('/admin/forms');
56
+ }
57
+ };
58
+ </script>
59
+
60
+ <template>
61
+ <AdminLayout>
62
+ <template #title>{{ isEdit ? 'Edit form' : 'New form' }}</template>
63
+
64
+ <form class="max-w-2xl space-y-6" @submit.prevent="submit">
65
+ <div class="grid gap-4 sm:grid-cols-2">
66
+ <label class="block">
67
+ <span class="mb-1 block text-sm font-medium text-foreground">Form name</span>
68
+ <Input v-model="form.name" placeholder="Contact" />
69
+ <p v-if="form.errors.name" class="mt-1 text-xs text-danger">{{ form.errors.name }}</p>
70
+ </label>
71
+ <label class="block">
72
+ <span class="mb-1 block text-sm font-medium text-foreground">Notification email</span>
73
+ <Input v-model="form.recipient_email" type="email" placeholder="Leave blank for the site default" />
74
+ <p v-if="form.errors.recipient_email" class="mt-1 text-xs text-danger">{{ form.errors.recipient_email }}</p>
75
+ </label>
76
+ <label class="block">
77
+ <span class="mb-1 block text-sm font-medium text-foreground">Submit button label</span>
78
+ <Input v-model="form.submit_label" />
79
+ </label>
80
+ <label class="block">
81
+ <span class="mb-1 block text-sm font-medium text-foreground">Success message</span>
82
+ <Input v-model="form.success_message" />
83
+ </label>
84
+ </div>
85
+
86
+ <div>
87
+ <h3 class="mb-2 text-base font-semibold">Fields</h3>
88
+ <p v-if="form.errors.fields" class="mb-2 text-xs text-danger">{{ form.errors.fields }}</p>
89
+ <FieldBuilder v-model="form.fields" :field-types="fieldTypes" />
90
+ </div>
91
+
92
+ <div class="flex items-center gap-2">
93
+ <Button type="submit" :disabled="form.processing">{{ isEdit ? 'Save form' : 'Create form' }}</Button>
94
+ <Link href="/admin/forms"><Button variant="ghost" type="button">Cancel</Button></Link>
95
+ </div>
96
+ </form>
97
+ </AdminLayout>
98
+ </template>