@jjlmoya/utils-hardware 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +107 -0
  3. package/src/category/i18n/es.ts +107 -0
  4. package/src/category/i18n/fr.ts +107 -0
  5. package/src/category/index.ts +18 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +26 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +23 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/batteryHealthEstimator/bibliography.astro +14 -0
  25. package/src/tool/batteryHealthEstimator/component.astro +606 -0
  26. package/src/tool/batteryHealthEstimator/i18n/en.ts +158 -0
  27. package/src/tool/batteryHealthEstimator/i18n/es.ts +158 -0
  28. package/src/tool/batteryHealthEstimator/i18n/fr.ts +158 -0
  29. package/src/tool/batteryHealthEstimator/index.ts +34 -0
  30. package/src/tool/batteryHealthEstimator/seo.astro +14 -0
  31. package/src/tool/batteryHealthEstimator/ui.ts +31 -0
  32. package/src/tool/deadPixelTest/bibliography.astro +31 -0
  33. package/src/tool/deadPixelTest/component.astro +717 -0
  34. package/src/tool/deadPixelTest/i18n/en.ts +218 -0
  35. package/src/tool/deadPixelTest/i18n/es.ts +218 -0
  36. package/src/tool/deadPixelTest/i18n/fr.ts +218 -0
  37. package/src/tool/deadPixelTest/index.ts +34 -0
  38. package/src/tool/deadPixelTest/seo.astro +14 -0
  39. package/src/tool/deadPixelTest/ui.ts +20 -0
  40. package/src/tool/gamepadTest/bibliography.astro +30 -0
  41. package/src/tool/gamepadTest/component.astro +1419 -0
  42. package/src/tool/gamepadTest/i18n/en.ts +186 -0
  43. package/src/tool/gamepadTest/i18n/es.ts +186 -0
  44. package/src/tool/gamepadTest/i18n/fr.ts +186 -0
  45. package/src/tool/gamepadTest/index.ts +34 -0
  46. package/src/tool/gamepadTest/seo.astro +14 -0
  47. package/src/tool/gamepadTest/ui.ts +57 -0
  48. package/src/tool/gamepadVibrationTester/bibliography.astro +14 -0
  49. package/src/tool/gamepadVibrationTester/component.astro +694 -0
  50. package/src/tool/gamepadVibrationTester/i18n/en.ts +155 -0
  51. package/src/tool/gamepadVibrationTester/i18n/es.ts +155 -0
  52. package/src/tool/gamepadVibrationTester/i18n/fr.ts +150 -0
  53. package/src/tool/gamepadVibrationTester/index.ts +34 -0
  54. package/src/tool/gamepadVibrationTester/seo.astro +14 -0
  55. package/src/tool/gamepadVibrationTester/ui.ts +28 -0
  56. package/src/tool/keyboardTest/bibliography.astro +30 -0
  57. package/src/tool/keyboardTest/component.astro +601 -0
  58. package/src/tool/keyboardTest/i18n/en.ts +146 -0
  59. package/src/tool/keyboardTest/i18n/es.ts +146 -0
  60. package/src/tool/keyboardTest/i18n/fr.ts +146 -0
  61. package/src/tool/keyboardTest/index.ts +34 -0
  62. package/src/tool/keyboardTest/seo.astro +14 -0
  63. package/src/tool/keyboardTest/ui.ts +8 -0
  64. package/src/tool/mousePollingTest/bibliography.astro +14 -0
  65. package/src/tool/mousePollingTest/component.astro +237 -0
  66. package/src/tool/mousePollingTest/i18n/en.ts +135 -0
  67. package/src/tool/mousePollingTest/i18n/es.ts +135 -0
  68. package/src/tool/mousePollingTest/i18n/fr.ts +135 -0
  69. package/src/tool/mousePollingTest/index.ts +34 -0
  70. package/src/tool/mousePollingTest/logic/MovingAverage.ts +20 -0
  71. package/src/tool/mousePollingTest/logic/RatonManager.ts +97 -0
  72. package/src/tool/mousePollingTest/logic/StatisticalFilter.ts +48 -0
  73. package/src/tool/mousePollingTest/seo.astro +14 -0
  74. package/src/tool/mousePollingTest/ui.ts +8 -0
  75. package/src/tool/mousePollingTest/worker/mouseWorker.ts +50 -0
  76. package/src/tool/toneGenerator/bibliography.astro +14 -0
  77. package/src/tool/toneGenerator/component.astro +571 -0
  78. package/src/tool/toneGenerator/i18n/en.ts +146 -0
  79. package/src/tool/toneGenerator/i18n/es.ts +146 -0
  80. package/src/tool/toneGenerator/i18n/fr.ts +146 -0
  81. package/src/tool/toneGenerator/index.ts +34 -0
  82. package/src/tool/toneGenerator/seo.astro +14 -0
  83. package/src/tool/toneGenerator/ui.ts +19 -0
  84. package/src/tools.ts +10 -0
  85. package/src/types.ts +72 -0
