@cavulsqa/create 2.9.2 โ†’ 2.10.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 (209) hide show
  1. package/README.md +12 -1
  2. package/bin/create.mjs +18 -25
  3. package/lib/templates.mjs +56 -0
  4. package/package.json +13 -5
  5. package/templates/f7-app/CLAUDE.md +3 -0
  6. package/templates/f7-app/auto-imports.d.ts +15 -0
  7. package/templates/f7-app/package.json +6 -3
  8. package/templates/f7-app/src/App.vue +1 -0
  9. package/templates/f7-app/src/env.d.ts +1 -0
  10. package/templates/f7-app/src/main.ts +3 -0
  11. package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +4 -16
  12. package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +3 -11
  13. package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +2 -6
  14. package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
  15. package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
  16. package/templates/f7-app/src/router/global/global.routes.ts +2 -6
  17. package/templates/f7-app/src/shared/composables/useNavigationGuard.ts +48 -0
  18. package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
  19. package/templates/f7-app/src/shared/utils/lazyRoute.ts +25 -0
  20. package/templates/f7-app/tests/lazyRoute.test.ts +30 -0
  21. package/templates/f7-app/tests/navigationGuard.test.ts +82 -0
  22. package/templates/f7-app/vite.config.ts +1 -0
  23. package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
  24. package/templates/m3e-app/.claude/rules/database.md +106 -0
  25. package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
  26. package/templates/m3e-app/.claude/rules/modules.md +43 -0
  27. package/templates/m3e-app/.claude/rules/native.md +60 -0
  28. package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
  29. package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
  30. package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
  31. package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
  32. package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
  33. package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
  34. package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
  35. package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
  36. package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
  37. package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
  38. package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
  39. package/templates/m3e-app/.env.example +32 -0
  40. package/templates/m3e-app/CLAUDE.md +93 -0
  41. package/templates/m3e-app/auto-imports.d.ts +851 -0
  42. package/templates/m3e-app/capacitor.config.ts +44 -0
  43. package/templates/m3e-app/components.d.ts +252 -0
  44. package/templates/m3e-app/index.html +16 -0
  45. package/templates/m3e-app/package.json +107 -0
  46. package/templates/m3e-app/src/App.vue +129 -0
  47. package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
  48. package/templates/m3e-app/src/app/scroll.config.ts +20 -0
  49. package/templates/m3e-app/src/app/storage.config.ts +65 -0
  50. package/templates/m3e-app/src/app/tabs.ts +33 -0
  51. package/templates/m3e-app/src/app/theme.config.ts +51 -0
  52. package/templates/m3e-app/src/assets/css/app.css +18 -0
  53. package/templates/m3e-app/src/assets/css/base.css +53 -0
  54. package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
  55. package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
  56. package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
  57. package/templates/m3e-app/src/assets/css/m3e.css +6 -0
  58. package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
  59. package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
  60. package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  61. package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  62. package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
  63. package/templates/m3e-app/src/env.d.ts +40 -0
  64. package/templates/m3e-app/src/locales/ar.json +1260 -0
  65. package/templates/m3e-app/src/locales/en.json +1260 -0
  66. package/templates/m3e-app/src/locales/fr.json +1260 -0
  67. package/templates/m3e-app/src/main.ts +41 -0
  68. package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
  69. package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
  70. package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
  71. package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
  72. package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
  73. package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
  74. package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
  75. package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  76. package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
  77. package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
  78. package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
  79. package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
  80. package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
  81. package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
  82. package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
  83. package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
  84. package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
  85. package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
  86. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
  87. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
  88. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
  89. package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
  90. package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
  91. package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
  92. package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
  93. package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
  94. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
  95. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
  96. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
  97. package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
  98. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
  99. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
  100. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
  101. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
  102. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
  103. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
  104. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
  105. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
  106. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
  107. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
  108. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
  109. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
  110. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
  111. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
  112. package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
  113. package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
  114. package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
  115. package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
  116. package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
  117. package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
  118. package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
  119. package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
  120. package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
  121. package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
  122. package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
  123. package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
  124. package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
  125. package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
  126. package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
  127. package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
  128. package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
  129. package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
  130. package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
  131. package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
  132. package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
  133. package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
  134. package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
  135. package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
  136. package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
  137. package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
  138. package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
  139. package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
  140. package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
  141. package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
  142. package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
  143. package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
  144. package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
  145. package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
  146. package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
  147. package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
  148. package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
  149. package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
  150. package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
  151. package/templates/m3e-app/src/modules/settings/types.ts +7 -0
  152. package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
  153. package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
  154. package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
  155. package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
  156. package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
  157. package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
  158. package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
  159. package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
  160. package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
  161. package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
  162. package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
  163. package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
  164. package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
  165. package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
  166. package/templates/m3e-app/src/router/index.ts +20 -0
  167. package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
  168. package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
  169. package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
  170. package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
  171. package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
  172. package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
  173. package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
  174. package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
  175. package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
  176. package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
  177. package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
  178. package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
  179. package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
  180. package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  181. package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
  182. package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
  183. package/templates/m3e-app/src/shared/database/database.ts +219 -0
  184. package/templates/m3e-app/src/shared/database/index.ts +3 -0
  185. package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
  186. package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
  187. package/templates/m3e-app/src/shared/database/queries.ts +26 -0
  188. package/templates/m3e-app/src/shared/database/schema.ts +153 -0
  189. package/templates/m3e-app/src/shared/database/storage.ts +64 -0
  190. package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
  191. package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
  192. package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
  193. package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
  194. package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
  195. package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
  196. package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
  197. package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
  198. package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
  199. package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
  200. package/templates/m3e-app/tests/locales.test.ts +73 -0
  201. package/templates/m3e-app/tests/migrations.test.ts +74 -0
  202. package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
  203. package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
  204. package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
  205. package/templates/m3e-app/tests/storage.test.ts +117 -0
  206. package/templates/m3e-app/tests/textDirection.test.ts +11 -0
  207. package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
  208. package/templates/m3e-app/tsconfig.json +21 -0
  209. package/templates/m3e-app/vite.config.ts +144 -0
