@myelophone/nuxt 0.18.2

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 (259) hide show
  1. package/.devcontainer/Dockerfile +7 -0
  2. package/.devcontainer/devcontainer.json +21 -0
  3. package/.editorconfig +43 -0
  4. package/.github/workflows/package.yml +73 -0
  5. package/.nuxtignore +6 -0
  6. package/.releaserc.js +132 -0
  7. package/.yarn/releases/yarn-4.18.0.cjs +1000 -0
  8. package/.yarnrc.yml +8 -0
  9. package/CHANGELOG.md +141 -0
  10. package/LICENSE +77 -0
  11. package/README.md +3004 -0
  12. package/app/app.vue +455 -0
  13. package/app/assets/css/animation.css +81 -0
  14. package/app/assets/css/backgrounds.css +57 -0
  15. package/app/assets/css/base.css +182 -0
  16. package/app/assets/css/index.css +9 -0
  17. package/app/assets/css/links.css +12 -0
  18. package/app/assets/css/multimedia.css +102 -0
  19. package/app/assets/css/preloader.css +68 -0
  20. package/app/assets/css/print.css +40 -0
  21. package/app/assets/css/tailwind.css +20 -0
  22. package/app/assets/css/theme.css +29 -0
  23. package/app/components/consent/GoogleMap.vue +59 -0
  24. package/app/components/consent/Youtube.vue +27 -0
  25. package/app/components/cookie/Banner.vue +70 -0
  26. package/app/components/cookie/ConsentWrapper.vue +71 -0
  27. package/app/components/cookie/PrivacyPolicy.vue +179 -0
  28. package/app/components/cookie/SettingsModal.vue +387 -0
  29. package/app/components/grid/Col.vue +262 -0
  30. package/app/components/grid/Container.vue +236 -0
  31. package/app/components/grid/Row.vue +248 -0
  32. package/app/components/grid/Stack.vue +233 -0
  33. package/app/components/myelophone/Copyright.vue +5 -0
  34. package/app/components/myelophone/Welcome.vue +107 -0
  35. package/app/components/page/FullscreenPreloader.vue +177 -0
  36. package/app/components/page/Preloader.vue +18 -0
  37. package/app/components/page/ReadyLocalized.vue +19 -0
  38. package/app/components/ready/.gitkeep +0 -0
  39. package/app/components/safe/Email.vue +52 -0
  40. package/app/components/safe/ImgWithLoader.vue +180 -0
  41. package/app/components/safe/NuxtImg.vue +25 -0
  42. package/app/components/safe/NuxtPicture.vue +29 -0
  43. package/app/components/section/.gitkeep +0 -0
  44. package/app/components/seo/BrandInfo.vue +76 -0
  45. package/app/components/seo/ContentNoIndex.vue +35 -0
  46. package/app/components/seo/NoIndex.vue +22 -0
  47. package/app/components/seo/OrgInfo.vue +130 -0
  48. package/app/components/seo/ProfileInfo.vue +137 -0
  49. package/app/components/ui/Accordion.vue +163 -0
  50. package/app/components/ui/Alert.vue +179 -0
  51. package/app/components/ui/Avatar.vue +88 -0
  52. package/app/components/ui/Badge.vue +150 -0
  53. package/app/components/ui/Banner.vue +133 -0
  54. package/app/components/ui/Button.vue +175 -0
  55. package/app/components/ui/Card.vue +66 -0
  56. package/app/components/ui/Checkbox.vue +134 -0
  57. package/app/components/ui/Chip.vue +136 -0
  58. package/app/components/ui/CommandPalette.vue +434 -0
  59. package/app/components/ui/Console.vue +583 -0
  60. package/app/components/ui/CursorCreative.vue +148 -0
  61. package/app/components/ui/FeatureAccordion.vue +165 -0
  62. package/app/components/ui/FileUpload.vue +705 -0
  63. package/app/components/ui/GeoDependent.vue +72 -0
  64. package/app/components/ui/Heading.vue +51 -0
  65. package/app/components/ui/Icon.vue +42 -0
  66. package/app/components/ui/Icon8.vue +39 -0
  67. package/app/components/ui/Input.vue +88 -0
  68. package/app/components/ui/LangLink.vue +34 -0
  69. package/app/components/ui/LangVisible.vue +33 -0
  70. package/app/components/ui/LanguageSelect.vue +202 -0
  71. package/app/components/ui/LightBox.vue +155 -0
  72. package/app/components/ui/LocalTime.vue +170 -0
  73. package/app/components/ui/Modal.vue +161 -0
  74. package/app/components/ui/Popup.vue +200 -0
  75. package/app/components/ui/PostContent.server.vue +23 -0
  76. package/app/components/ui/ProtectedEmail.vue +52 -0
  77. package/app/components/ui/RelativeTime.vue +141 -0
  78. package/app/components/ui/ScheduledContent.vue +150 -0
  79. package/app/components/ui/ScrollToTop.client.vue +48 -0
  80. package/app/components/ui/SegmentedControl.vue +148 -0
  81. package/app/components/ui/SiteSearch.vue +1191 -0
  82. package/app/components/ui/Skeleton.vue +91 -0
  83. package/app/components/ui/SmartContrast.vue +74 -0
  84. package/app/components/ui/SnapContainer.vue +222 -0
  85. package/app/components/ui/SnapSection.vue +294 -0
  86. package/app/components/ui/SonnerStyle.vue +7 -0
  87. package/app/components/ui/SplitSection.vue +99 -0
  88. package/app/components/ui/Steps.vue +858 -0
  89. package/app/components/ui/StickyWrapper.vue +46 -0
  90. package/app/components/ui/Table.vue +251 -0
  91. package/app/components/ui/Tabs.vue +451 -0
  92. package/app/components/ui/TextColumn.vue +36 -0
  93. package/app/components/ui/Textarea.vue +251 -0
  94. package/app/components/ui/Toggler.vue +40 -0
  95. package/app/components/ui/Tooltip.vue +524 -0
  96. package/app/components/ui/TruncateText.vue +105 -0
  97. package/app/components/ui/ViewportSpacer.vue +72 -0
  98. package/app/components/view/AnimatedCounter.vue +121 -0
  99. package/app/components/view/AnnouncementBar.vue +104 -0
  100. package/app/components/view/AvatarGroup.vue +104 -0
  101. package/app/components/view/Breadcrumbs.vue +97 -0
  102. package/app/components/view/ContentWithToc.vue +152 -0
  103. package/app/components/view/Copyright.vue +68 -0
  104. package/app/components/view/CountdownTimer.vue +184 -0
  105. package/app/components/view/CreativeCTA.vue +143 -0
  106. package/app/components/view/CurrencySelect.vue +254 -0
  107. package/app/components/view/FeatureBoxGrid.vue +142 -0
  108. package/app/components/view/HorizontalMenu.vue +97 -0
  109. package/app/components/view/ImageCompare.vue +141 -0
  110. package/app/components/view/ImgSlider.vue +333 -0
  111. package/app/components/view/InfiniteMarquee.vue +147 -0
  112. package/app/components/view/InfoBar.vue +86 -0
  113. package/app/components/view/LogoGrid.vue +106 -0
  114. package/app/components/view/LogoSlider.vue +83 -0
  115. package/app/components/view/NotFound.vue +81 -0
  116. package/app/components/view/PartialBackground.vue +313 -0
  117. package/app/components/view/QuoteBig.vue +111 -0
  118. package/app/components/view/ResponsiveMenu.vue +219 -0
  119. package/app/components/view/ReviewsSlider.vue +230 -0
  120. package/app/components/view/SocialBar.vue +78 -0
  121. package/app/components/view/StickyHeadingContent.vue +158 -0
  122. package/app/components/view/TallyForm.vue +295 -0
  123. package/app/components/view/WarningOutdated.vue +113 -0
  124. package/app/composables/useApi.ts +88 -0
  125. package/app/composables/useAppSeo.ts +78 -0
  126. package/app/composables/useBreadcrumbs.ts +106 -0
  127. package/app/composables/useChangeUrl.ts +73 -0
  128. package/app/composables/useCommand.ts +28 -0
  129. package/app/composables/useCookieControl.ts +111 -0
  130. package/app/composables/useCookieModal.ts +9 -0
  131. package/app/composables/useDevice.ts +109 -0
  132. package/app/composables/useGeoFetch.ts +139 -0
  133. package/app/composables/useLoadScript.ts +36 -0
  134. package/app/composables/useModalState.ts +37 -0
  135. package/app/composables/useMultiLang.ts +278 -0
  136. package/app/composables/useNetwork.ts +49 -0
  137. package/app/composables/useNuxtVersion.ts +5 -0
  138. package/app/composables/usePiniaStore.ts +5 -0
  139. package/app/composables/usePopupControl.ts +57 -0
  140. package/app/composables/useQuery.ts +307 -0
  141. package/app/composables/useReadyPage.ts +182 -0
  142. package/app/composables/useSafeList.ts +90 -0
  143. package/app/composables/useScriptLoader.ts +221 -0
  144. package/app/composables/useSiteSearch.ts +32 -0
  145. package/app/composables/useSnapScroll.ts +24 -0
  146. package/app/composables/useStatic.ts +5 -0
  147. package/app/composables/useStorage.ts +252 -0
  148. package/app/composables/useStoreBroadcast.ts +146 -0
  149. package/app/composables/useThemeSync.ts +81 -0
  150. package/app/constants/layout.ts +109 -0
  151. package/app/constants/predefinedCookieScripts.ts +1222 -0
  152. package/app/constants/ui-events.ts +1 -0
  153. package/app/error.vue +169 -0
  154. package/app/layouts/default-blog.vue +5 -0
  155. package/app/layouts/default.vue +3 -0
  156. package/app/locales/en/commands.json +5 -0
  157. package/app/locales/en/cookies.json +182 -0
  158. package/app/locales/en/error.json +4 -0
  159. package/app/locales/en/interface.json +33 -0
  160. package/app/locales/en/myelophone.json +8 -0
  161. package/app/locales/en/outdated.json +5 -0
  162. package/app/locales/en/times.json +18 -0
  163. package/app/locales/pl/commands.json +5 -0
  164. package/app/locales/pl/cookies.json +182 -0
  165. package/app/locales/pl/error.json +4 -0
  166. package/app/locales/pl/interface.json +33 -0
  167. package/app/locales/pl/myelophone.json +8 -0
  168. package/app/locales/pl/outdated.json +5 -0
  169. package/app/locales/pl/times.json +18 -0
  170. package/app/locales/ru/commands.json +5 -0
  171. package/app/locales/ru/cookies.json +182 -0
  172. package/app/locales/ru/error.json +4 -0
  173. package/app/locales/ru/interface.json +33 -0
  174. package/app/locales/ru/myelophone.json +8 -0
  175. package/app/locales/ru/outdated.json +5 -0
  176. package/app/locales/ru/times.json +18 -0
  177. package/app/middleware/blog-layout.ts +28 -0
  178. package/app/middleware/i18n.global.ts +17 -0
  179. package/app/middleware/not-found-status.ts +11 -0
  180. package/app/modules/default-blog/index.ts +39 -0
  181. package/app/modules/default-blog/posts.ts +80 -0
  182. package/app/modules/default-sitemap/index.ts +116 -0
  183. package/app/modules/default-sitemap/runtime/sitemap.get.ts +42 -0
  184. package/app/modules/fullscreen-preloader/module.ts +43 -0
  185. package/app/modules/htaccess/.htaccess +38 -0
  186. package/app/modules/htaccess/index.ts +23 -0
  187. package/app/modules/i18n-shaker.mjs +221 -0
  188. package/app/modules/multi18n/module.ts +71 -0
  189. package/app/modules/myelophone-config/module.ts +241 -0
  190. package/app/pages/[...all].vue +9 -0
  191. package/app/pages/index.vue +3 -0
  192. package/app/pages/post/[...slug].vue +30 -0
  193. package/app/plugins/bfcache-refresh.client.ts +9 -0
  194. package/app/plugins/canonical.ts +30 -0
  195. package/app/plugins/csp-nonce.server.ts +41 -0
  196. package/app/plugins/multi18n.client.ts +21 -0
  197. package/app/plugins/multi18n.server.ts +15 -0
  198. package/app/plugins/nuxt-version.client.ts +4 -0
  199. package/app/plugins/restore.client.ts +21 -0
  200. package/app/plugins/reveal.ts +89 -0
  201. package/app/plugins/url-helper.client.ts +66 -0
  202. package/app/server/api/exchange-rates.get.ts +216 -0
  203. package/app/server/api/healthz.ts +1 -0
  204. package/app/server/api/logout.post.ts +6 -0
  205. package/app/server/middleware/normalize-url.ts +14 -0
  206. package/app/server/middleware/security-headers.ts +147 -0
  207. package/app/server/middleware/url-helper.ts +43 -0
  208. package/app/server/plugins/clean-html-spaces.ts +15 -0
  209. package/app/server/plugins/clean-styles.ts +94 -0
  210. package/app/server/plugins/compression.ts +122 -0
  211. package/app/server/plugins/css-preload.ts +26 -0
  212. package/app/server/plugins/early-hints.ts +31 -0
  213. package/app/server/plugins/force-noindex.ts +43 -0
  214. package/app/server/plugins/html-one-line.ts +17 -0
  215. package/app/server/plugins/inline-style.ts +7 -0
  216. package/app/server/routes/healthz.get.ts +1 -0
  217. package/app/server/tsconfig.json +3 -0
  218. package/app/stores/cart.ts +945 -0
  219. package/app/stores/commands.ts +43 -0
  220. package/app/stores/device.ts +156 -0
  221. package/app/stores/querySync.ts +5 -0
  222. package/app/stores/settings.ts +145 -0
  223. package/app/stores/user.ts +607 -0
  224. package/app/types/cookie.ts +89 -0
  225. package/app/types/myelophone.d.ts +87 -0
  226. package/app/types/site-search.ts +110 -0
  227. package/app/utils/asyncRefresh.ts +4 -0
  228. package/app/utils/blog.server.ts +142 -0
  229. package/app/utils/jsonLd.ts +56 -0
  230. package/app/utils/myelophoneConfig.ts +4 -0
  231. package/app/utils/sortBy.ts +18 -0
  232. package/app/utils/transliterate.ts +75 -0
  233. package/app/utils/ytVideo.ts +37 -0
  234. package/app/workers/site-search.worker.ts +661 -0
  235. package/biome.json +94 -0
  236. package/commitlint.config.js +3 -0
  237. package/nuxt.config.ts +539 -0
  238. package/package.json +101 -0
  239. package/playground/content/.gitkeep +0 -0
  240. package/playground/locales/.gitkeep +0 -0
  241. package/playground/myelophone.dev.ts +45 -0
  242. package/playground/nuxt.config.ts +105 -0
  243. package/playground/package.json +5 -0
  244. package/playground/pages/.gitkeep +0 -0
  245. package/playground/tsconfig.json +3 -0
  246. package/playwright.config.ts +23 -0
  247. package/public/browsers/chrome.svg +77 -0
  248. package/public/browsers/edge.svg +98 -0
  249. package/public/browsers/firefox.svg +300 -0
  250. package/public/favicon.ico +0 -0
  251. package/public/myelophone_eng.png +0 -0
  252. package/public/myelophone_eng_white.png +0 -0
  253. package/spa-loading-template.html +72 -0
  254. package/tests/home.spec.ts +9 -0
  255. package/tests/utils.ts +23 -0
  256. package/tsconfig.json +16 -0
  257. package/vite/plugins/clean-css.ts +190 -0
  258. package/vite/plugins/postcss-add-viewportunits.ts +33 -0
  259. package/vite/plugins/postcss-viewport-fallback.ts +49 -0
