@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,111 @@
1
+ <script setup lang="ts">
2
+ type Alignment = "left" | "center" | "right";
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ text: string;
7
+ authorName?: string;
8
+ authorRole?: string;
9
+ avatarSrc?: string;
10
+ avatarChipColor?: string;
11
+ textSizeClass?: string;
12
+ quoteAlign?: Alignment;
13
+ footerAlign?: Alignment;
14
+ }>(),
15
+ {
16
+ textSizeClass: "text-xl sm:text-2xl md:text-3xl leading-relaxed",
17
+ quoteAlign: "center",
18
+ footerAlign: "center",
19
+ },
20
+ );
21
+
22
+ const quoteContainerClass = computed(() => {
23
+ switch (props.quoteAlign) {
24
+ case "left":
25
+ return "text-left";
26
+ case "right":
27
+ return "text-right";
28
+ default:
29
+ return "text-center";
30
+ }
31
+ });
32
+
33
+ const footerContainerClass = computed(() => {
34
+ switch (props.footerAlign) {
35
+ case "left":
36
+ return "justify-start text-left";
37
+ case "right":
38
+ return "justify-start text-right flex-row-reverse";
39
+ default:
40
+ return "justify-center text-center flex-col sm:flex-row";
41
+ }
42
+ });
43
+
44
+ const authorTextWrapperClass = computed(() => {
45
+ switch (props.footerAlign) {
46
+ case "left":
47
+ return "items-start";
48
+ case "right":
49
+ return "items-end";
50
+ default:
51
+ return "items-center";
52
+ }
53
+ });
54
+ </script>
55
+
56
+ <template>
57
+ <figure class="flex flex-col max-w-4xl mx-auto px-4 py-8 sm:py-10">
58
+ <div
59
+ class="mb-4 sm:mb-6 self-start text-primary-500/30 dark:text-primary-400/30 select-none"
60
+ aria-hidden="true"
61
+ >
62
+ <UiIcon
63
+ name="ri:double-quotes-l"
64
+ class="w-16! h-16! sm:w-20! sm:h-20! mask-size-[100%_100%]"
65
+ />
66
+ </div>
67
+
68
+ <blockquote
69
+ class="font-medium text-gray-900 dark:text-white relative z-10 w-full mb-8 sm:mb-10"
70
+ :class="quoteContainerClass"
71
+ >
72
+ <p :class="textSizeClass">
73
+ {{ text }}
74
+ </p>
75
+ </blockquote>
76
+
77
+ <figcaption
78
+ v-if="authorName || authorRole || avatarSrc"
79
+ class="flex items-center gap-4 w-full"
80
+ :class="footerContainerClass"
81
+ >
82
+ <div v-if="avatarSrc" class="shrink-0">
83
+ <AppAvatar
84
+ :src="avatarSrc"
85
+ :chip-color="avatarChipColor"
86
+ class="w-12 h-12 sm:w-14 sm:h-14 shadow-sm"
87
+ />
88
+ </div>
89
+
90
+ <div
91
+ v-if="authorName || authorRole"
92
+ class="flex flex-col"
93
+ :class="authorTextWrapperClass"
94
+ >
95
+ <cite
96
+ v-if="authorName"
97
+ class="not-italic font-bold text-gray-900 dark:text-white text-base sm:text-lg"
98
+ >
99
+ {{ authorName }}
100
+ </cite>
101
+
102
+ <span
103
+ v-if="authorRole"
104
+ class="text-sm sm:text-base text-gray-500 dark:text-gray-400 mt-0.5"
105
+ >
106
+ {{ authorRole }}
107
+ </span>
108
+ </div>
109
+ </figcaption>
110
+ </figure>
111
+ </template>
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts">
2
+ interface MenuItem {
3
+ label: string;
4
+ to: string;
5
+ [key: string]: any;
6
+ }
7
+
8
+ interface MenuClasses {
9
+ wrapper?: string;
10
+ link?: string;
11
+ linkActive?: string;
12
+ moreButton?: string;
13
+ dropdown?: string;
14
+ dropdownLink?: string;
15
+ }
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ items: MenuItem[];
20
+ ui?: MenuClasses;
21
+ }>(),
22
+ {
23
+ ui: () => ({}),
24
+ },
25
+ );
26
+
27
+ const route = useRoute();
28
+
29
+ const containerRef = ref<HTMLElement | null>(null);
30
+ const dummyRef = ref<HTMLElement | null>(null);
31
+ const moreContainerRef = ref<HTMLElement | null>(null);
32
+ const isDropdownOpen = ref(false);
33
+ const availableWidth = ref(0);
34
+ const itemWidths = ref<number[]>([]);
35
+
36
+ const GAP = 16;
37
+ const MORE_BTN_WIDTH = 90;
38
+
39
+ const isActive = (to: string) => {
40
+ if (!to) return false;
41
+ const currentPath = route.path.replace(/[?#].*$/, "").replace(/\/$/, "");
42
+ const targetPath = to.replace(/[?#].*$/, "").replace(/\/$/, "");
43
+ return currentPath === targetPath;
44
+ };
45
+
46
+ const visibleCount = computed(() => {
47
+ if (!availableWidth.value || itemWidths.value.length === 0)
48
+ return props.items.length;
49
+ let currentWidth = 0;
50
+ let count = 0;
51
+ for (let i = 0; i < props.items.length; i++) {
52
+ const itemW = itemWidths.value[i];
53
+ if (itemW === undefined) break;
54
+ const widthAfterAdding = currentWidth + itemW + (i > 0 ? GAP : 0);
55
+ const isLast = i === props.items.length - 1;
56
+ const requiredSpace = isLast
57
+ ? widthAfterAdding
58
+ : widthAfterAdding + GAP + MORE_BTN_WIDTH;
59
+ if (requiredSpace <= availableWidth.value) {
60
+ currentWidth = widthAfterAdding;
61
+ count++;
62
+ } else {
63
+ break;
64
+ }
65
+ }
66
+ return count;
67
+ });
68
+
69
+ const visibleItems = computed(() => props.items.slice(0, visibleCount.value));
70
+ const hiddenItems = computed(() => props.items.slice(visibleCount.value));
71
+
72
+ const measure = () => {
73
+ if (!containerRef.value || !dummyRef.value) return;
74
+ availableWidth.value = containerRef.value.offsetWidth;
75
+ const children = Array.from(dummyRef.value.children) as HTMLElement[];
76
+ itemWidths.value = children.map((el) => el.getBoundingClientRect().width);
77
+ };
78
+
79
+ const handleClickOutside = (event: MouseEvent) => {
80
+ if (isDropdownOpen.value && moreContainerRef.value) {
81
+ if (!moreContainerRef.value.contains(event.target as Node)) {
82
+ isDropdownOpen.value = false;
83
+ }
84
+ }
85
+ };
86
+
87
+ let resizeObserver: ResizeObserver | null = null;
88
+ onMounted(() => {
89
+ measure();
90
+ if (containerRef.value) {
91
+ resizeObserver = new ResizeObserver(() =>
92
+ requestAnimationFrame(measure),
93
+ );
94
+ resizeObserver.observe(containerRef.value);
95
+ }
96
+ document.addEventListener("click", handleClickOutside);
97
+ });
98
+ onUnmounted(() => {
99
+ if (resizeObserver) resizeObserver.disconnect();
100
+ document.removeEventListener("click", handleClickOutside);
101
+ });
102
+ watch(
103
+ () => props.items,
104
+ async () => {
105
+ await nextTick();
106
+ measure();
107
+ },
108
+ { deep: true },
109
+ );
110
+ watch(
111
+ () => route.path,
112
+ () => {
113
+ isDropdownOpen.value = false;
114
+ },
115
+ );
116
+
117
+ const layoutClass =
118
+ "flex items-center whitespace-nowrap transition-colors duration-200";
119
+
120
+ const defaultLink =
121
+ "h-10 px-3 rounded-md hover:bg-neutral-100 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-300 font-medium text-sm";
122
+ const defaultActive = "bg-neutral-100 dark:bg-white/10 text-primary";
123
+ const defaultMore =
124
+ "h-10 px-3 rounded-md gap-1.5 text-sm font-medium hover:bg-neutral-100 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-300";
125
+ const defaultDropdown =
126
+ "absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg shadow-xl py-1";
127
+ </script>
128
+
129
+ <template>
130
+ <div class="relative w-full" :class="ui.wrapper">
131
+ <nav
132
+ ref="containerRef"
133
+ class="flex items-center w-full gap-4 flex-nowrap"
134
+ >
135
+ <div class="flex items-center gap-4 overflow-hidden flex-1">
136
+ <NuxtLink
137
+ v-for="item in visibleItems"
138
+ :key="item.to"
139
+ :to="item.to"
140
+ :class="[
141
+ layoutClass,
142
+ ui.link || defaultLink,
143
+ isActive(item.to) ? ui.linkActive || defaultActive : '',
144
+ ]"
145
+ >
146
+ {{ item.label }}
147
+ </NuxtLink>
148
+ </div>
149
+
150
+ <div
151
+ v-if="hiddenItems.length > 0"
152
+ ref="moreContainerRef"
153
+ class="relative shrink-0 ml-auto"
154
+ >
155
+ <button
156
+ @click="isDropdownOpen = !isDropdownOpen"
157
+ :class="[
158
+ layoutClass,
159
+ ui.moreButton || defaultMore,
160
+ isDropdownOpen && !ui.moreButton
161
+ ? 'bg-neutral-100 dark:bg-white/10'
162
+ : '',
163
+ ]"
164
+ >
165
+ Ещё
166
+ <svg
167
+ xmlns="http://www.w3.org/2000/svg"
168
+ viewBox="0 0 20 20"
169
+ fill="currentColor"
170
+ class="w-4 h-4 transition-transform duration-200"
171
+ :class="{ 'rotate-180': isDropdownOpen }"
172
+ >
173
+ <path
174
+ fill-rule="evenodd"
175
+ d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
176
+ clip-rule="evenodd"
177
+ />
178
+ </svg>
179
+ </button>
180
+
181
+ <div
182
+ v-if="isDropdownOpen"
183
+ class="z-50 overflow-hidden animate-in fade-in zoom-in-95 duration-100"
184
+ :class="ui.dropdown || defaultDropdown"
185
+ >
186
+ <NuxtLink
187
+ v-for="item in hiddenItems"
188
+ :key="item.to"
189
+ :to="item.to"
190
+ class="block px-4 py-2.5 text-sm text-left transition-colors"
191
+ :class="[
192
+ ui.dropdownLink ||
193
+ 'hover:bg-neutral-50 dark:hover:bg-white/5 text-neutral-600 dark:text-neutral-300',
194
+ isActive(item.to)
195
+ ? 'text-primary font-medium bg-neutral-50 dark:bg-white/5'
196
+ : '',
197
+ ]"
198
+ >
199
+ {{ item.label }}
200
+ </NuxtLink>
201
+ </div>
202
+ </div>
203
+ </nav>
204
+
205
+ <div
206
+ ref="dummyRef"
207
+ class="absolute top-0 left-0 invisible -z-50 flex gap-4 pointer-events-none w-max"
208
+ aria-hidden="true"
209
+ >
210
+ <div
211
+ v-for="item in props.items"
212
+ :key="`dummy-${item.to}`"
213
+ :class="[layoutClass, ui.link || defaultLink]"
214
+ >
215
+ {{ item.label }}
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </template>
@@ -0,0 +1,230 @@
1
+ <script setup lang="ts">
2
+ interface Review {
3
+ text: string;
4
+ name?: string;
5
+ city?: string;
6
+ avatar?: string;
7
+ link?: string;
8
+ }
9
+
10
+ const props = defineProps<{ items: Review[] }>();
11
+
12
+ const currentIndex = ref(0);
13
+ const isPaused = ref(false);
14
+ const isDragging = ref(false);
15
+ const isMounted = ref(false);
16
+ const startX = ref(0);
17
+ const dragOffset = ref(0);
18
+ const visibleCount = ref(4);
19
+ const desktopVisible = 4;
20
+
21
+ const maxIndex = computed(() =>
22
+ Math.max(0, props.items.length - visibleCount.value),
23
+ );
24
+
25
+ const updateVisibleCount = () => {
26
+ if (typeof window !== "undefined") {
27
+ visibleCount.value = window.innerWidth < 768 ? 1 : desktopVisible;
28
+
29
+ if (currentIndex.value > maxIndex.value)
30
+ currentIndex.value = maxIndex.value;
31
+ }
32
+ };
33
+
34
+ const moveNextAuto = () =>
35
+ currentIndex.value >= maxIndex.value
36
+ ? (currentIndex.value = 0)
37
+ : currentIndex.value++;
38
+
39
+ const moveNextManual = () => {
40
+ if (currentIndex.value < maxIndex.value) currentIndex.value++;
41
+ };
42
+
43
+ const movePrevManual = () => {
44
+ if (currentIndex.value > 0) currentIndex.value--;
45
+ };
46
+
47
+ const getClientX = (e: MouseEvent | TouchEvent): number => {
48
+ if ("touches" in e) {
49
+ return e.touches[0]?.clientX ?? 0;
50
+ }
51
+ return e.clientX;
52
+ };
53
+
54
+ const onStart = (e: MouseEvent | TouchEvent) => {
55
+ isPaused.value = true;
56
+ isDragging.value = true;
57
+ startX.value = getClientX(e);
58
+ };
59
+
60
+ const onMove = (e: MouseEvent | TouchEvent) => {
61
+ if (!isDragging.value) return;
62
+ const currentX = getClientX(e);
63
+ const diff = currentX - startX.value;
64
+ const isAtStart = currentIndex.value === 0 && diff > 0;
65
+ const isAtEnd = currentIndex.value >= maxIndex.value && diff < 0;
66
+ dragOffset.value = isAtStart || isAtEnd ? diff * 0.2 : diff;
67
+ };
68
+
69
+ const onEnd = () => {
70
+ if (!isDragging.value) return;
71
+ if (dragOffset.value < -50) moveNextManual();
72
+ else if (dragOffset.value > 50) movePrevManual();
73
+ isDragging.value = false;
74
+ dragOffset.value = 0;
75
+ };
76
+
77
+ let interval: any = null;
78
+
79
+ const startAutoPlay = () => {
80
+ if (interval) clearInterval(interval);
81
+ interval = setInterval(() => {
82
+ if (!isPaused.value) moveNextAuto();
83
+ }, 4000);
84
+ };
85
+
86
+ onMounted(() => {
87
+ updateVisibleCount();
88
+ window.addEventListener("resize", updateVisibleCount);
89
+ startAutoPlay();
90
+ nextTick(() => (isMounted.value = true));
91
+ });
92
+
93
+ onUnmounted(() => {
94
+ if (interval) clearInterval(interval);
95
+ window.removeEventListener("resize", updateVisibleCount);
96
+ });
97
+
98
+ const { t } = useMultiLang(["interface"]);
99
+ </script>
100
+
101
+ <template>
102
+ <div class="slider-wrapper relative w-full overflow-hidden py-4">
103
+ <div
104
+ class="slider-track flex touch-pan-y transition-transform duration-500 ease-[cubic-bezier(0.25,1,0.5,1)]"
105
+ :class="{ 'transition-none': isDragging }"
106
+ :style="{
107
+ transform: `translateX(calc(-${currentIndex} * var(--slide-width) + ${dragOffset}px))`,
108
+ }"
109
+ @mousedown="onStart"
110
+ @mousemove="onMove"
111
+ @mouseup="onEnd"
112
+ @mouseleave="onEnd"
113
+ @touchstart="onStart"
114
+ @touchmove="onMove"
115
+ @touchend="onEnd"
116
+ >
117
+ <div
118
+ v-for="(item, index) in items"
119
+ :key="index"
120
+ class="slider-item shrink-0 px-1.5 flex select-none"
121
+ >
122
+ <div
123
+ class="card-inner w-full bg-white dark:bg-zinc-900 rounded-2xl p-5 md:p-6 shadow-sm border border-zinc-100 dark:border-zinc-800 flex flex-col pointer-events-none"
124
+ >
125
+ <div class="grow">
126
+ <p
127
+ class="text-zinc-700 dark:text-zinc-300 text-sm md:text-[15px] leading-snug"
128
+ >
129
+ {{ item.text }}
130
+ </p>
131
+ </div>
132
+
133
+ <div
134
+ v-if="item.name || item.link || item.avatar"
135
+ class="mt-4 pt-4 border-t border-zinc-50 dark:border-zinc-800/50 flex items-center justify-between"
136
+ >
137
+ <div class="flex items-center gap-3">
138
+ <div
139
+ v-if="item.avatar"
140
+ class="relative w-10 h-10 shrink-0 rounded-full overflow-hidden avatar-skeleton bg-zinc-100 dark:bg-zinc-800"
141
+ >
142
+ <ImgWithLoader
143
+ :src="item.avatar"
144
+ class="avatar-img w-full h-full object-cover"
145
+ alt="Reviewer"
146
+ />
147
+ </div>
148
+
149
+ <div v-if="item.name || item.city">
150
+ <div
151
+ v-if="item.name"
152
+ class="font-bold text-zinc-900 dark:text-white text-sm leading-tight"
153
+ >
154
+ {{ item.name }}
155
+ </div>
156
+
157
+ <div
158
+ v-if="item.city"
159
+ class="text-zinc-400 text-xs mt-0.5"
160
+ >
161
+ {{ item.city }}
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <a
167
+ v-if="item.link"
168
+ :href="item.link"
169
+ class="text-blue-500 dark:text-blue-400 text-xs font-medium hover:underline pointer-events-auto shrink-0 ml-4"
170
+ target="_blank"
171
+ >
172
+ {{ t("interface.goMore") }} →
173
+ </a>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ <div
180
+ class="pagination-container flex justify-center gap-1.5 mt-6 transition-opacity duration-500 h-1"
181
+ :class="{ 'opacity-0': !isMounted, 'opacity-100': isMounted }"
182
+ >
183
+ <button
184
+ v-for="(_, i) in items.length - visibleCount + 1"
185
+ :key="i"
186
+ @click="
187
+ currentIndex = i;
188
+
189
+ isPaused = true;
190
+ "
191
+ class="h-full rounded-full transition-all duration-300"
192
+ :class="
193
+ currentIndex === i
194
+ ? 'w-6 bg-zinc-800 dark:bg-zinc-200'
195
+ : 'w-1.5 bg-zinc-200 dark:bg-zinc-700'
196
+ "
197
+ ></button>
198
+ </div>
199
+ </div>
200
+ </template>
201
+
202
+ <style scoped>
203
+ .slider-wrapper {
204
+ --slide-width: 100%;
205
+ }
206
+
207
+ @media (min-width: 768px) {
208
+ .slider-wrapper {
209
+ --slide-width: 25%;
210
+ }
211
+ }
212
+
213
+ .slider-item {
214
+ width: var(--slide-width);
215
+ }
216
+
217
+ .slider-track {
218
+ align-items: stretch;
219
+ cursor: grab;
220
+ }
221
+
222
+ .slider-track:active {
223
+ cursor: grabbing;
224
+ }
225
+
226
+ .select-none {
227
+ user-select: none;
228
+ -webkit-user-drag: none;
229
+ }
230
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ interface SocialItem {
3
+ name: string;
4
+ to: string;
5
+ label: string;
6
+ color?: string;
7
+ target?: "_blank" | "_self";
8
+ }
9
+
10
+ type Variant = "simple" | "colored" | "circle" | "solid" | "glass";
11
+
12
+ interface Props {
13
+ items: SocialItem[];
14
+ variant?: Variant;
15
+ size?: string | number;
16
+ gap?: string;
17
+ iconClass?: string;
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ variant: "simple",
22
+ size: 24,
23
+ gap: "gap-4",
24
+ iconClass: "",
25
+ });
26
+
27
+ const baseLinkClass =
28
+ "flex items-center justify-center transition-colors duration-200 social-link no-external-icon";
29
+
30
+ const variantClasses: Record<Variant, string> = {
31
+ simple: "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white p-1",
32
+ colored: "hover:opacity-80 p-1",
33
+ circle: `
34
+ w-10 h-10 rounded-full border
35
+ border-neutral-200 text-neutral-600
36
+ hover:border-black hover:bg-black hover:text-white
37
+ dark:border-neutral-700 dark:text-neutral-400
38
+ dark:hover:border-white dark:hover:bg-white dark:hover:text-black
39
+ `,
40
+ solid: `
41
+ w-10 h-10 rounded-full
42
+ bg-neutral-100 text-neutral-600
43
+ hover:bg-neutral-900 hover:text-white
44
+ dark:bg-neutral-800 dark:text-neutral-300
45
+ dark:hover:bg-neutral-100 dark:hover:text-black
46
+ `,
47
+ glass: `
48
+ w-10 h-10 rounded-xl border
49
+ bg-white/50 border-white/20 text-neutral-700 hover:bg-white/80
50
+ dark:bg-white/5 dark:border-white/10 dark:text-neutral-300 dark:hover:bg-white/10
51
+ `,
52
+ };
53
+
54
+ const getLinkClass = (item: SocialItem) => {
55
+ let cls = `${baseLinkClass} ${variantClasses[props.variant].replace(/\s+/g, " ")}`;
56
+ if (props.variant === "colored" || props.variant === "simple") {
57
+ if (item.color) cls += ` ${item.color}`;
58
+ }
59
+
60
+ return cls;
61
+ };
62
+ </script>
63
+
64
+ <template>
65
+ <div class="flex flex-wrap items-center" :class="gap">
66
+ <NuxtLink
67
+ v-for="(item, index) in items"
68
+ :key="index"
69
+ :to="item.to"
70
+ :target="item.target || '_blank'"
71
+ :aria-label="item.label"
72
+ :class="getLinkClass(item)"
73
+ external
74
+ >
75
+ <UiIcon :name="item.name" :size="size" :class="`${iconClass}`" />
76
+ </NuxtLink>
77
+ </div>
78
+ </template>