@@ -0,0 +1,299 @@
1
+ import {
2
+ applyReaction,
3
+ applyRsvp,
4
+ applyVote,
5
+ type ChatMessage,
6
+ type MessageContact,
7
+ type MessageInvite,
8
+ type MessageLocation,
9
+ type MessageImage,
10
+ type MessagePoll,
11
+ type MessageReaction,
12
+ type MessageStatus,
13
+ type RsvpAnswer,
14
+ } from "@cavulsqa/m3e-vue";
15
+ import { teammateAnswer, teammateVote } from "@/modules/gallery/composables/chatTeam";
16
+
17
+ const AMINA = "Amina Benali";
18
+ const KARIM = "Karim Haddad";
19
+ const MINUTE = 60_000;
20
+ const HOUR = 60 * MINUTE;
21
+ const DAY = 24 * HOUR;
22
+ const OLDER_PAGES = 3;
23
+ const PAGE_SIZE = 10;
24
+
25
+ type Line = [author: string | null, key: string];
26
+
27
+ const OLDER: readonly Line[] = [
28
+ [AMINA, "o1"],
29
+ [null, "o2"],
30
+ [KARIM, "o3"],
31
+ [AMINA, "o4"],
32
+ [null, "o5"],
33
+ ];
34
+
35
+ const TEAM_REACTIONS = ["โค๏ธ", "๐Ÿ‘", "๐Ÿ”ฅ", "๐Ÿ˜‚"] as const;
36
+
37
+ function joinReaction(
38
+ reactions: readonly MessageReaction[] | undefined,
39
+ emoji: string,
40
+ name: string,
41
+ ): MessageReaction[] {
42
+ const at = Date.now();
43
+ const list = [...(reactions ?? [])];
44
+ const index = list.findIndex((reaction) => reaction.emoji === emoji);
45
+ const existing = list[index];
46
+ if (existing)
47
+ list[index] = {
48
+ ...existing,
49
+ count: (existing.count ?? 1) + 1,
50
+ by: [...(existing.by ?? []), name],
51
+ at,
52
+ };
53
+ else list.push({ emoji, count: 1, by: [name], at });
54
+ return list;
55
+ }
56
+
57
+ const REPLIES: readonly Line[] = [
58
+ [AMINA, "r1"],
59
+ [KARIM, "r2"],
60
+ [AMINA, "r3"],
61
+ [KARIM, "r4"],
62
+ ];
63
+
64
+ /**
65
+ * A team conversation that behaves like a live one without a server: history pages in from above,
66
+ * a sent message walks through sending, sent and delivered, a teammate types and answers, and the
67
+ * answer marks what you sent as read - and they react to it, springing onto the bubble. Every timer is
68
+ * cleared with the page.
69
+ */
70
+ export function useChatDemo(photo: (index: number) => MessageImage | null) {
71
+ const { t } = useI18n();
72
+ const timers = new Set<ReturnType<typeof setTimeout>>();
73
+ const messages = shallowRef<ChatMessage[]>([]);
74
+ const typing = shallowRef<{ author: string } | false>(false);
75
+ const draft = ref("");
76
+ let sequence = 0;
77
+ let pages = 0;
78
+ let replies = 0;
79
+ let attached = 0;
80
+
81
+ const id = () => `m${(sequence += 1)}`;
82
+
83
+ function later(ms: number, run: () => void) {
84
+ const timer = setTimeout(() => {
85
+ timers.delete(timer);
86
+ run();
87
+ }, ms);
88
+ timers.add(timer);
89
+ }
90
+
91
+ function text(author: string | null, key: string, at: number): ChatMessage {
92
+ return author
93
+ ? { id: id(), sent: false, author, at, text: t(`gallery.chat.lines.${key}`) }
94
+ : { id: id(), sent: true, at, text: t(`gallery.chat.lines.${key}`), status: "read" };
95
+ }
96
+
97
+ function seed() {
98
+ const now = Date.now();
99
+ const today = new Date(now).setHours(9, 12, 0, 0);
100
+ const morning = Math.min(today, now - 2 * HOUR);
101
+ const image = photo(1);
102
+ messages.value = [
103
+ text(AMINA, "s1", now - 2 * DAY),
104
+ text(null, "s2", now - 2 * DAY + 4 * MINUTE),
105
+ text(AMINA, "s3", now - DAY),
106
+ {
107
+ ...text(null, "s4", now - DAY + 2 * MINUTE),
108
+ reactions: [{ emoji: "๐Ÿ”ฅ", count: 2, by: [AMINA, KARIM] }],
109
+ },
110
+ text(null, "s5", now - DAY + 3 * MINUTE),
111
+ { id: id(), sent: false, author: AMINA, at: now - DAY + 5 * MINUTE, text: "๐Ÿ‘" },
112
+ {
113
+ id: id(),
114
+ sent: false,
115
+ author: KARIM,
116
+ at: morning,
117
+ text: t("gallery.chat.lines.s6"),
118
+ ...(image ? { image } : {}),
119
+ },
120
+ {
121
+ ...text(AMINA, "s7", morning + 3 * MINUTE),
122
+ reactions: [{ emoji: "๐Ÿ‘", mine: true }],
123
+ },
124
+ text(null, "s8", morning + 6 * MINUTE),
125
+ { ...text(null, "s9", morning + 7 * MINUTE), status: "failed" },
126
+ ];
127
+ }
128
+
129
+ function update(target: string | number, change: (message: ChatMessage) => ChatMessage) {
130
+ messages.value = messages.value.map((message) =>
131
+ message.id === target ? change(message) : message,
132
+ );
133
+ }
134
+
135
+ function react(message: ChatMessage, emoji: string | null) {
136
+ update(message.id, (entry) => ({ ...entry, reactions: applyReaction(entry.reactions, emoji) }));
137
+ }
138
+
139
+ function setStatus(target: string | number, status: MessageStatus) {
140
+ update(target, (message) => ({ ...message, status }));
141
+ }
142
+
143
+ function deliver(target: string | number) {
144
+ later(700, () => setStatus(target, "sent"));
145
+ later(1600, () => setStatus(target, "delivered"));
146
+ }
147
+
148
+ function reply() {
149
+ const [author, key] = REPLIES[replies % REPLIES.length]!;
150
+ replies += 1;
151
+ later(2200, () => (typing.value = { author: author! }));
152
+ later(4600, () => {
153
+ typing.value = false;
154
+ const lastSent = messages.value.findLast((message) => message.sent);
155
+ if (lastSent && replies % 2 === 1) {
156
+ const emoji = TEAM_REACTIONS[(replies >> 1) % TEAM_REACTIONS.length]!;
157
+ update(lastSent.id, (entry) => ({
158
+ ...entry,
159
+ reactions: joinReaction(entry.reactions, emoji, author!),
160
+ }));
161
+ }
162
+ messages.value = [
163
+ ...messages.value.map((message) =>
164
+ message.sent && message.status === "delivered"
165
+ ? { ...message, status: "read" as const }
166
+ : message,
167
+ ),
168
+ text(author, key, Date.now()),
169
+ ];
170
+ });
171
+ }
172
+
173
+ type Outgoing = Omit<ChatMessage, "id" | "sent" | "at" | "status">;
174
+
175
+ /** Posts without a reply: the team answers a poll or an invite instead of writing back. */
176
+ function postQuietly(message: Outgoing) {
177
+ const next: ChatMessage = {
178
+ ...message,
179
+ id: id(),
180
+ sent: true,
181
+ at: Date.now(),
182
+ status: "sending",
183
+ };
184
+ messages.value = [...messages.value, next];
185
+ deliver(next.id);
186
+ return next.id;
187
+ }
188
+
189
+ function post(message: Outgoing) {
190
+ postQuietly(message);
191
+ reply();
192
+ }
193
+
194
+ function send(value: string) {
195
+ post({ text: value });
196
+ }
197
+
198
+ function sendPhoto(index?: number) {
199
+ attached += 1;
200
+ const image = photo(index ?? attached * 2);
201
+ if (image) post({ image });
202
+ }
203
+
204
+ function sendImage(image: MessageImage) {
205
+ post({ image });
206
+ }
207
+
208
+ function sendLocation(location: MessageLocation) {
209
+ post({ location });
210
+ }
211
+
212
+ function sendContact(contact: MessageContact) {
213
+ post({ contact });
214
+ }
215
+
216
+ /** A poll the team answers: one vote soon, another a little later. */
217
+ function sendPoll(poll: MessagePoll) {
218
+ const next = postQuietly({ poll });
219
+ later(1800, () => update(next, (entry) => withPoll(entry, (value) => teammateVote(value, 0))));
220
+ later(3400, () => update(next, (entry) => withPoll(entry, (value) => teammateVote(value, 1))));
221
+ }
222
+
223
+ /** An invite the team answers: one is going, one may come. */
224
+ function sendInvite(invite: MessageInvite) {
225
+ const next = postQuietly({ invite });
226
+ later(1800, () =>
227
+ update(next, (entry) => withInvite(entry, (value) => teammateAnswer(value, "going"))),
228
+ );
229
+ later(3400, () =>
230
+ update(next, (entry) => withInvite(entry, (value) => teammateAnswer(value, "maybe"))),
231
+ );
232
+ }
233
+
234
+ function vote(message: ChatMessage, optionId: string) {
235
+ update(message.id, (entry) => withPoll(entry, (value) => applyVote(value, optionId)));
236
+ }
237
+
238
+ function rsvp(message: ChatMessage, answer: RsvpAnswer) {
239
+ update(message.id, (entry) => withInvite(entry, (value) => applyRsvp(value, answer)));
240
+ }
241
+
242
+ function withPoll(message: ChatMessage, change: (poll: MessagePoll) => MessagePoll): ChatMessage {
243
+ return message.poll ? { ...message, poll: change(message.poll) } : message;
244
+ }
245
+
246
+ function withInvite(
247
+ message: ChatMessage,
248
+ change: (invite: MessageInvite) => MessageInvite,
249
+ ): ChatMessage {
250
+ return message.invite ? { ...message, invite: change(message.invite) } : message;
251
+ }
252
+
253
+ function retry(message: ChatMessage) {
254
+ setStatus(message.id, "sending");
255
+ deliver(message.id);
256
+ }
257
+
258
+ function remove(message: ChatMessage) {
259
+ messages.value = messages.value.filter((entry) => entry.id !== message.id);
260
+ }
261
+
262
+ async function loadOlder(): Promise<boolean> {
263
+ if (pages >= OLDER_PAGES) return false;
264
+ await new Promise<void>((resolve) => later(700, resolve));
265
+ pages += 1;
266
+ const oldest = messages.value[0] ? new Date(messages.value[0].at).getTime() : Date.now();
267
+ const page = Array.from({ length: PAGE_SIZE }, (_, index) => {
268
+ const [author, key] = OLDER[(index + pages) % OLDER.length]!;
269
+ return text(author, key, oldest - DAY - (PAGE_SIZE - index) * 3 * MINUTE);
270
+ });
271
+ messages.value = [...page, ...messages.value];
272
+ return pages < OLDER_PAGES;
273
+ }
274
+
275
+ onMounted(seed);
276
+ onScopeDispose(() => {
277
+ for (const timer of timers) clearTimeout(timer);
278
+ timers.clear();
279
+ });
280
+
281
+ return {
282
+ messages,
283
+ typing,
284
+ draft,
285
+ send,
286
+ sendPhoto,
287
+ sendImage,
288
+ sendLocation,
289
+ sendContact,
290
+ sendPoll,
291
+ sendInvite,
292
+ vote,
293
+ rsvp,
294
+ react,
295
+ retry,
296
+ remove,
297
+ loadOlder,
298
+ };
299
+ }
@@ -0,0 +1,230 @@
1
+ import type { MaterialShapeName } from "@cavulsqa/m3e";
2
+ import { defineAsyncComponent, type Component } from "vue";
3
+ import AnimationIcon from "~icons/material-symbols/animation-rounded";
4
+ import CheckIcon from "~icons/material-symbols/check-rounded";
5
+ import EditIcon from "~icons/material-symbols/edit-outline-rounded";
6
+ import InventoryIcon from "~icons/material-symbols/inventory-2-outline-rounded";
7
+ import AddIcon from "~icons/material-symbols/add-rounded";
8
+ import MenuIcon from "~icons/material-symbols/menu-rounded";
9
+ import ShapesIcon from "~icons/material-symbols/shapes-outline-rounded";
10
+ import WidgetsIcon from "~icons/material-symbols/widgets-outline-rounded";
11
+ import TuneIcon from "~icons/material-symbols/tune-rounded";
12
+ import TabsIcon from "~icons/material-symbols/tab-outline-rounded";
13
+ import TableIcon from "~icons/material-symbols/table-outline-rounded";
14
+ import ChartIcon from "~icons/material-symbols/monitoring-rounded";
15
+ import ContactsIcon from "~icons/material-symbols/contacts-outline-rounded";
16
+ import ChatIcon from "~icons/material-symbols/forum-outline-rounded";
17
+ import LoginIcon from "~icons/material-symbols/login-rounded";
18
+ import AgendaIcon from "~icons/material-symbols/event-note-outline-rounded";
19
+ import OnboardingIcon from "~icons/material-symbols/swipe-rounded";
20
+ import CalendarIcon from "~icons/material-symbols/calendar-today-outline-rounded";
21
+ import SpeedIcon from "~icons/material-symbols/speed-rounded";
22
+ import CarouselIcon from "~icons/material-symbols/view-carousel-outline-rounded";
23
+ import type { Tone } from "@/shared/utils/tone";
24
+
25
+ export interface GallerySection {
26
+ id: string;
27
+ icon: Component;
28
+ shape: MaterialShapeName;
29
+ tone: Tone;
30
+ titleKey: string;
31
+ subtitleKey: string;
32
+ /** Loaded only when the section opens, so the gallery index stays light. */
33
+ component: Component;
34
+ /** A section with its own route, for a demo that needs the page's app bar. */
35
+ path?: string;
36
+ }
37
+
38
+ const section = (
39
+ id: string,
40
+ icon: Component,
41
+ shape: MaterialShapeName,
42
+ tone: Tone,
43
+ load: () => Promise<{ default: Component }>,
44
+ ): GallerySection => ({
45
+ id,
46
+ icon,
47
+ shape,
48
+ tone,
49
+ titleKey: `gallery.sections.${id}.title`,
50
+ subtitleKey: `gallery.sections.${id}.subtitle`,
51
+ component: defineAsyncComponent(load),
52
+ });
53
+
54
+ /** One page per component family, each demonstrating its variants against the spec. */
55
+ export const sections: readonly GallerySection[] = [
56
+ section(
57
+ "buttons",
58
+ WidgetsIcon,
59
+ "cookie9Sided",
60
+ "primary",
61
+ () => import("../components/sections/GalleryButtons.vue"),
62
+ ),
63
+ section(
64
+ "fabs",
65
+ AddIcon,
66
+ "cookie4Sided",
67
+ "tertiary",
68
+ () => import("../components/sections/GalleryFabs.vue"),
69
+ ),
70
+ section(
71
+ "progress",
72
+ AnimationIcon,
73
+ "softBurst",
74
+ "secondary",
75
+ () => import("../components/sections/GalleryProgress.vue"),
76
+ ),
77
+ section(
78
+ "shapes",
79
+ ShapesIcon,
80
+ "flower",
81
+ "primary",
82
+ () => import("../components/sections/GalleryShapes.vue"),
83
+ ),
84
+ section(
85
+ "navigation",
86
+ MenuIcon,
87
+ "pill",
88
+ "secondary",
89
+ () => import("../components/sections/GalleryNavigation.vue"),
90
+ ),
91
+ section(
92
+ "overlays",
93
+ InventoryIcon,
94
+ "clover4Leaf",
95
+ "tertiary",
96
+ () => import("../components/sections/GalleryOverlays.vue"),
97
+ ),
98
+ section(
99
+ "selection",
100
+ CheckIcon,
101
+ "sunny",
102
+ "primary",
103
+ () => import("../components/sections/GallerySelection.vue"),
104
+ ),
105
+ section(
106
+ "pickers",
107
+ CalendarIcon,
108
+ "cookie12Sided",
109
+ "tertiary",
110
+ () => import("../components/sections/GalleryPickers.vue"),
111
+ ),
112
+ section(
113
+ "scale",
114
+ SpeedIcon,
115
+ "softBurst",
116
+ "secondary",
117
+ () => import("../components/sections/GalleryScale.vue"),
118
+ ),
119
+ section(
120
+ "inputs",
121
+ EditIcon,
122
+ "gem",
123
+ "secondary",
124
+ () => import("../components/sections/GalleryInputs.vue"),
125
+ ),
126
+ section(
127
+ "carousels",
128
+ CarouselIcon,
129
+ "cookie12Sided",
130
+ "primary",
131
+ () => import("../components/sections/GalleryCarousels.vue"),
132
+ ),
133
+ section(
134
+ "tables",
135
+ TableIcon,
136
+ "square",
137
+ "secondary",
138
+ () => import("../components/sections/GalleryTables.vue"),
139
+ ),
140
+ section(
141
+ "charts",
142
+ ChartIcon,
143
+ "sunny",
144
+ "tertiary",
145
+ () => import("../components/sections/GalleryCharts.vue"),
146
+ ),
147
+ section(
148
+ "surfaces",
149
+ TuneIcon,
150
+ "pentagon",
151
+ "tertiary",
152
+ () => import("../components/sections/GallerySurfaces.vue"),
153
+ ),
154
+ ];
155
+
156
+ /** Tabs pinned under the app bar need the whole page, so they have their own route. */
157
+ export const TABS_SECTION: GallerySection = {
158
+ id: "tabs",
159
+ icon: TabsIcon,
160
+ shape: "pill",
161
+ tone: "primary",
162
+ titleKey: "gallery.sections.tabs.title",
163
+ subtitleKey: "gallery.sections.tabs.subtitle",
164
+ component: { render: () => null },
165
+ path: "/gallery/tabs/",
166
+ };
167
+
168
+ /** Groups pinned under the app bar with an index rail in the page's fixed layer. */
169
+ export const CONTACTS_SECTION: GallerySection = {
170
+ id: "contacts",
171
+ icon: ContactsIcon,
172
+ shape: "clover4Leaf",
173
+ tone: "secondary",
174
+ titleKey: "gallery.sections.contacts.title",
175
+ subtitleKey: "gallery.sections.contacts.subtitle",
176
+ component: { render: () => null },
177
+ path: "/gallery/contacts/",
178
+ };
179
+
180
+ /** A conversation with its composer pinned to the page's bottom edge. */
181
+ export const CHAT_SECTION: GallerySection = {
182
+ id: "chat",
183
+ icon: ChatIcon,
184
+ shape: "softBurst",
185
+ tone: "primary",
186
+ titleKey: "gallery.sections.chat.title",
187
+ subtitleKey: "gallery.sections.chat.subtitle",
188
+ component: { render: () => null },
189
+ path: "/gallery/chat/",
190
+ };
191
+
192
+ /** Full-height pages swiped one at a time, with Skip and Next beside the page dots. */
193
+ export const ONBOARDING_SECTION: GallerySection = {
194
+ id: "onboarding",
195
+ icon: OnboardingIcon,
196
+ shape: "sunny",
197
+ tone: "tertiary",
198
+ titleKey: "gallery.sections.onboarding.title",
199
+ subtitleKey: "gallery.sections.onboarding.subtitle",
200
+ component: { render: () => null },
201
+ path: "/gallery/onboarding/",
202
+ };
203
+
204
+ /** A sign-in screen: validation, a password toggle, a draft of the email. */
205
+ export const LOGIN_SECTION: GallerySection = {
206
+ id: "login",
207
+ icon: LoginIcon,
208
+ shape: "cookie12Sided",
209
+ tone: "secondary",
210
+ titleKey: "gallery.sections.login.title",
211
+ subtitleKey: "gallery.sections.login.subtitle",
212
+ component: { render: () => null },
213
+ path: "/gallery/login/",
214
+ };
215
+
216
+ /** The route agenda: day, week and month views over the same visits. */
217
+ export const AGENDA_SECTION: GallerySection = {
218
+ id: "agenda",
219
+ icon: AgendaIcon,
220
+ shape: "cookie9Sided",
221
+ tone: "primary",
222
+ titleKey: "gallery.sections.agenda.title",
223
+ subtitleKey: "gallery.sections.agenda.subtitle",
224
+ component: { render: () => null },
225
+ path: "/gallery/agenda/",
226
+ };
227
+
228
+ export function findSection(id: string): GallerySection | undefined {
229
+ return sections.find((entry) => entry.id === id);
230
+ }
@@ -0,0 +1,71 @@
1
+ import type { MessageImage } from "@cavulsqa/m3e-vue";
2
+
3
+ const MAX_EDGE = 16_384;
4
+
5
+ /**
6
+ * Turns files picked on the device - the gallery, the camera, any file - into what a message
7
+ * carries, with no native plugin: Capacitor's web view answers a file input with the system picker,
8
+ * and `capture` opens the camera. Images become object URLs measured once; every URL is revoked
9
+ * with the page, so a long chat does not hold photos in memory after it closes.
10
+ */
11
+ export function useLocalAttachments() {
12
+ const urls = new Set<string>();
13
+
14
+ async function measure(file: File): Promise<{ width: number; height: number }> {
15
+ if (typeof createImageBitmap === "function") {
16
+ const bitmap = await createImageBitmap(file);
17
+ try {
18
+ return { width: bitmap.width, height: bitmap.height };
19
+ } finally {
20
+ bitmap.close();
21
+ }
22
+ }
23
+ const url = URL.createObjectURL(file);
24
+ try {
25
+ const image = new Image();
26
+ image.src = url;
27
+ await image.decode();
28
+ return { width: image.naturalWidth, height: image.naturalHeight };
29
+ } finally {
30
+ URL.revokeObjectURL(url);
31
+ }
32
+ }
33
+
34
+ /** An image file as a message image, or `null` when it cannot be read as one. */
35
+ async function toImage(file: File): Promise<MessageImage | null> {
36
+ if (!file.type.startsWith("image/")) return null;
37
+ try {
38
+ const { width, height } = await measure(file);
39
+ if (!width || !height || Math.max(width, height) > MAX_EDGE) return null;
40
+ const src = URL.createObjectURL(file);
41
+ urls.add(src);
42
+ return { src, width, height, alt: file.name };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function describe(file: File, locale: string): string {
49
+ const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const;
50
+ let size = file.size;
51
+ let unit = 0;
52
+ while (size >= 1024 && unit < units.length - 1) {
53
+ size /= 1024;
54
+ unit += 1;
55
+ }
56
+ const amount = new Intl.NumberFormat(locale, {
57
+ style: "unit",
58
+ unit: units[unit],
59
+ unitDisplay: "short",
60
+ maximumFractionDigits: unit === 0 ? 0 : 1,
61
+ }).format(size);
62
+ return `๐Ÿ“„ ${file.name} ยท ${amount}`;
63
+ }
64
+
65
+ onScopeDispose(() => {
66
+ for (const url of urls) URL.revokeObjectURL(url);
67
+ urls.clear();
68
+ });
69
+
70
+ return { toImage, describe };
71
+ }
@@ -0,0 +1,38 @@
1
+ import type { MessageLocation } from "@cavulsqa/m3e-vue";
2
+ import { currentPosition, PositionError } from "@/modules/gallery/composables/currentPosition";
3
+
4
+ const DEPOT = { lat: 35.69739, lng: -0.63309 } as const;
5
+
6
+ /**
7
+ * Shares where the device is. A second tap while a fix is pending is ignored rather than queued, and
8
+ * every way the lookup can fail - no permission, no fix, too slow, no geolocation at all - says which
9
+ * one it was and offers the depot instead, so the demo still has something to send on an emulator.
10
+ */
11
+ export function useLocationShare(send: (location: MessageLocation) => void) {
12
+ const { t } = useI18n();
13
+ const snackbar = useSnackbar();
14
+ const locating = shallowRef(false);
15
+
16
+ async function share() {
17
+ if (locating.value) return;
18
+ locating.value = true;
19
+ let reason: PositionError["reason"];
20
+ try {
21
+ const at = await currentPosition();
22
+ send({ ...at, label: t("gallery.chat.location.here") });
23
+ return;
24
+ } catch (error) {
25
+ reason = error instanceof PositionError ? error.reason : "unavailable";
26
+ } finally {
27
+ locating.value = false;
28
+ }
29
+ const result = await snackbar.show({
30
+ message: t(`gallery.chat.location.${reason}`),
31
+ action: t("gallery.chat.location.sendDepot"),
32
+ duration: "long",
33
+ });
34
+ if (result === "action") send({ ...DEPOT, label: t("gallery.chat.location.depot") });
35
+ }
36
+
37
+ return { locating, share };
38
+ }