@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,30 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute();
3
+
4
+ const param = "slug" in route.params ? route.params.slug : undefined;
5
+
6
+ const slug = Array.isArray(param) ? param.join("/") : (param ?? "");
7
+
8
+ if (import.meta.server) {
9
+ const { getPost } = await import("#myelophone/app/utils/blog.server");
10
+ const post = await getPost(slug);
11
+
12
+ if (!post) {
13
+ const event = useRequestEvent();
14
+
15
+ if (event) {
16
+ setResponseStatus(event, 404);
17
+ }
18
+ } else {
19
+ useAppSeo({
20
+ title: post.meta.title,
21
+ description: post.meta.description,
22
+ image: post.meta.image,
23
+ });
24
+ }
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <UiPostContent :slug="slug" />
30
+ </template>
@@ -0,0 +1,9 @@
1
+ export default defineNuxtPlugin(() => {
2
+ if (import.meta.client) {
3
+ window.addEventListener('pageshow', (event) => {
4
+ if (event.persisted) {
5
+ refreshNuxtData();
6
+ }
7
+ });
8
+ }
9
+ });
@@ -0,0 +1,30 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const route = useRoute();
3
+ const url = useRequestURL();
4
+
5
+ const canonicalUrl = computed(() => {
6
+ const origin = url.origin;
7
+ const path = route.path;
8
+
9
+ const normalizedPath =
10
+ path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
11
+
12
+ const queryParts = [];
13
+ if (route.query.page) {
14
+ queryParts.push(`page=${route.query.page}`);
15
+ }
16
+ const queryString =
17
+ queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
18
+
19
+ return `${origin}${normalizedPath}${queryString}`;
20
+ });
21
+
22
+ useHead({
23
+ link: [
24
+ {
25
+ rel: 'canonical',
26
+ href: canonicalUrl,
27
+ },
28
+ ],
29
+ });
30
+ });
@@ -0,0 +1,41 @@
1
+ import {
2
+ getResponseStatus,
3
+ getResponseStatusText,
4
+ setResponseHeader,
5
+ setResponseStatus,
6
+ } from 'h3';
7
+
8
+ export default defineNuxtPlugin((nuxtApp) => {
9
+ const nonce = useNonce();
10
+ const ssrContext = nuxtApp.ssrContext;
11
+ const head = ssrContext?.head;
12
+
13
+ if (!ssrContext?.event) {
14
+ return;
15
+ }
16
+
17
+ setResponseHeader(
18
+ ssrContext.event,
19
+ 'content-type',
20
+ 'text/html;charset=utf-8',
21
+ );
22
+ if (!getResponseStatusText(ssrContext.event)) {
23
+ setResponseStatus(
24
+ ssrContext.event,
25
+ getResponseStatus(ssrContext.event),
26
+ 'OK',
27
+ );
28
+ }
29
+
30
+ if (!nonce || !head) {
31
+ return;
32
+ }
33
+
34
+ head.hooks?.hook('tags:resolve', ({ tags }) => {
35
+ const script = tags.find((tag) => tag.tag === 'script');
36
+
37
+ if (script) {
38
+ script.props.nonce ??= nonce;
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,21 @@
1
+ import { type Pinia } from 'pinia';
2
+
3
+ export default defineNuxtPlugin(() => {
4
+ const pinia = useNuxtApp().$pinia as Pinia;
5
+ const settingsStore = useSettingsStore(pinia);
6
+ const route = useRoute();
7
+
8
+ watch(
9
+ () => route.path,
10
+ (path) => {
11
+ const seg = path.split('/').filter(Boolean)[0];
12
+
13
+ if (seg && settingsStore.locales.includes(seg)) {
14
+ settingsStore.currentLocale = seg;
15
+ } else {
16
+ settingsStore.currentLocale = settingsStore.defaultLocale;
17
+ }
18
+ },
19
+ { immediate: true },
20
+ );
21
+ });
@@ -0,0 +1,15 @@
1
+ import { type Pinia } from 'pinia';
2
+
3
+ export default defineNuxtPlugin(() => {
4
+ const pinia = useNuxtApp().$pinia as Pinia;
5
+ const settingsStore = useSettingsStore(pinia);
6
+ const route = useRoute();
7
+ const segments = route.path.split('/').filter(Boolean);
8
+ const maybeLang = segments[0];
9
+
10
+ if (maybeLang && settingsStore.locales.includes(maybeLang)) {
11
+ settingsStore.currentLocale = maybeLang;
12
+ } else {
13
+ settingsStore.currentLocale = settingsStore.defaultLocale;
14
+ }
15
+ });
@@ -0,0 +1,4 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const version = import.meta.env.PACKAGE_VERSION || '4.x';
3
+ return { provide: { nuxtVersion: version } };
4
+ });
@@ -0,0 +1,21 @@
1
+ export default defineNuxtPlugin((nuxtApp) => {
2
+ if (!import.meta.client) return;
3
+
4
+ const initialY = window.scrollY;
5
+
6
+ if (initialY > 0) {
7
+ const originalScrollTo = window.scrollTo;
8
+
9
+ window.scrollTo = function (x?: any, y?: any) {
10
+ const targetY = typeof x === 'object' ? x.top : y;
11
+
12
+ if (targetY === 0) return;
13
+
14
+ return originalScrollTo.apply(this, arguments as any);
15
+ };
16
+
17
+ nuxtApp.hook('app:suspense:resolve', () => {
18
+ window.scrollTo = originalScrollTo;
19
+ });
20
+ }
21
+ });
@@ -0,0 +1,89 @@
1
+ export default defineNuxtPlugin((nuxtApp) => {
2
+ const { isStaticMode } = useDevice();
3
+ const { isSlowConnection } = useNetwork();
4
+
5
+ if (import.meta.server) {
6
+ nuxtApp.vueApp.directive('reveal', {
7
+ getSSRProps: () => ({
8
+ 'data-v-reveal': '',
9
+ class: isStaticMode.value ? 'is-visible' : '',
10
+ }),
11
+ });
12
+ return;
13
+ }
14
+
15
+ const shouldSkipAnimation = computed(
16
+ () => isStaticMode.value || isSlowConnection.value,
17
+ );
18
+
19
+ if (shouldSkipAnimation.value) {
20
+ nuxtApp.vueApp.directive('reveal', {
21
+ mounted(el: HTMLElement) {
22
+ el.classList.add('is-visible');
23
+ },
24
+ });
25
+ return;
26
+ }
27
+
28
+ let revealQueue: HTMLElement[] = [];
29
+ let revealTimer: ReturnType<typeof setTimeout> | null = null;
30
+
31
+ const observer = new IntersectionObserver(
32
+ (entries) => {
33
+ entries.forEach((entry) => {
34
+ const el = entry.target as HTMLElement;
35
+ const isRepeat = (el as any)._revealRepeat;
36
+
37
+ if (entry.isIntersecting) {
38
+ if (el.classList.contains('is-visible')) return;
39
+
40
+ revealQueue.push(el);
41
+ if (revealTimer) clearTimeout(revealTimer);
42
+
43
+ revealTimer = setTimeout(() => {
44
+ revealQueue.forEach((item, index) => {
45
+ const step = (item as any)._revealStep || 100;
46
+ item.style.setProperty(
47
+ '--reveal-delay',
48
+ `${index * step}ms`,
49
+ );
50
+ requestAnimationFrame(() =>
51
+ item.classList.add('is-visible'),
52
+ );
53
+ });
54
+ revealQueue = [];
55
+ }, 50);
56
+
57
+ if (!isRepeat) observer.unobserve(el);
58
+ } else {
59
+ if (isRepeat && el.classList.contains('is-visible')) {
60
+ el.classList.remove('is-visible');
61
+ el.style.removeProperty('--reveal-delay');
62
+ }
63
+ }
64
+ });
65
+ },
66
+ {
67
+ rootMargin: '20px 0px 20px 0px',
68
+ threshold: [0, 0.15],
69
+ },
70
+ );
71
+
72
+ nuxtApp.vueApp.directive('reveal', {
73
+ mounted(el: HTMLElement, binding) {
74
+ const type = binding.arg || 'slide';
75
+ (el as any)._revealStep =
76
+ typeof binding.value === 'number' ? binding.value : 100;
77
+ (el as any)._revealRepeat = !!binding.modifiers.repeat;
78
+
79
+ el.classList.add('reveal-active', `reveal-${type}`);
80
+ if (binding.modifiers.fast) el.classList.add('reveal-fast');
81
+ if (binding.modifiers.slow) el.classList.add('reveal-slow');
82
+
83
+ observer.observe(el);
84
+ },
85
+ unmounted(el: HTMLElement) {
86
+ observer.unobserve(el);
87
+ },
88
+ });
89
+ });
@@ -0,0 +1,66 @@
1
+ import { type Pinia } from 'pinia';
2
+
3
+ export default defineNuxtPlugin((nuxtApp) => {
4
+ const router = useRouter();
5
+
6
+ const pinia = useNuxtApp().$pinia as Pinia;
7
+ const settingsStore = useSettingsStore(pinia);
8
+
9
+ nuxtApp.hook('app:beforeMount', () => {
10
+ settingsStore.loadTheme();
11
+ });
12
+
13
+ nuxtApp.hook('page:start', () => {
14
+ settingsStore.setIsLoading(true);
15
+ });
16
+
17
+ nuxtApp.hook('page:finish', () => {
18
+ settingsStore.setIsLoading(false);
19
+ });
20
+
21
+ router.beforeEach((to) => {
22
+ let path = to.path;
23
+
24
+ path = path
25
+ .replace(/[<>{}|\\^`]/g, '')
26
+ .replace(/\/+/g, '/')
27
+ .replace(/\.\.\//g, '')
28
+ .replace(/\.\./g, '');
29
+
30
+ try {
31
+ path = encodeURI(decodeURI(path));
32
+ } catch (_) {}
33
+
34
+ if (path.length > 1 && path.endsWith('/')) {
35
+ path = path.replace(/\/+$/, '');
36
+ }
37
+
38
+ path = path.toLowerCase();
39
+
40
+ if (path !== to.path) {
41
+ return router.replace({
42
+ path,
43
+ query: to.query,
44
+ hash: to.hash,
45
+ });
46
+ }
47
+
48
+ if (location.hostname.startsWith('www.')) {
49
+ const nonWwwHost = location.hostname.replace(/^www\./, '');
50
+ const newUrl = `${location.protocol}//${nonWwwHost}${location.pathname}${location.search}${location.hash}`;
51
+ return location.replace(newUrl);
52
+ }
53
+
54
+ const isLocalhost =
55
+ location.hostname === 'localhost' ||
56
+ location.hostname.startsWith('127.') ||
57
+ location.hostname.startsWith('192.168.') ||
58
+ location.hostname === '[::1]' ||
59
+ /^\d+\.\d+\.\d+\.\d+$/.test(location.hostname);
60
+
61
+ if (location.protocol === 'http:' && !isLocalhost) {
62
+ const httpsUrl = `https://${location.host}${location.pathname}${location.search}${location.hash}`;
63
+ return location.replace(httpsUrl);
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,216 @@
1
+ interface FrankfurterRate {
2
+ date: string;
3
+ base: string;
4
+ quote: string;
5
+ rate: number;
6
+ providers?: string[];
7
+ }
8
+
9
+ interface FrankfurterErrorResponse {
10
+ message?: string;
11
+ }
12
+
13
+ interface FetchErrorLike {
14
+ status?: number;
15
+ statusCode?: number;
16
+ statusText?: string;
17
+ statusMessage?: string;
18
+ message?: string;
19
+ data?: FrankfurterErrorResponse;
20
+ }
21
+
22
+ type CurrencyConfigValue =
23
+ string | string[] | readonly string[] | null | undefined;
24
+
25
+ const FRANKFURTER_RATES_URL = 'https://api.frankfurter.dev/v2/rates';
26
+
27
+ const normalizeCurrency = (value: unknown, fallback = ''): string => {
28
+ const normalized = String(value ?? fallback)
29
+ .trim()
30
+ .toUpperCase()
31
+ .replace(/[^A-Z]/g, '');
32
+
33
+ return normalized || fallback;
34
+ };
35
+
36
+ const isValidCurrencyCode = (currency: string): boolean => {
37
+ return /^[A-Z]{3}$/.test(currency);
38
+ };
39
+
40
+ const parseCurrencyList = (
41
+ value: CurrencyConfigValue,
42
+ base: string,
43
+ ): string[] => {
44
+ const rawValues = Array.isArray(value)
45
+ ? value
46
+ : String(value ?? '').split(',');
47
+
48
+ return [
49
+ ...new Set(
50
+ rawValues
51
+ .flatMap((item) => String(item).split(','))
52
+ .map((item) => normalizeCurrency(item))
53
+ .filter(
54
+ (currency) =>
55
+ currency !== base && isValidCurrencyCode(currency),
56
+ ),
57
+ ),
58
+ ];
59
+ };
60
+
61
+ const parseQueryCurrencies = (
62
+ searchParams: URLSearchParams,
63
+ base: string,
64
+ ): string[] => {
65
+ const values = searchParams.getAll('currencies');
66
+
67
+ return parseCurrencyList(values, base);
68
+ };
69
+
70
+ const getProviderStatus = (error: unknown): number | undefined => {
71
+ if (!error || typeof error !== 'object') {
72
+ return undefined;
73
+ }
74
+
75
+ const fetchError = error as FetchErrorLike;
76
+
77
+ return fetchError.statusCode ?? fetchError.status;
78
+ };
79
+
80
+ const getProviderErrorMessage = (error: unknown): string => {
81
+ if (!error || typeof error !== 'object') {
82
+ return 'Unknown currency provider error.';
83
+ }
84
+
85
+ const fetchError = error as FetchErrorLike;
86
+
87
+ return (
88
+ fetchError.data?.message ??
89
+ fetchError.statusMessage ??
90
+ fetchError.statusText ??
91
+ fetchError.message ??
92
+ 'Unknown currency provider error.'
93
+ );
94
+ };
95
+
96
+ export default defineEventHandler(async (event) => {
97
+ const config = useRuntimeConfig(event).myelophone;
98
+
99
+ const requestUrl = getRequestURL(event);
100
+
101
+ const configuredBase = normalizeCurrency(
102
+ config.frankfurterBaseCurrency,
103
+ 'USD',
104
+ );
105
+
106
+ const base = normalizeCurrency(
107
+ requestUrl.searchParams.get('base'),
108
+ configuredBase,
109
+ );
110
+
111
+ if (!isValidCurrencyCode(base)) {
112
+ throw createError({
113
+ statusCode: 400,
114
+ statusMessage: 'Invalid Currency',
115
+ message:
116
+ 'The "base" parameter must be a valid three-letter currency code, for example USD, EUR, RUB or BYN.',
117
+ });
118
+ }
119
+
120
+ const queryCurrencies = parseQueryCurrencies(requestUrl.searchParams, base);
121
+
122
+ const configuredCurrencies = parseCurrencyList(
123
+ config.frankfurterCurrencies as CurrencyConfigValue,
124
+ base,
125
+ );
126
+
127
+ const currencies =
128
+ queryCurrencies.length > 0 ? queryCurrencies : configuredCurrencies;
129
+
130
+ const providerUrl = new URL(FRANKFURTER_RATES_URL);
131
+
132
+ providerUrl.searchParams.set('base', base);
133
+
134
+ if (currencies.length > 0) {
135
+ providerUrl.searchParams.set('quotes', currencies.join(','));
136
+ }
137
+
138
+ let response: FrankfurterRate[];
139
+
140
+ try {
141
+ response = await $fetch<FrankfurterRate[]>(providerUrl.toString(), {
142
+ method: 'GET',
143
+ headers: {
144
+ accept: 'application/json',
145
+ },
146
+ retry: 1,
147
+ timeout: 10_000,
148
+ });
149
+ } catch (error: unknown) {
150
+ const providerStatus = getProviderStatus(error);
151
+
152
+ const providerMessage = getProviderErrorMessage(error);
153
+
154
+ if (
155
+ providerStatus === 400 ||
156
+ providerStatus === 404 ||
157
+ providerStatus === 422
158
+ ) {
159
+ throw createError({
160
+ statusCode: 400,
161
+ statusMessage: 'Invalid Currency Request',
162
+ message: providerMessage,
163
+ cause: error,
164
+ });
165
+ }
166
+
167
+ throw createError({
168
+ statusCode: 502,
169
+ statusMessage: 'Currency Provider Error',
170
+ message: 'Failed to retrieve exchange rates from Frankfurter.',
171
+ cause: error,
172
+ });
173
+ }
174
+
175
+ if (!Array.isArray(response)) {
176
+ throw createError({
177
+ statusCode: 502,
178
+ statusMessage: 'Invalid Provider Response',
179
+ message: 'Frankfurter returned an unexpected response format.',
180
+ });
181
+ }
182
+
183
+ const rates: Record<string, number> = {
184
+ [base]: 1,
185
+ };
186
+
187
+ const responseDates: string[] = [];
188
+
189
+ for (const item of response) {
190
+ const quote = normalizeCurrency(item?.quote);
191
+
192
+ if (
193
+ !isValidCurrencyCode(quote) ||
194
+ typeof item.rate !== 'number' ||
195
+ !Number.isFinite(item.rate) ||
196
+ item.rate <= 0
197
+ ) {
198
+ continue;
199
+ }
200
+
201
+ rates[quote] = item.rate;
202
+
203
+ if (item.date) {
204
+ responseDates.push(item.date);
205
+ }
206
+ }
207
+
208
+ const date = responseDates.sort().at(-1) ?? null;
209
+
210
+ return {
211
+ base,
212
+ date,
213
+ rates,
214
+ source: FRANKFURTER_RATES_URL,
215
+ };
216
+ });
@@ -0,0 +1 @@
1
+ export default eventHandler(() => 'ok');
@@ -0,0 +1,6 @@
1
+ export default defineEventHandler(async (event) => {
2
+ deleteCookie(event, 'token', { path: '/' });
3
+ deleteCookie(event, 'sessionExpiry', { path: '/' });
4
+
5
+ return { success: true };
6
+ });
@@ -0,0 +1,14 @@
1
+ export default defineEventHandler((event) => {
2
+ const originalPath = event.path || '/';
3
+ let normalized = originalPath;
4
+
5
+ normalized = normalized.replace(/\/{2,}/g, '/');
6
+
7
+ if (normalized.length > 1 && normalized.endsWith('/')) {
8
+ normalized = normalized.slice(0, -1);
9
+ }
10
+
11
+ if (normalized !== originalPath) {
12
+ return sendRedirect(event, normalized, 301);
13
+ }
14
+ });