@@ -0,0 +1,143 @@
1
+ ---
2
+ import type { KnownLocale } from '../types';
3
+
4
+ interface Props {
5
+ currentLocale: KnownLocale;
6
+ localeUrls?: Partial<Record<KnownLocale, string>>;
7
+ }
8
+
9
+ const { currentLocale, localeUrls = {} } = Astro.props;
10
+
11
+ const otherLocales = Object.entries(localeUrls).filter(([l]) => l !== currentLocale) as [
12
+ KnownLocale,
13
+ string,
14
+ ][];
15
+ ---
16
+
17
+ <nav class="preview-toolbar">
18
+ {
19
+ otherLocales.length > 0 && (
20
+ <div class="locale-group">
21
+ <span class="locale-current">{currentLocale.toUpperCase()}</span>
22
+ {otherLocales.map(([locale, url]) => (
23
+ <a href={url} class="btn-locale">
24
+ {locale.toUpperCase()}
25
+ </a>
26
+ ))}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ <button id="theme-toggle" class="btn-theme" aria-label="Toggle theme" title="Toggle theme">
32
+ <span class="theme-icon"></span>
33
+ </button>
34
+ </nav>
35
+
36
+ <script>
37
+ function initToolbar() {
38
+ const btn = document.getElementById('theme-toggle');
39
+ if (!btn) return;
40
+
41
+ const applyTheme = (theme: string) => {
42
+ document.documentElement.classList.remove('theme-light', 'theme-dark');
43
+ document.documentElement.classList.add(theme);
44
+ localStorage.setItem('theme', theme);
45
+ };
46
+
47
+ btn.addEventListener('click', () => {
48
+ const next = document.documentElement.classList.contains('theme-dark')
49
+ ? 'theme-light'
50
+ : 'theme-dark';
51
+ applyTheme(next);
52
+ });
53
+ }
54
+
55
+ initToolbar();
56
+ document.addEventListener('astro:after-swap', initToolbar);
57
+ </script>
58
+
59
+ <style>
60
+ .preview-toolbar {
61
+ position: fixed;
62
+ top: 1rem;
63
+ right: 1.5rem;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 0.5rem;
67
+ z-index: 1000;
68
+ background: var(--bg-surface);
69
+ padding: 0.4rem;
70
+ border-radius: 1rem;
71
+ border: 1px solid var(--border-color);
72
+ backdrop-filter: blur(12px);
73
+ }
74
+
75
+ .locale-group {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.25rem;
79
+ padding-right: 0.5rem;
80
+ border-right: 1px solid var(--border-color);
81
+ }
82
+
83
+ .locale-current {
84
+ font-size: 0.7rem;
85
+ font-weight: 900;
86
+ color: var(--text-muted);
87
+ padding: 0.4rem 0.6rem;
88
+ letter-spacing: 0.08em;
89
+ }
90
+
91
+ .btn-locale {
92
+ background: transparent;
93
+ border: 1px solid var(--border-color);
94
+ color: var(--text-main);
95
+ cursor: pointer;
96
+ padding: 0.4rem 0.7rem;
97
+ border-radius: 0.5rem;
98
+ font-weight: 700;
99
+ font-size: 0.7rem;
100
+ text-decoration: none;
101
+ letter-spacing: 0.08em;
102
+ transition: all 0.2s ease;
103
+ }
104
+
105
+ .btn-locale:hover {
106
+ border-color: var(--accent);
107
+ color: var(--accent);
108
+ }
109
+
110
+ .btn-theme {
111
+ background: transparent;
112
+ border: none;
113
+ cursor: pointer;
114
+ padding: 0.5rem;
115
+ border-radius: 0.6rem;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ transition: background 0.2s ease;
120
+ }
121
+
122
+ .btn-theme:hover {
123
+ background: rgba(255, 255, 255, 0.08);
124
+ }
125
+
126
+ .theme-icon {
127
+ display: block;
128
+ width: 16px;
129
+ height: 16px;
130
+ border-radius: 50%;
131
+ }
132
+
133
+ :global(.theme-light) .theme-icon {
134
+ background: #f59e0b;
135
+ box-shadow: 0 0 8px #fbbf24;
136
+ }
137
+
138
+ :global(.theme-dark) .theme-icon {
139
+ background: #94a3b8;
140
+ box-shadow: inset -4px -2px 0 #1e293b;
141
+ }
142
+ </style>
143
+
package/src/data.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { hardwareCategory } from './category';
2
+
3
+ export type {
4
+ KnownLocale,
5
+ ToolLocaleContent,
6
+ CategoryLocaleContent,
7
+ LocaleMap,
8
+ HardwareToolEntry,
9
+ HardwareCategoryEntry,
10
+ } from './types';
11
+
package/src/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module '*.astro' {
2
+ const Component: (_props: Record<string, unknown>) => unknown;
3
+ export default Component;
4
+ }
5
+
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { hardwareCategory } from './category';
2
+ export { default as hardwareCategorySEO } from './category/seo.astro';
3
+
4
+ export type {
5
+ KnownLocale,
6
+ FAQItem,
7
+ BibliographyEntry,
8
+ HowToStep,
9
+ ToolLocaleContent,
10
+ CategoryLocaleContent,
11
+ LocaleLoader,
12
+ LocaleMap,
13
+ HardwareToolEntry,
14
+ HardwareCategoryEntry,
15
+ ToolDefinition,
16
+ } from './types';
17
+
18
+ export { ALL_TOOLS } from './tools';
19
+
20
+ export { PixelesPantallaComponent, PixelesPantallaSEO, PixelesPantallaBibliography, PIXELES_PANTALLA_TOOL } from './tool/deadPixelTest/index';
21
+ export { TestTecladoComponent, TestTecladoSEO, TestTecladoBibliography, TEST_TECLADO_TOOL } from './tool/keyboardTest/index';
22
+ export { TestMandoComponent, TestMandoSEO, TestMandoBibliography, TEST_MANDO_TOOL } from './tool/gamepadTest/index';
23
+ export { ProbadorVibracionMandoComponent, ProbadorVibracionMandoSEO, ProbadorVibracionMandoBibliography, PROBADOR_VIBRACION_MANDO_TOOL } from './tool/gamepadVibrationTester/index';
24
+ export { TestRatonComponent, TestRatonSEO, TestRatonBibliography, TEST_RATON_TOOL } from './tool/mousePollingTest/index';
25
+ export { EstimadorSaludBateriaComponent, EstimadorSaludBateriaSEO, EstimadorSaludBateriaBibliography, ESTIMADOR_SALUD_BATERIA_TOOL } from './tool/batteryHealthEstimator/index';
26
+ export { ToneGeneratorComponent, ToneGeneratorSEO, ToneGeneratorBibliography, TONE_GENERATOR_TOOL } from './tool/toneGenerator/index';
@@ -0,0 +1,117 @@
1
+ ---
2
+ import "@jjlmoya/utils-shared/theme.css";
3
+ import PreviewToolbar from "../components/PreviewToolbar.astro";
4
+ import type { KnownLocale } from "../types";
5
+
6
+ interface Props {
7
+ title: string;
8
+ currentLocale?: KnownLocale;
9
+ localeUrls?: Partial<Record<KnownLocale, string>>;
10
+ hasSidebar?: boolean;
11
+ }
12
+
13
+ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
14
+ ---
15
+
16
+ <!doctype html>
17
+ <html lang={currentLocale}>
18
+ <head>
19
+ <meta charset="UTF-8" />
20
+ <meta name="viewport" content="width=device-width" />
21
+ <title>{title} · preview</title>
22
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
24
+ <link
25
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
26
+ rel="stylesheet"
27
+ />
28
+
29
+ <script is:inline>
30
+ (function () {
31
+ const saved = localStorage.getItem("theme") || "theme-dark";
32
+ document.documentElement.classList.add(saved);
33
+ })();
34
+ </script>
35
+ <slot name="head" />
36
+ </head>
37
+ <body>
38
+ <PreviewToolbar currentLocale={currentLocale} localeUrls={localeUrls} />
39
+ <div class:list={["page-wrapper", { "with-sidebar": hasSidebar }]}>
40
+ {
41
+ hasSidebar && (
42
+ <aside class="sidebar-area">
43
+ <slot name="sidebar" />
44
+ </aside>
45
+ )
46
+ }
47
+ <main>
48
+ <slot />
49
+ </main>
50
+ </div>
51
+ </body>
52
+ </html>
53
+
54
+ <style is:global>
55
+ :root {
56
+ --accent: #f43f5e;
57
+ --primary-base: #9f1239;
58
+ --cyan: #06b6d4;
59
+ }
60
+
61
+ .theme-dark,
62
+ .theme-light {
63
+ --text-main: var(--text-base);
64
+ --border-color: var(--border-base);
65
+ }
66
+
67
+ *,
68
+ *::before,
69
+ *::after {
70
+ box-sizing: border-box;
71
+ }
72
+
73
+ body {
74
+ background-color: var(--bg-page);
75
+ color: var(--text-base);
76
+ margin: 0;
77
+ min-height: 100vh;
78
+ transition:
79
+ background-color 0.3s ease,
80
+ color 0.3s ease;
81
+ }
82
+
83
+ main {
84
+ padding: 0 2rem;
85
+ }
86
+
87
+ .page-wrapper {
88
+ display: flex;
89
+ flex-direction: column;
90
+ }
91
+
92
+ .page-wrapper.with-sidebar {
93
+ display: grid;
94
+ grid-template-columns: 240px 1fr;
95
+ min-height: 100vh;
96
+ }
97
+
98
+ .sidebar-area {
99
+ position: sticky;
100
+ top: 0;
101
+ height: 100vh;
102
+ overflow-y: auto;
103
+ border-right: 1px solid var(--border-color);
104
+ background: var(--bg-page);
105
+ }
106
+
107
+ @media (max-width: 768px) {
108
+ .page-wrapper.with-sidebar {
109
+ grid-template-columns: 1fr;
110
+ }
111
+
112
+ .sidebar-area {
113
+ display: none;
114
+ }
115
+ }
116
+ </style>
117
+
@@ -0,0 +1,146 @@
1
+ ---
2
+ import PreviewLayout from "../../layouts/PreviewLayout.astro";
3
+ import PreviewNavSidebar from "../../components/PreviewNavSidebar.astro";
4
+ import { ALL_TOOLS } from "../../index";
5
+ import {
6
+ UtilityHeader,
7
+ FAQSection,
8
+ Bibliography,
9
+ SEORenderer,
10
+ } from "@jjlmoya/utils-shared";
11
+ import type { KnownLocale, ToolLocaleContent } from "../../types";
12
+ import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
13
+
14
+ export async function getStaticPaths() {
15
+ const paths = [];
16
+
17
+ for (const { entry, Component } of ALL_TOOLS) {
18
+ const localeEntries = Object.entries(entry.i18n) as [
19
+ KnownLocale,
20
+ () => Promise<ToolLocaleContent>,
21
+ ][];
22
+ const localeContents = await Promise.all(
23
+ localeEntries.map(async ([locale, loader]) => ({
24
+ locale,
25
+ content: await loader(),
26
+ })),
27
+ );
28
+
29
+ const localeUrls = Object.fromEntries(
30
+ localeContents.map(({ locale, content }) => [
31
+ locale,
32
+ `/${locale}/${content.slug}`,
33
+ ]),
34
+ ) as Partial<Record<KnownLocale, string>>;
35
+
36
+ for (const { locale, content } of localeContents) {
37
+ const allToolsNav = await Promise.all(
38
+ ALL_TOOLS.map(async ({ entry: navEntry }) => ({
39
+ id: navEntry.id,
40
+ title: (await navEntry.i18n[locale]!()).title,
41
+ href: `/${locale}/${(await navEntry.i18n[locale]!()).slug}`,
42
+ isActive: navEntry.id === entry.id,
43
+ })),
44
+ );
45
+ paths.push({
46
+ params: { locale, slug: content.slug },
47
+ props: { Component, locale, content, localeUrls, allToolsNav },
48
+ });
49
+ }
50
+ }
51
+
52
+ return paths;
53
+ }
54
+
55
+ type ToolComponent = (props: { ui: Record<string, string> }) => unknown;
56
+
57
+ interface NavItem {
58
+ id: string;
59
+ title: string;
60
+ href: string;
61
+ isActive?: boolean;
62
+ }
63
+
64
+ interface Props {
65
+ Component: ToolComponent;
66
+ locale: KnownLocale;
67
+ content: ToolLocaleContent;
68
+ localeUrls: Partial<Record<KnownLocale, string>>;
69
+ allToolsNav: NavItem[];
70
+ }
71
+
72
+ const { Component, locale, content, localeUrls, allToolsNav } = Astro.props;
73
+
74
+ const seoContent: UtilitySEOContent = { locale, sections: content.seo };
75
+
76
+ const words = content.title.split(" ");
77
+ const titleHighlight = words[0] || "";
78
+ const titleBase = words.slice(1).join(" ") || "";
79
+ ---
80
+
81
+ <PreviewLayout
82
+ title={content.title}
83
+ currentLocale={locale}
84
+ localeUrls={localeUrls}
85
+ hasSidebar={true}
86
+ >
87
+ <PreviewNavSidebar
88
+ slot="sidebar"
89
+ categoryTitle="Tools"
90
+ tools={allToolsNav}
91
+ />
92
+ <Fragment slot="head">
93
+ {
94
+ content.schemas.map((schema: unknown) => (
95
+ <script
96
+ is:inline
97
+ type="application/ld+json"
98
+ set:html={JSON.stringify(schema)}
99
+ />
100
+ ))
101
+ }
102
+ </Fragment>
103
+
104
+ <div class="tool-page">
105
+ <UtilityHeader
106
+ titleHighlight={titleHighlight}
107
+ titleBase={titleBase}
108
+ description={content.description}
109
+ />
110
+
111
+ <section class="section-tool">
112
+ <Component ui={content.ui} />
113
+ </section>
114
+
115
+ <section class="section-seo">
116
+ <SEORenderer content={seoContent} />
117
+ </section>
118
+
119
+ <section class="section-faq">
120
+ <FAQSection items={content.faq} />
121
+ </section>
122
+
123
+ <section class="section-bibliography">
124
+ <Bibliography links={content.bibliography} />
125
+ </section>
126
+ </div>
127
+ </PreviewLayout>
128
+
129
+ <style>
130
+ .tool-page {
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 2rem;
134
+ }
135
+ .section-tool {
136
+ max-width: 1200px;
137
+ margin: 0 auto;
138
+ width: 100%;
139
+ }
140
+ .section-seo,
141
+ .section-faq,
142
+ .section-bibliography {
143
+ padding-top: 2rem;
144
+ border-top: 1px solid var(--border-color);
145
+ }
146
+ </style>
@@ -0,0 +1,251 @@
1
+ ---
2
+ import PreviewLayout from '../layouts/PreviewLayout.astro';
3
+ import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
4
+ import { hardwareCategory, ALL_TOOLS } from '../index';
5
+ import { Icon } from 'astro-icon/components';
6
+ import type { KnownLocale, ToolLocaleContent } from '../types';
7
+
8
+ export async function getStaticPaths() {
9
+ const locales = ['en', 'es', 'fr'] as KnownLocale[];
10
+ return locales.map(locale => ({ params: { locale } }));
11
+ }
12
+
13
+ const { locale: currentLocale } = Astro.params as { locale: KnownLocale };
14
+
15
+ const categoryContent = await hardwareCategory.i18n[currentLocale]!();
16
+
17
+ const tools = ALL_TOOLS || [];
18
+
19
+ const toolsWithContent = tools.length > 0
20
+ ? await Promise.all(
21
+ tools.map(async ({ entry, Component }) => {
22
+ const languages = Object.keys(entry.i18n);
23
+ const localeEntries = await Promise.all(
24
+ languages.map(async (l) => {
25
+ const content = await entry.i18n[l as KnownLocale]!();
26
+ return [l, content];
27
+ })
28
+ );
29
+
30
+ const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
31
+
32
+ const currentLocaleContent = localeContents[currentLocale] || localeContents['en'] || localeContents['es'];
33
+ const availableLocales: Record<string, string> = {};
34
+
35
+ for (const l of languages) {
36
+ const lCont = localeContents[l];
37
+ if (lCont) {
38
+ availableLocales[l] = `/${l}/${lCont.slug}`;
39
+ }
40
+ }
41
+
42
+ return { entry, Component, locale: currentLocaleContent, availableLocales };
43
+ })
44
+ )
45
+ : [];
46
+ ---
47
+
48
+ <PreviewLayout
49
+ title={categoryContent.title}
50
+ currentLocale={currentLocale}
51
+ hasSidebar={true}
52
+ >
53
+ <PreviewNavSidebar
54
+ slot="sidebar"
55
+ categoryTitle={categoryContent.title}
56
+ tools={toolsWithContent.map(({ entry, locale, availableLocales }) => {
57
+ const href = availableLocales[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#');
58
+ return {
59
+ id: entry.id,
60
+ title: locale?.title || entry.id,
61
+ href: href,
62
+ };
63
+ })}
64
+ />
65
+ <div class="dashboard">
66
+ <header class="preview-header">
67
+ <span class="badge">preview · @jjlmoya/utils-hardware</span>
68
+ <h1>{categoryContent.title}</h1>
69
+ <p>{categoryContent.description}</p>
70
+ </header>
71
+
72
+ <div class="tool-list">
73
+ {toolsWithContent?.map(({ entry, locale, availableLocales }) => (
74
+ <article class="tool-card">
75
+ <a href={availableLocales?.[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#')} class="tool-card-link">
76
+ <div class="tool-icons">
77
+ <div class="icon-wrapper bg">
78
+ <Icon name={entry.icons.bg} />
79
+ </div>
80
+ <div class="icon-wrapper fg">
81
+ <Icon name={entry.icons.fg} />
82
+ </div>
83
+ </div>
84
+ <div class="tool-card-content">
85
+ <h2 class="tool-title">{locale?.title}</h2>
86
+ <p class="tool-description">{locale?.description}</p>
87
+ </div>
88
+ <div class="tool-card-meta">
89
+ <span class="tool-id">{entry.id}</span>
90
+ </div>
91
+ </a>
92
+
93
+ {availableLocales && Object.keys(availableLocales).length > 1 && (
94
+ <div class="tool-locales">
95
+ {Object.entries(availableLocales).map(([l, url]) => (
96
+ <a href={url} class="locale-badge" title={`Ver en ${l.toUpperCase()}`} class:list={{ active: l === currentLocale }}>
97
+ {l.toUpperCase()}
98
+ </a>
99
+ ))}
100
+ </div>
101
+ )}
102
+ </article>
103
+ ))}
104
+ </div>
105
+ </div>
106
+ </PreviewLayout>
107
+
108
+ <style>
109
+ .dashboard {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 5rem;
113
+ }
114
+ .preview-header {
115
+ text-align: center;
116
+ padding-bottom: 3rem;
117
+ border-bottom: 1px solid var(--border-color);
118
+ }
119
+ .badge {
120
+ display: inline-block;
121
+ padding: 0.25rem 0.75rem;
122
+ background: var(--accent);
123
+ border-radius: 99px;
124
+ font-size: 0.7rem;
125
+ font-weight: 800;
126
+ margin-bottom: 1.5rem;
127
+ color: var(--text-base);
128
+ letter-spacing: 0.05em;
129
+ }
130
+ h1 {
131
+ font-size: clamp(2rem, 6vw, 3.5rem);
132
+ font-weight: 900;
133
+ margin: 0 0 1rem;
134
+ background: linear-gradient(to bottom, var(--text-base), var(--text-muted));
135
+ -webkit-background-clip: text;
136
+ -webkit-text-fill-color: transparent;
137
+ background-clip: text;
138
+ }
139
+ .preview-header p {
140
+ color: var(--text-muted);
141
+ font-size: 1.1rem;
142
+ margin: 0;
143
+ }
144
+ .tool-list {
145
+ display: grid;
146
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
147
+ gap: 2rem;
148
+ }
149
+ .tool-card {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 1rem;
153
+ }
154
+ .tool-card-link {
155
+ flex: 1;
156
+ display: flex;
157
+ flex-direction: column;
158
+ padding: 1.5rem;
159
+ background: var(--bg-surface);
160
+ border: 1px solid var(--border-color);
161
+ border-radius: 0.75rem;
162
+ text-decoration: none;
163
+ transition: all 0.2s ease;
164
+ }
165
+ .tool-card-link:hover {
166
+ border-color: var(--accent);
167
+ background: rgba(244, 63, 94, 0.05);
168
+ transform: translateY(-2px);
169
+ }
170
+ .tool-icons {
171
+ display: flex;
172
+ align-items: center;
173
+ gap: 1rem;
174
+ margin-bottom: 1.25rem;
175
+ }
176
+ .icon-wrapper {
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ width: 3rem;
181
+ height: 3rem;
182
+ border-radius: 0.5rem;
183
+ font-size: 1.5rem;
184
+ }
185
+ .icon-wrapper.bg {
186
+ background: var(--accent);
187
+ color: var(--text-base);
188
+ }
189
+ .icon-wrapper.fg {
190
+ background: var(--bg-page);
191
+ border: 1px solid var(--border-color);
192
+ color: var(--accent);
193
+ }
194
+ .tool-card-content {
195
+ flex: 1;
196
+ display: flex;
197
+ flex-direction: column;
198
+ }
199
+ .tool-title {
200
+ font-size: 1.25rem;
201
+ font-weight: 700;
202
+ margin: 0 0 0.5rem;
203
+ color: var(--text-base);
204
+ }
205
+ .tool-description {
206
+ font-size: 0.9375rem;
207
+ color: var(--text-muted);
208
+ line-height: 1.5;
209
+ margin: 0;
210
+ }
211
+ .tool-card-meta {
212
+ padding-top: 1rem;
213
+ border-top: 1px solid var(--border-color);
214
+ margin-top: 1.5rem;
215
+ }
216
+ .tool-id {
217
+ display: inline-block;
218
+ font-size: 0.7rem;
219
+ background: var(--bg-page);
220
+ border: 1px solid var(--border-color);
221
+ padding: 0.35rem 0.75rem;
222
+ border-radius: 0.4rem;
223
+ color: var(--accent);
224
+ font-weight: 600;
225
+ }
226
+ .tool-locales {
227
+ display: flex;
228
+ gap: 0.5rem;
229
+ flex-wrap: wrap;
230
+ }
231
+ .locale-badge {
232
+ display: inline-block;
233
+ padding: 0.4rem 0.85rem;
234
+ background: var(--bg-page);
235
+ border: 1px solid var(--border-color);
236
+ border-radius: 0.4rem;
237
+ color: var(--text-muted);
238
+ text-decoration: none;
239
+ font-size: 0.75rem;
240
+ font-weight: 600;
241
+ text-transform: uppercase;
242
+ letter-spacing: 0.05em;
243
+ transition: all 0.15s ease;
244
+ }
245
+ .locale-badge:hover,
246
+ .locale-badge.active {
247
+ color: var(--accent);
248
+ border-color: var(--accent);
249
+ background: rgba(244, 63, 94, 0.1);
250
+ }
251
+ </style>
@@ -0,0 +1,4 @@
1
+ ---
2
+ ---
3
+ <meta http-equiv="refresh" content="0;url=/es" />
4
+