@@ -0,0 +1,136 @@
1
+ <script setup lang="ts">
2
+ type ChipPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left";
3
+
4
+ interface Props {
5
+ text?: string | number;
6
+ color?: "primary" | "red" | "green" | "blue" | "gray" | "neutral";
7
+ size?: "xs" | "sm" | "md" | "lg";
8
+ position?: ChipPosition;
9
+ inset?: boolean;
10
+ standalone?: boolean;
11
+ show?: boolean;
12
+ ping?: boolean;
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ color: "primary",
17
+ size: "sm",
18
+ position: "top-right",
19
+ show: true,
20
+ ping: false,
21
+ });
22
+
23
+ const positionClasses: Record<ChipPosition, string> = {
24
+ "top-right": "top-0 right-0 -translate-y-1/2 translate-x-1/2",
25
+ "top-left": "top-0 left-0 -translate-y-1/2 -translate-x-1/2",
26
+ "bottom-right": "bottom-0 right-0 translate-y-1/2 translate-x-1/2",
27
+ "bottom-left": "bottom-0 left-0 translate-y-1/2 -translate-x-1/2",
28
+ };
29
+
30
+ const insetClasses: Record<ChipPosition, string> = {
31
+ "top-right": "top-0 right-0",
32
+ "top-left": "top-0 left-0",
33
+ "bottom-right": "bottom-0 right-0",
34
+ "bottom-left": "bottom-0 left-0",
35
+ };
36
+
37
+ const sizeClasses = {
38
+ xs: "h-1.5 w-1.5 text-[8px]",
39
+ sm: "h-2.5 w-2.5 text-[9px]",
40
+ md: "h-4 w-4 text-[10px]",
41
+ lg: "h-5 w-5 text-[11px]",
42
+ };
43
+
44
+ const textClasses = {
45
+ xs: "text-[8px]",
46
+ sm: "text-[9px]",
47
+ md: "text-[10px]",
48
+ lg: "text-[11px]",
49
+ };
50
+
51
+ const colorClasses = {
52
+ primary: "bg-primary-500 ring-white dark:ring-gray-900 shadow-sm",
53
+ red: "bg-red-500 ring-white dark:ring-gray-900 shadow-sm",
54
+ green: "bg-green-500 ring-white dark:ring-gray-900 shadow-sm",
55
+ blue: "bg-blue-500 ring-white dark:ring-gray-900 shadow-sm",
56
+ gray: "bg-gray-500 ring-white dark:ring-gray-900 shadow-sm",
57
+ neutral: "bg-slate-500 ring-white dark:ring-gray-900 shadow-sm",
58
+ };
59
+
60
+ const isLongText = computed(() => String(props.text ?? "").length > 2);
61
+ </script>
62
+
63
+ <template>
64
+ <span
65
+ :class="[
66
+ standalone ? 'inline-flex align-middle' : 'relative inline-flex',
67
+ ]"
68
+ >
69
+ <slot /><Transition
70
+ enter-active-class="transition duration-150 ease-out"
71
+ enter-from-class="scale-0 opacity-0"
72
+ enter-to-class="scale-100 opacity-100"
73
+ leave-active-class="transition duration-100 ease-in"
74
+ leave-from-class="scale-100 opacity-100"
75
+ leave-to-class="scale-0 opacity-0"
76
+ ><span
77
+ v-if="show"
78
+ :class="[
79
+ 'rounded-full flex items-center justify-center whitespace-nowrap z-10 shrink-0 leading-0! tracking-tighter font-medium text-white',
80
+ !standalone ? 'absolute ring-1' : 'relative mx-1.5',
81
+ !standalone &&
82
+ (inset
83
+ ? insetClasses[position]
84
+ : positionClasses[position]),
85
+ sizeClasses[size],
86
+ colorClasses[color],
87
+ isLongText ? 'w-auto px-1.5' : 'aspect-square',
88
+ ]"
89
+ >
90
+ <span
91
+ v-if="ping"
92
+ class="chip-ping-element"
93
+ :class="colorClasses[color].split(' ')[0]"
94
+ />
95
+
96
+ <span
97
+ class="relative z-20 flex items-center justify-center"
98
+ :class="textClasses[size]"
99
+ >
100
+ <slot name="content">{{ text }}</slot>
101
+ </span>
102
+ </span></Transition
103
+ ></span
104
+ >
105
+ </template>
106
+
107
+ <style scoped>
108
+ span {
109
+ line-height: 1;
110
+ }
111
+
112
+ .chip-ping-element {
113
+ position: absolute;
114
+ inset: 0;
115
+ border-radius: 9999px;
116
+ animation: custom-ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
117
+ z-index: -1;
118
+ pointer-events: none;
119
+ }
120
+
121
+ @keyframes custom-ping {
122
+ 0% {
123
+ transform: scale(1);
124
+ opacity: 0.8;
125
+ }
126
+ 70%,
127
+ 100% {
128
+ transform: scale(2.5);
129
+ opacity: 0;
130
+ }
131
+ }
132
+
133
+ .ring-1 {
134
+ box-shadow: 0 0 0 1px var(--tw-ring-color);
135
+ }
136
+ </style>
@@ -0,0 +1,434 @@
1
+ <template>
2
+ <Transition
3
+ enter-active-class="transition duration-100 ease-out"
4
+ enter-from-class="opacity-0 scale-95"
5
+ enter-to-class="opacity-100 scale-100"
6
+ leave-active-class="transition duration-75 ease-in"
7
+ leave-from-class="opacity-100 scale-100"
8
+ leave-to-class="opacity-0 scale-95"
9
+ >
10
+ <div
11
+ v-if="isOpen"
12
+ class="fixed inset-0 z-99999 flex items-start justify-center pt-[10vh] px-4"
13
+ >
14
+ <div
15
+ class="absolute inset-0 bg-black/40 backdrop-blur-[1px]"
16
+ @click="close"
17
+ ></div>
18
+
19
+ <div
20
+ class="relative w-full max-w-lg flex flex-col rounded-lg shadow-2xl overflow-hidden ring-1 ring-(--ui-text)/20 bg-(--ui-bg)"
21
+ @click.stop
22
+ >
23
+ <div
24
+ class="flex items-center px-3 py-2 border-b border-(--ui-text)/10 bg-(--ui-text)/5"
25
+ >
26
+ <span
27
+ v-if="mode === 'commands'"
28
+ class="mr-2 text-(--ui-text) font-mono font-bold text-sm"
29
+ >#</span
30
+ >
31
+ <span
32
+ v-else-if="mode === 'pages'"
33
+ class="mr-2 text-(--ui-text) font-mono font-bold text-sm"
34
+ >/</span
35
+ >
36
+ <svg
37
+ v-else
38
+ class="w-4 h-4 opacity-40 mr-2 text-(--ui-text)"
39
+ xmlns="http://www.w3.org/2000/svg"
40
+ fill="none"
41
+ viewBox="0 0 24 24"
42
+ stroke="currentColor"
43
+ >
44
+ <path
45
+ stroke-linecap="round"
46
+ stroke-linejoin="round"
47
+ stroke-width="2"
48
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
49
+ />
50
+ </svg>
51
+
52
+ <input
53
+ ref="searchInput"
54
+ v-model="query"
55
+ type="text"
56
+ placeholder="Введите / для страниц или # для команд..."
57
+ class="w-full bg-transparent border-none outline-none text-sm placeholder-(--ui-text)/30 text-(--ui-text) h-6"
58
+ autocomplete="off"
59
+ spellcheck="false"
60
+ @keydown.down.prevent="onArrowDown"
61
+ @keydown.up.prevent="onArrowUp"
62
+ @keydown.enter.prevent="onEnter"
63
+ @keydown.esc="close"
64
+ />
65
+
66
+ <span
67
+ class="hidden sm:block text-[10px] font-mono opacity-30 text-(--ui-text) border border-(--ui-text)/20 rounded px-1 ml-2"
68
+ >ESC</span
69
+ >
70
+ </div>
71
+
72
+ <div class="max-h-[50vh] overflow-y-auto scrollbar-hide p-1">
73
+ <ul v-if="displayedItems.length > 0">
74
+ <li
75
+ v-for="(item, index) in displayedItems"
76
+ :key="item.id"
77
+ :ref="(el) => (itemRefs[index] = el)"
78
+ class="px-2 py-1.5 mx-0.5 rounded cursor-pointer flex items-center justify-between group transition-colors"
79
+ :class="{
80
+ 'bg-(--ui-text)/10': selectedIndex === index,
81
+ 'hover:bg-(--ui-text)/5':
82
+ selectedIndex !== index,
83
+ }"
84
+ @click="executeItem(item)"
85
+ @mouseenter="selectedIndex = index"
86
+ >
87
+ <div
88
+ class="flex items-center gap-2 min-w-0 overflow-hidden"
89
+ >
90
+ <div
91
+ class="shrink-0 opacity-50 text-(--ui-text)"
92
+ >
93
+ <svg
94
+ v-if="item.type === 'page'"
95
+ class="w-3.5 h-3.5"
96
+ xmlns="http://www.w3.org/2000/svg"
97
+ fill="none"
98
+ viewBox="0 0 24 24"
99
+ stroke="currentColor"
100
+ >
101
+ <path
102
+ stroke-linecap="round"
103
+ stroke-linejoin="round"
104
+ stroke-width="2"
105
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
106
+ />
107
+ </svg>
108
+ <svg
109
+ v-else-if="item.type === 'history'"
110
+ class="w-3.5 h-3.5"
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ fill="none"
113
+ viewBox="0 0 24 24"
114
+ stroke="currentColor"
115
+ >
116
+ <path
117
+ stroke-linecap="round"
118
+ stroke-linejoin="round"
119
+ stroke-width="2"
120
+ d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
121
+ />
122
+ </svg>
123
+ <svg
124
+ v-else
125
+ class="w-3.5 h-3.5"
126
+ xmlns="http://www.w3.org/2000/svg"
127
+ fill="none"
128
+ viewBox="0 0 24 24"
129
+ stroke="currentColor"
130
+ >
131
+ <path
132
+ stroke-linecap="round"
133
+ stroke-linejoin="round"
134
+ stroke-width="2"
135
+ d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
136
+ />
137
+ </svg>
138
+ </div>
139
+
140
+ <div
141
+ class="flex items-baseline gap-2 truncate text-(--ui-text)"
142
+ >
143
+ <span
144
+ class="text-xs font-medium opacity-90 truncate"
145
+ >{{ item.name }}</span
146
+ >
147
+ <span
148
+ v-if="item.hint"
149
+ class="text-[10px] opacity-40 font-mono truncate hidden sm:inline"
150
+ >
151
+ {{ item.hint }}
152
+ </span>
153
+ </div>
154
+ </div>
155
+
156
+ <svg
157
+ v-if="selectedIndex === index"
158
+ class="hidden sm:block w-3 h-3 text-(--ui-text) opacity-30 shrink-0"
159
+ xmlns="http://www.w3.org/2000/svg"
160
+ fill="none"
161
+ viewBox="0 0 24 24"
162
+ stroke="currentColor"
163
+ >
164
+ <path
165
+ stroke-linecap="round"
166
+ stroke-linejoin="round"
167
+ stroke-width="2"
168
+ d="M14 5l7 7m0 0l-7 7m7-7H3"
169
+ />
170
+ </svg>
171
+ </li>
172
+ </ul>
173
+
174
+ <div v-else class="px-4 py-4 text-center select-none">
175
+ <div
176
+ v-if="!query"
177
+ class="flex justify-center gap-3 opacity-30 text-(--ui-text) text-[10px] font-mono"
178
+ >
179
+ <span>/ страницы</span>
180
+ <span># команды</span>
181
+ </div>
182
+ <div
183
+ v-else
184
+ class="text-[10px] opacity-30 text-(--ui-text)"
185
+ >
186
+ Ничего не найдено
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </Transition>
193
+ </template>
194
+
195
+ <script setup lang="ts">
196
+ import { useCommandStore } from "~/stores/commands";
197
+ import type { RouteRecordNormalized } from "vue-router";
198
+ import { UI_SITE_SEARCH_OPEN_EVENT } from "~/constants/ui-events";
199
+
200
+ const router = useRouter();
201
+ const route = useRoute();
202
+ const nuxtApp = useNuxtApp();
203
+
204
+ const commandStore = useCommandStore(nuxtApp.$pinia);
205
+
206
+ interface SearchItem {
207
+ id: string;
208
+ type: "page" | "history" | "command";
209
+ name: string;
210
+ hint?: string;
211
+ path?: string;
212
+ action?: () => void;
213
+ }
214
+
215
+ const isOpen = ref(false);
216
+ const query = ref("");
217
+ const selectedIndex = ref(0);
218
+ const searchInput = ref<HTMLInputElement | null>(null);
219
+ const itemRefs = ref<(Element | ComponentPublicInstance | null)[]>([]);
220
+ const history = ref<SearchItem[]>([]);
221
+
222
+ const formatRouteName = (r: any): string => {
223
+ if (r.path === "/" || r.name === "index") return "Главная";
224
+ if (r.meta?.title) return r.meta.title as string;
225
+ const name = (r.name || r.path).toString().replace(/^\//, "");
226
+ return name
227
+ .split(/[-_]/)
228
+ .map((s: string) => s.charAt(0).toUpperCase() + s.slice(1))
229
+ .join(" ");
230
+ };
231
+
232
+ const allRoutes = router
233
+ .getRoutes()
234
+ .filter(
235
+ (r: RouteRecordNormalized) =>
236
+ !r.redirect &&
237
+ !r.children.length &&
238
+ !r.path.includes(":") &&
239
+ !r.path.includes("["),
240
+ )
241
+ .map((r: RouteRecordNormalized) => ({
242
+ id: "page-" + r.path,
243
+ type: "page" as const,
244
+ path: r.path,
245
+ name: formatRouteName(r),
246
+ hint: r.path,
247
+ }))
248
+ .sort((a, b) => a.path.length - b.path.length);
249
+
250
+ const mode = computed<"pages" | "commands" | "idle">(() => {
251
+ if (query.value.startsWith("/")) return "pages";
252
+ if (query.value.startsWith("#")) return "commands";
253
+ return "idle";
254
+ });
255
+
256
+ const displayedItems = computed<SearchItem[]>(() => {
257
+ const q = query.value.substring(1).toLowerCase();
258
+
259
+ if (mode.value === "pages") {
260
+ const histItems = history.value.filter(
261
+ (h) =>
262
+ (h.name.toLowerCase().includes(q) ||
263
+ (h.path && h.path.includes(q))) &&
264
+ h.path !== route.path,
265
+ );
266
+
267
+ const pageItems = allRoutes.filter(
268
+ (p) =>
269
+ (p.name.toLowerCase().includes(q) || p.path.includes(q)) &&
270
+ p.path !== route.path &&
271
+ !histItems.some((h) => h.path === p.path),
272
+ );
273
+
274
+ return [...histItems, ...pageItems];
275
+ }
276
+
277
+ if (mode.value === "commands") {
278
+ return commandStore.commands.filter(
279
+ (c) =>
280
+ c.name.toLowerCase().includes(q) ||
281
+ c.id.includes(q) ||
282
+ (c.keywords && c.keywords.some((k) => k.includes(q))),
283
+ );
284
+ }
285
+
286
+ return [];
287
+ });
288
+
289
+ watch(displayedItems, (items) => {
290
+ if (mode.value === "pages" && items.length > 0) {
291
+ items.slice(0, 5).forEach((item) => {
292
+ if (
293
+ (item.type === "page" || item.type === "history") &&
294
+ item.path
295
+ ) {
296
+ preloadRouteComponents(item.path);
297
+ }
298
+ });
299
+ }
300
+ selectedIndex.value = 0;
301
+ });
302
+
303
+ const executeItem = (item: SearchItem) => {
304
+ isOpen.value = false;
305
+
306
+ if (item.type === "page" || item.type === "history") {
307
+ if (item.path) router.push(item.path);
308
+ } else if (item.type === "command") {
309
+ if (item.action) {
310
+ item.action();
311
+ }
312
+ }
313
+ };
314
+
315
+ const safeSessionStorage = {
316
+ getItem: (key: string) => {
317
+ try {
318
+ return window.sessionStorage.getItem(key);
319
+ } catch (e) {
320
+ return null;
321
+ }
322
+ },
323
+ setItem: (key: string, val: string) => {
324
+ try {
325
+ window.sessionStorage.setItem(key, val);
326
+ } catch (e) {}
327
+ },
328
+ };
329
+
330
+ const addToHistory = (r: any) => {
331
+ if (r.path.includes("[") || r.path === "/") return;
332
+
333
+ const item: SearchItem = {
334
+ id: "hist-" + r.path,
335
+ type: "history",
336
+ path: r.path,
337
+ name: formatRouteName(r),
338
+ hint: "Недавно",
339
+ };
340
+
341
+ const existing = history.value.findIndex((h) => h.path === item.path);
342
+ if (existing > -1) history.value.splice(existing, 1);
343
+
344
+ history.value.unshift(item);
345
+ if (history.value.length > 5) history.value.pop();
346
+
347
+ safeSessionStorage.setItem("cmd_history", JSON.stringify(history.value));
348
+ };
349
+
350
+ const close = () => (isOpen.value = false);
351
+
352
+ const onArrowDown = () => {
353
+ if (!displayedItems.value.length) return;
354
+ selectedIndex.value =
355
+ (selectedIndex.value + 1) % displayedItems.value.length;
356
+ scrollToItem();
357
+ };
358
+
359
+ const onArrowUp = () => {
360
+ if (!displayedItems.value.length) return;
361
+ selectedIndex.value =
362
+ (selectedIndex.value - 1 + displayedItems.value.length) %
363
+ displayedItems.value.length;
364
+ scrollToItem();
365
+ };
366
+
367
+ const onEnter = () => {
368
+ const item = displayedItems.value[selectedIndex.value];
369
+ if (item) executeItem(item);
370
+ };
371
+
372
+ const scrollToItem = () => {
373
+ nextTick(() => {
374
+ const el = itemRefs.value[selectedIndex.value];
375
+ if (el instanceof Element)
376
+ el.scrollIntoView({ block: "nearest", behavior: "smooth" });
377
+ });
378
+ };
379
+
380
+ const onKeydown = (e: KeyboardEvent) => {
381
+ if (
382
+ (e.ctrlKey || e.metaKey) &&
383
+ !e.shiftKey &&
384
+ e.key.toLowerCase() === "k"
385
+ ) {
386
+ e.preventDefault();
387
+ if (!isOpen.value) {
388
+ isOpen.value = true;
389
+ query.value = "";
390
+ nextTick(() => searchInput.value?.focus());
391
+ } else {
392
+ close();
393
+ }
394
+ }
395
+ };
396
+
397
+ const onSiteSearchOpen = () => {
398
+ if (isOpen.value) close();
399
+ };
400
+
401
+ onMounted(() => {
402
+ if (typeof window !== "undefined") {
403
+ window.addEventListener("keydown", onKeydown);
404
+ window.addEventListener(UI_SITE_SEARCH_OPEN_EVENT, onSiteSearchOpen);
405
+ const saved = safeSessionStorage.getItem("cmd_history");
406
+ if (saved)
407
+ try {
408
+ history.value = JSON.parse(saved);
409
+ } catch (e) {}
410
+ }
411
+ });
412
+
413
+ onUnmounted(() => {
414
+ if (typeof window !== "undefined") {
415
+ window.removeEventListener("keydown", onKeydown);
416
+ window.removeEventListener(UI_SITE_SEARCH_OPEN_EVENT, onSiteSearchOpen);
417
+ }
418
+ });
419
+
420
+ watch(
421
+ () => route.path,
422
+ () => addToHistory(route),
423
+ );
424
+ </script>
425
+
426
+ <style scoped>
427
+ .scrollbar-hide::-webkit-scrollbar {
428
+ display: none;
429
+ }
430
+ .scrollbar-hide {
431
+ -ms-overflow-style: none;
432
+ scrollbar-width: none;
433
+ }
434
+ </style>