@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,121 @@
1
+ <template>
2
+ <span
3
+ ref="target"
4
+ class="relative inline-flex flex-col items-center justify-center tabular-nums leading-none whitespace-nowrap"
5
+ :aria-label="`${prefix}${to}${suffix}`"
6
+ >
7
+ <span
8
+ class="opacity-0 select-none pointer-events-none"
9
+ aria-hidden="true"
10
+ >
11
+ {{ prefix }}{{ formattedTarget }}{{ suffix }}
12
+ </span>
13
+ <span
14
+ class="absolute inset-0 flex items-center justify-center"
15
+ aria-hidden="true"
16
+ >
17
+ {{ prefix }}{{ formattedCurrent }}{{ suffix }}
18
+ </span>
19
+ </span>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ interface Props {
24
+ to: number;
25
+ from?: number;
26
+ duration?: number;
27
+ prefix?: string;
28
+ suffix?: string;
29
+ decimals?: number;
30
+ separator?: string;
31
+ once?: boolean;
32
+ }
33
+
34
+ const props = withDefaults(defineProps<Props>(), {
35
+ from: 0,
36
+ duration: 1500,
37
+ prefix: "",
38
+ suffix: "",
39
+ decimals: 0,
40
+ separator: " ",
41
+ once: true,
42
+ });
43
+
44
+ const target = ref<HTMLElement | null>(null);
45
+ const current = ref(props.from);
46
+ const hasAnimated = ref(false);
47
+
48
+ const formatNumber = (num: number) => {
49
+ return new Intl.NumberFormat("ru-RU", {
50
+ minimumFractionDigits: props.decimals,
51
+ maximumFractionDigits: props.decimals,
52
+ })
53
+ .format(num)
54
+ .replace(/,/g, ".");
55
+ };
56
+
57
+ const formattedTarget = computed(() => formatNumber(props.to));
58
+ const formattedCurrent = computed(() => formatNumber(current.value));
59
+
60
+ const animate = () => {
61
+ const startTimestamp = performance.now();
62
+ const startValue = current.value;
63
+ const endValue = props.to;
64
+ const diff = endValue - startValue;
65
+
66
+ const step = (timestamp: number) => {
67
+ const elapsed = timestamp - startTimestamp;
68
+ const progress = Math.min(elapsed / props.duration, 1);
69
+
70
+ const ease = 1 - Math.pow(1 - progress, 4);
71
+
72
+ current.value = startValue + diff * ease;
73
+
74
+ if (progress < 1) {
75
+ requestAnimationFrame(step);
76
+ } else {
77
+ current.value = endValue;
78
+ }
79
+ };
80
+
81
+ requestAnimationFrame(step);
82
+ };
83
+
84
+ onMounted(() => {
85
+ if (typeof IntersectionObserver === "undefined") {
86
+ animate();
87
+ return;
88
+ }
89
+
90
+ const observer = new IntersectionObserver(
91
+ (entries) => {
92
+ entries.forEach((entry) => {
93
+ if (entry.isIntersecting) {
94
+ if (props.once && hasAnimated.value) return;
95
+
96
+ if (!hasAnimated.value) current.value = props.from;
97
+
98
+ animate();
99
+ hasAnimated.value = true;
100
+
101
+ if (props.once) observer.disconnect();
102
+ }
103
+ });
104
+ },
105
+ { threshold: 0.2 },
106
+ );
107
+
108
+ if (target.value) {
109
+ observer.observe(target.value);
110
+ }
111
+ });
112
+
113
+ watch(
114
+ () => props.to,
115
+ () => {
116
+ if (hasAnimated.value) {
117
+ animate();
118
+ }
119
+ },
120
+ );
121
+ </script>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(
3
+ defineProps<{
4
+ title?: string;
5
+ description?: string;
6
+
7
+ icon?: string;
8
+
9
+ color?: "primary" | "gray" | "info" | "success" | "warning" | "error";
10
+ variant?: "solid" | "soft" | "outline";
11
+
12
+ sticky?: boolean;
13
+ closable?: boolean;
14
+ }>(),
15
+ {
16
+ color: "primary",
17
+ variant: "soft",
18
+ sticky: false,
19
+ closable: false,
20
+ },
21
+ );
22
+
23
+ const visible = ref(true);
24
+
25
+ const close = () => {
26
+ visible.value = false;
27
+ };
28
+
29
+ const colorVariants: Record<
30
+ NonNullable<typeof props.color>,
31
+ Record<NonNullable<typeof props.variant>, string>
32
+ > = {
33
+ gray: {
34
+ solid: "bg-gray-800 text-white border-b border-gray-800/30",
35
+ soft: "bg-gray-100 text-gray-900 border-b border-gray-300",
36
+ outline: "bg-transparent text-gray-900 border-b border-gray-400",
37
+ },
38
+ primary: {
39
+ solid: "bg-primary-600 text-white border-b border-primary-600/30",
40
+ soft: "bg-primary-100 text-primary-900 border-b border-primary-300",
41
+ outline: "bg-transparent text-primary-900 border-b border-primary-400",
42
+ },
43
+ info: {
44
+ solid: "bg-blue-600 text-white border-b border-blue-600/30",
45
+ soft: "bg-blue-100 text-blue-900 border-b border-blue-300",
46
+ outline: "bg-transparent text-blue-900 border-b border-blue-400",
47
+ },
48
+ success: {
49
+ solid: "bg-emerald-600 text-white border-b border-emerald-600/30",
50
+ soft: "bg-emerald-100 text-emerald-900 border-b border-emerald-300",
51
+ outline: "bg-transparent text-emerald-900 border-b border-emerald-400",
52
+ },
53
+ warning: {
54
+ solid: "bg-amber-500 text-white border-b border-amber-500/30",
55
+ soft: "bg-amber-100 text-amber-900 border-b border-amber-300",
56
+ outline: "bg-transparent text-amber-900 border-b border-amber-400",
57
+ },
58
+ error: {
59
+ solid: "bg-red-600 text-white border-b border-red-600/30",
60
+ soft: "bg-red-100 text-red-900 border-b border-red-300",
61
+ outline: "bg-transparent text-red-900 border-b border-red-400",
62
+ },
63
+ };
64
+
65
+ const classes = computed(() => [
66
+ "w-full flex items-center gap-4 px-4 py-2 text-sm",
67
+ props.sticky ? "sticky top-0 z-50" : null,
68
+ colorVariants[props.color][props.variant],
69
+ ]);
70
+ </script>
71
+
72
+ <template>
73
+ <div v-if="visible" role="region" :class="classes">
74
+ <div v-if="icon" class="flex items-center shrink-0">
75
+ <UiIcon :name="icon" class="h-5 w-5" />
76
+ </div>
77
+
78
+ <div class="flex-1 min-w-0 flex items-center gap-2">
79
+ <span v-if="$slots.title || title" class="font-medium">
80
+ <slot name="title">{{ title }}</slot>
81
+ </span>
82
+
83
+ <span
84
+ v-if="$slots.default || description"
85
+ class="opacity-90 truncate"
86
+ >
87
+ <slot>{{ description }}</slot>
88
+ </span>
89
+ </div>
90
+
91
+ <div v-if="$slots.actions" class="flex items-center gap-2 shrink-0">
92
+ <slot name="actions" />
93
+ </div>
94
+
95
+ <button
96
+ v-if="closable"
97
+ @click="close"
98
+ class="ml-2 opacity-60 hover:opacity-100 transition shrink-0"
99
+ aria-label="Close announcement"
100
+ >
101
+ <UiIcon name="heroicons:x-mark" class="h-4 w-4" />
102
+ </button>
103
+ </div>
104
+ </template>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { Fragment, isVNode } from "vue";
3
+
4
+ interface Props {
5
+ size?: "sm" | "md" | "lg" | "xl";
6
+ max?: number;
7
+ class?: any;
8
+ }
9
+
10
+ const props = withDefaults(defineProps<Props>(), {
11
+ size: "md",
12
+ max: 7,
13
+ });
14
+
15
+ const slots = useSlots();
16
+
17
+ function flatten(nodes: any[]): any[] {
18
+ const out: any[] = [];
19
+
20
+ for (const n of nodes) {
21
+ if (!n) continue;
22
+
23
+ if (Array.isArray(n)) {
24
+ out.push(...flatten(n));
25
+ continue;
26
+ }
27
+
28
+ if (isVNode(n) && n.type === Fragment && Array.isArray(n.children)) {
29
+ out.push(...flatten(n.children as any[]));
30
+ continue;
31
+ }
32
+
33
+ if (isVNode(n) && (n.type === Text || n.type === Comment)) continue;
34
+
35
+ out.push(n);
36
+ }
37
+
38
+ return out;
39
+ }
40
+
41
+ const children = computed(() => flatten(slots.default?.() ?? []));
42
+
43
+ const visibleChildren = computed(() => {
44
+ if (!props.max) return children.value;
45
+ return children.value.slice(0, props.max);
46
+ });
47
+
48
+ const remainingCount = computed(() => {
49
+ return Math.max(0, children.value.length - (props.max || 0));
50
+ });
51
+
52
+ const baseItemClass =
53
+ "relative -ml-2 ring-2 ring-white dark:ring-zinc-900 transition-transform hover:z-50";
54
+
55
+ const rendered = computed(() => {
56
+ const list = [...visibleChildren.value].reverse();
57
+
58
+ return list.map((child: any, index: number) => {
59
+ if (!isVNode(child)) return child;
60
+
61
+ const childProps = (child.props ?? {}) as Record<string, any>;
62
+
63
+ return h(
64
+ child.type as any,
65
+ {
66
+ ...childProps,
67
+ key: child.key ?? index,
68
+ size: props.size,
69
+ class: [baseItemClass, childProps.class],
70
+ style: [childProps.style, { zIndex: index + 1 }],
71
+ },
72
+ child.children as any,
73
+ );
74
+ });
75
+ });
76
+ </script>
77
+
78
+ <template>
79
+ <div
80
+ :class="[
81
+ 'flex items-center justify-start flex-row-reverse',
82
+ props.class,
83
+ ]"
84
+ >
85
+ <UiAvatar
86
+ v-if="remainingCount > 0"
87
+ :text="`+${remainingCount}`"
88
+ :size="size"
89
+ class="relative -ml-2 ring-2 ring-white dark:ring-zinc-900 z-0"
90
+ />
91
+
92
+ <component
93
+ v-for="node in rendered"
94
+ :key="(node as any)?.key"
95
+ :is="node"
96
+ />
97
+ </div>
98
+ </template>
99
+
100
+ <style scoped>
101
+ .flex-row-reverse > *:last-child {
102
+ margin-left: 0;
103
+ }
104
+ </style>
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <nav class="ui-breadcrumbs" :class="containerClass" aria-label="Breadcrumb">
3
+ <ol class="breadcrumb-list">
4
+ <li class="breadcrumb-item">
5
+ <NuxtLink :to="homePath" class="breadcrumb-link">
6
+ {{ homeLabel }}
7
+ </NuxtLink>
8
+ <span v-if="breadcrumbs.length > 0" class="separator">{{
9
+ separator
10
+ }}</span>
11
+ </li>
12
+
13
+ <li
14
+ v-for="(crumb, index) in breadcrumbs"
15
+ :key="crumb.path"
16
+ class="breadcrumb-item"
17
+ >
18
+ <span
19
+ v-if="index === breadcrumbs.length - 1"
20
+ class="current-page"
21
+ >
22
+ {{ truncate(crumb.title, maxChars) }}
23
+ </span>
24
+
25
+ <template v-else>
26
+ <NuxtLink :to="crumb.path" class="breadcrumb-link">
27
+ {{ truncate(crumb.title, maxChars) }}
28
+ </NuxtLink>
29
+ <span class="separator">{{ separator }}</span>
30
+ </template>
31
+ </li>
32
+ </ol>
33
+ </nav>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ const props = withDefaults(
38
+ defineProps<{
39
+ maxChars?: number;
40
+ separator?: string;
41
+ containerClass?: string;
42
+ }>(),
43
+ {
44
+ maxChars: 30,
45
+ separator: "/",
46
+ containerClass: "",
47
+ },
48
+ );
49
+
50
+ const { breadcrumbs, homePath, homeLabel, truncate } = useBreadcrumbs();
51
+ </script>
52
+
53
+ <style scoped>
54
+ .ui-breadcrumbs {
55
+ padding: 0.75rem 0;
56
+ font-size: 0.875rem;
57
+ }
58
+ .breadcrumb-list {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ list-style: none;
62
+ padding: 0;
63
+ margin: 0;
64
+ align-items: center;
65
+ }
66
+ .breadcrumb-item {
67
+ display: flex;
68
+ align-items: center;
69
+ color: var(--ui-text);
70
+ line-height: 1.2;
71
+ }
72
+ .breadcrumb-link {
73
+ color: var(--ui-text);
74
+ opacity: 0.6;
75
+ text-decoration: none;
76
+ transition: opacity 0.2s;
77
+ }
78
+ .breadcrumb-link:hover {
79
+ opacity: 1;
80
+ }
81
+ .separator {
82
+ margin: 0 0.5rem;
83
+ opacity: 0.3;
84
+ user-select: none;
85
+ font-size: 0.9em;
86
+ }
87
+ .current-page {
88
+ font-weight: 500;
89
+ opacity: 0.9;
90
+ text-transform: capitalize;
91
+ }
92
+ @media (max-width: 640px) {
93
+ .ui-breadcrumbs {
94
+ font-size: 0.8rem;
95
+ }
96
+ }
97
+ </style>
@@ -0,0 +1,152 @@
1
+ <script lang="ts">
2
+ import { cloneVNode, Fragment } from "vue";
3
+ import { transliterate } from "~/utils/transliterate";
4
+
5
+ function getTextFromNodes(nodes: VNode[] | VNode | string): string {
6
+ if (typeof nodes === "string") return nodes;
7
+ if (Array.isArray(nodes)) return nodes.map(getTextFromNodes).join("");
8
+ if (!nodes) return "";
9
+ if (nodes.type === Text) return (nodes.children as string) || "";
10
+ if (nodes.type === Fragment && Array.isArray(nodes.children))
11
+ return getTextFromNodes(nodes.children as VNode[]);
12
+ if (typeof nodes.children === "string") return nodes.children;
13
+ if (Array.isArray(nodes.children))
14
+ return getTextFromNodes(nodes.children as VNode[]);
15
+ if (typeof nodes.children === "object" && (nodes.children as any).default) {
16
+ try {
17
+ return getTextFromNodes((nodes.children as any).default());
18
+ } catch (e) {
19
+ return "";
20
+ }
21
+ }
22
+ return "";
23
+ }
24
+
25
+ function flattenFragments(nodes: VNode[]): VNode[] {
26
+ let result: VNode[] = [];
27
+ for (const node of nodes) {
28
+ if (node.type === Fragment && Array.isArray(node.children)) {
29
+ result = result.concat(flattenFragments(node.children as VNode[]));
30
+ } else {
31
+ result.push(node);
32
+ }
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export default defineComponent({
38
+ name: "ContentWithToc",
39
+ setup(_, { slots }) {
40
+ const route = useRoute();
41
+
42
+ const scrollToId = (id: string, smooth = true) => {
43
+ const element = document.getElementById(id);
44
+ if (element) {
45
+ const headerOffset = 60;
46
+ const elementPosition = element.getBoundingClientRect().top;
47
+ const offsetPosition =
48
+ elementPosition + window.pageYOffset - headerOffset;
49
+
50
+ window.scrollTo({
51
+ top: offsetPosition,
52
+ behavior: smooth ? "smooth" : "auto",
53
+ });
54
+ }
55
+ };
56
+
57
+ const handleNavClick = (id: string) => {
58
+ if (history.pushState) {
59
+ history.pushState(null, "", `#${id}`);
60
+ } else {
61
+ window.location.hash = id;
62
+ }
63
+
64
+ scrollToId(id, true);
65
+ };
66
+
67
+ onMounted(async () => {
68
+ await nextTick();
69
+
70
+ if (route.hash) {
71
+ const id = route.hash.replace("#", "");
72
+ setTimeout(() => {
73
+ scrollToId(id, true);
74
+ }, 300);
75
+ }
76
+ });
77
+
78
+ return () => {
79
+ const rawChildren = slots.default ? slots.default() : [];
80
+ const children = flattenFragments(rawChildren);
81
+ const tocItems: { id: string; text: string }[] = [];
82
+
83
+ const modifiedChildren = children.map((node, index) => {
84
+ const type = node.type as any;
85
+ const tag =
86
+ typeof type === "string"
87
+ ? type
88
+ : type.name || type.__name || "";
89
+
90
+ const isHeading =
91
+ ["h2", "h3", "h4"].includes(tag) ||
92
+ tag.includes("Heading") ||
93
+ (node.props && node.props.level);
94
+
95
+ if (isHeading) {
96
+ const text = getTextFromNodes(node);
97
+ if (text && text.trim().length > 0) {
98
+ let id = node.props?.id;
99
+ if (!id) {
100
+ id = transliterate(text);
101
+ if (tocItems.find((t) => t.id === id))
102
+ id = `${id}-${index}`;
103
+ }
104
+ tocItems.push({ id, text });
105
+ return cloneVNode(node, {
106
+ id,
107
+ style: "scroll-margin-top: 1rem;",
108
+ });
109
+ }
110
+ }
111
+ return node;
112
+ });
113
+
114
+ const nav =
115
+ tocItems.length > 0
116
+ ? h("nav", { class: "w-full mb-2" }, [
117
+ h(
118
+ "div",
119
+ { class: "flex flex-wrap gap-2" },
120
+ tocItems.map((item) =>
121
+ h(
122
+ "a",
123
+ {
124
+ href: `#${item.id}`,
125
+ class: `
126
+ inline-flex items-center px-3 py-1.5 rounded-md
127
+ text-xs font-medium uppercase tracking-wide
128
+ transition-all duration-200
129
+
130
+ bg-gray-100 text-gray-700
131
+ dark:bg-white/5 dark:text-gray-200
132
+
133
+ hover:bg-gray-200 dark:hover:bg-white/10
134
+ hover:shadow-sm
135
+ `,
136
+ onClick: (e: Event) => {
137
+ e.preventDefault();
138
+ handleNavClick(item.id);
139
+ },
140
+ },
141
+ item.text,
142
+ ),
143
+ ),
144
+ ),
145
+ ])
146
+ : null;
147
+
148
+ return h("div", { class: "relative" }, [nav, modifiedChildren]);
149
+ };
150
+ },
151
+ });
152
+ </script>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <div class="ui-copyright" :class="containerClass">
3
+ <span class="copyright-text">
4
+ {{ symbol }} {{ yearRange }} {{ owner }}.
5
+ </span>
6
+ <span v-if="allRightsReserved" class="rights-text">
7
+ {{ t("common.all_rights_reserved") }}
8
+ </span>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ const props = withDefaults(
14
+ defineProps<{
15
+ owner: string;
16
+ startYear?: number | string;
17
+ allRightsReserved?: boolean;
18
+ containerClass?: string;
19
+ }>(),
20
+ {
21
+ allRightsReserved: true,
22
+ },
23
+ );
24
+
25
+ const { t } = useMultiLang(["common"]);
26
+
27
+ const symbol = "©";
28
+
29
+ const yearRange = computed(() => {
30
+ const currentYear = new Date().getFullYear();
31
+ const start =
32
+ typeof props.startYear === "string"
33
+ ? parseInt(props.startYear, 10)
34
+ : props.startYear;
35
+
36
+ if (!start || isNaN(start) || start >= currentYear) {
37
+ return currentYear.toString();
38
+ }
39
+
40
+ return `${start}-${currentYear}`;
41
+ });
42
+ </script>
43
+
44
+ <style scoped>
45
+ .ui-copyright {
46
+ color: var(--ui-text);
47
+ opacity: 0.7;
48
+ font-size: 0.875rem;
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ align-items: center;
52
+ gap: 0.5ch;
53
+ }
54
+
55
+ .copyright-text {
56
+ white-space: nowrap;
57
+ }
58
+
59
+ .rights-text {
60
+ text-transform: capitalize;
61
+ }
62
+
63
+ @media (max-width: 640px) {
64
+ .ui-copyright {
65
+ font-size: 0.8rem;
66
+ }
67
+ }
68
+ </style>