@jjlmoya/utils-tabletop 1.36.0 → 1.37.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.
- package/package.json +18 -4
- package/scripts/postbuild.mjs +17 -0
- package/scripts/postinstall.mjs +2 -4
- package/src/components/ProductionBreadcrumb.astro +132 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/i18n/header-ui.ts +15 -0
- package/src/i18n/language-ui.ts +9 -0
- package/src/i18n/languages.ts +24 -0
- package/src/identity/brands.ts +5 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +209 -0
- package/src/layouts/ProductionUtilityPage.astro +237 -0
- package/src/mfe/assets.ts +15 -0
- package/src/mfe/category-ui.ts +26 -0
- package/src/mfe/routes.ts +50 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
- package/src/pages/index.astro +1 -1
- package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/category_ui.test.ts +16 -0
- package/src/tests/mfe_assets_contract.test.ts +44 -0
- package/src/tests/routing_contract.test.ts +41 -0
- package/src/tool/board-game-timer/component.astro +0 -1
- package/src/tool/card-draw-odds-calculator/component.astro +0 -1
- package/src/tool/dungeon-map-generator/component.astro +2 -2
- package/src/tool/fantasy-runes-translator/component.astro +0 -1
- package/src/tool/first-player-selector/component.astro +0 -1
- package/src/tool/hidden-role-dealer/component.astro +0 -1
- package/src/tool/lunar-tide-tracker/component.astro +0 -1
- package/src/tool/mus-scoreboard/component.astro +0 -1
- package/src/tool/rpg-initiative-tracker/component.astro +0 -1
- package/src/tool/rpg-settlement-exploration-map-generator/component.astro +0 -1
- package/src/tool/scatter-direction-selector/component.astro +0 -1
- package/src/tool/score-tracker/component.astro +0 -1
- package/src/tool/token-stamp-studio/component.astro +0 -1
- package/src/types.ts +6 -8
- package/src/worker.ts +26 -0
- package/src/pages/[locale]/[slug].astro +0 -162
- package/src/pages/[locale].astro +0 -251
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Footer from "@jjlmoya/identity/footer";
|
|
3
|
+
import Header from "@jjlmoya/identity/header";
|
|
4
|
+
import LanguageSheet from "@jjlmoya/identity/language-sheet";
|
|
5
|
+
import LanguageTrigger from "@jjlmoya/identity/language-trigger";
|
|
6
|
+
import ThemeBoot from "@jjlmoya/identity/theme-boot";
|
|
7
|
+
import { getSiteNavigation } from "@jjlmoya/identity/navigation";
|
|
8
|
+
import "@jjlmoya/identity/styles.css";
|
|
9
|
+
import "@jjlmoya/utils-shared/theme.css";
|
|
10
|
+
import "@jjlmoya/utils-shared/styles/jjlmoya-shared.css";
|
|
11
|
+
import { HEADER_UI } from "../i18n/header-ui";
|
|
12
|
+
import { LANGUAGE_LOCALES, LANGUAGE_NAMES, type Language } from "../i18n/languages";
|
|
13
|
+
import { LANGUAGE_UI } from "../i18n/language-ui";
|
|
14
|
+
import { getBrand } from "../identity/brands";
|
|
15
|
+
|
|
16
|
+
interface AlternateLink { language: Language; url: string; }
|
|
17
|
+
interface Props {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
locale: Language;
|
|
21
|
+
image?: string | undefined;
|
|
22
|
+
toolCss?: string | undefined;
|
|
23
|
+
publicPath: string;
|
|
24
|
+
alternates: readonly AlternateLink[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { title, description, locale, image, toolCss, publicPath, alternates } = Astro.props;
|
|
28
|
+
const brand = getBrand(locale);
|
|
29
|
+
const origin = brand === "jjlmoya" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev";
|
|
30
|
+
const canonical = new URL(publicPath, origin).href;
|
|
31
|
+
const socialImage = image ? new URL(image, canonical).href : undefined;
|
|
32
|
+
const languageUi = LANGUAGE_UI[locale];
|
|
33
|
+
const absoluteUrl = (language: Language, url: string): string => {
|
|
34
|
+
if (import.meta.env.DEV) return new URL(url, "http://localhost:4324").pathname;
|
|
35
|
+
return new URL(
|
|
36
|
+
url,
|
|
37
|
+
language === "es" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev",
|
|
38
|
+
).href;
|
|
39
|
+
};
|
|
40
|
+
const languageLinks = alternates.map(({ language, url }) => ({
|
|
41
|
+
code: language,
|
|
42
|
+
name: LANGUAGE_NAMES[language],
|
|
43
|
+
href: absoluteUrl(language, url),
|
|
44
|
+
current: language === locale,
|
|
45
|
+
}));
|
|
46
|
+
const homeUrl = brand === "jjlmoya" ? "https://www.jjlmoya.es/" : `https://www.gamebob.dev/${locale}/`;
|
|
47
|
+
const openGraphLocale = LANGUAGE_LOCALES[locale].replace("-", "_");
|
|
48
|
+
const xDefaultUrl = alternates.find(({ language }) => language === "en")?.url ?? canonical;
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<!doctype html>
|
|
52
|
+
<html lang={locale} data-brand={brand}>
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="UTF-8" />
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
56
|
+
<ThemeBoot defaultTheme={brand === "gamebob" ? "theme-dark" : "theme-system"} />
|
|
57
|
+
<title>{title}</title>
|
|
58
|
+
<meta name="title" content={title} />
|
|
59
|
+
<meta name="description" content={description} />
|
|
60
|
+
<meta name="author" content="jjlmoya" />
|
|
61
|
+
<meta name="robots" content="index, follow, max-image-preview:large" />
|
|
62
|
+
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
|
63
|
+
<link rel="canonical" href={canonical} />
|
|
64
|
+
{alternates.map(({ language, url }) => <link rel="alternate" hreflang={language} href={absoluteUrl(language, url)} />)}
|
|
65
|
+
<link rel="alternate" hreflang="x-default" href={absoluteUrl("en", xDefaultUrl)} />
|
|
66
|
+
{toolCss && <link rel="stylesheet" href={toolCss} />}
|
|
67
|
+
<meta property="og:type" content="website" />
|
|
68
|
+
<meta property="og:url" content={canonical} />
|
|
69
|
+
<meta property="og:title" content={title} />
|
|
70
|
+
<meta property="og:description" content={description} />
|
|
71
|
+
<meta property="og:site_name" content={brand === "jjlmoya" ? "jjlmoya" : "GameBob Studio"} />
|
|
72
|
+
<meta property="og:locale" content={openGraphLocale} />
|
|
73
|
+
{socialImage && <meta property="og:image" content={socialImage} />}
|
|
74
|
+
{socialImage && <meta property="og:image:secure_url" content={socialImage} />}
|
|
75
|
+
{socialImage && <meta property="og:image:type" content="image/webp" />}
|
|
76
|
+
{socialImage && <meta property="og:image:width" content="1200" />}
|
|
77
|
+
{socialImage && <meta property="og:image:height" content="630" />}
|
|
78
|
+
{socialImage && <meta property="og:image:alt" content={description} />}
|
|
79
|
+
<meta name="twitter:card" content={socialImage ? "summary_large_image" : "summary"} />
|
|
80
|
+
<meta name="twitter:url" content={canonical} />
|
|
81
|
+
<meta name="twitter:title" content={title} />
|
|
82
|
+
<meta name="twitter:description" content={description} />
|
|
83
|
+
{socialImage && <meta name="twitter:image" content={socialImage} />}
|
|
84
|
+
<script is:inline type="application/ld+json" set:html={JSON.stringify({
|
|
85
|
+
"@context": "https://schema.org",
|
|
86
|
+
"@type": "WebPage",
|
|
87
|
+
url: canonical,
|
|
88
|
+
name: title,
|
|
89
|
+
description,
|
|
90
|
+
inLanguage: LANGUAGE_LOCALES[locale],
|
|
91
|
+
})} />
|
|
92
|
+
<script>
|
|
93
|
+
import { installEcosystemTracking } from "@jjlmoya/tracking";
|
|
94
|
+
installEcosystemTracking();
|
|
95
|
+
</script>
|
|
96
|
+
<slot name="head" />
|
|
97
|
+
</head>
|
|
98
|
+
<body class="production-page">
|
|
99
|
+
<Header
|
|
100
|
+
class="utilities-site-header"
|
|
101
|
+
identity={brand}
|
|
102
|
+
{homeUrl}
|
|
103
|
+
navigation={getSiteNavigation({ language: locale, activeSection: "utilities" })}
|
|
104
|
+
labels={HEADER_UI[locale]}
|
|
105
|
+
>
|
|
106
|
+
<LanguageTrigger slot="desktop-actions" label={languageUi.change} />
|
|
107
|
+
<LanguageTrigger slot="mobile-actions" label={languageUi.change} />
|
|
108
|
+
</Header>
|
|
109
|
+
<LanguageSheet links={languageLinks} title={languageUi.select} closeLabel={languageUi.close} />
|
|
110
|
+
<main><slot /></main>
|
|
111
|
+
<Footer class="utilities-site-footer" identity={brand} language={locale} />
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
114
|
+
|
|
115
|
+
<style is:global>
|
|
116
|
+
:root {
|
|
117
|
+
--tabletop-accent: #10b981;
|
|
118
|
+
--tabletop-accent-strong: #0d9488;
|
|
119
|
+
--color-brand-blue: #1ddabc;
|
|
120
|
+
--color-brand-dark: #000;
|
|
121
|
+
--color-brand-white: #fff;
|
|
122
|
+
--color-primary: var(--color-brand-blue);
|
|
123
|
+
--color-primary-rgb: 29, 218, 188;
|
|
124
|
+
--space-2xs: 0.125rem;
|
|
125
|
+
--space-xs: 0.25rem;
|
|
126
|
+
--space-sm: 0.5rem;
|
|
127
|
+
--space-base: 0.75rem;
|
|
128
|
+
--space-md: 1rem;
|
|
129
|
+
--space-md-lg: 1.25rem;
|
|
130
|
+
--space-lg: 1.5rem;
|
|
131
|
+
--space-xl: 2rem;
|
|
132
|
+
--space-1-5xl: 2.5rem;
|
|
133
|
+
--space-2xl: 3rem;
|
|
134
|
+
--space-3xl: 4rem;
|
|
135
|
+
--space-4xl: 6rem;
|
|
136
|
+
--space-3-5xl: 5rem;
|
|
137
|
+
--radius-base: 0.5rem;
|
|
138
|
+
--radius-lg: 1rem;
|
|
139
|
+
--radius-2xl: 2rem;
|
|
140
|
+
--radius-full: 9999px;
|
|
141
|
+
--font-family: inter, system-ui, sans-serif;
|
|
142
|
+
--font-weight-bold: 800;
|
|
143
|
+
--color-white: #fff;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
:root,
|
|
147
|
+
.theme-light {
|
|
148
|
+
--bg-main: #f0f4f8;
|
|
149
|
+
--bg-page: #f0f4f8;
|
|
150
|
+
--bg-surface: #fff;
|
|
151
|
+
--bg-muted: #f1f5f9;
|
|
152
|
+
--text-main: #000;
|
|
153
|
+
--text-base: #000;
|
|
154
|
+
--text-muted: #4a5568;
|
|
155
|
+
--text-dimmed: #64748b;
|
|
156
|
+
--border-base: #e2e8f0;
|
|
157
|
+
--border-color: #e2e8f0;
|
|
158
|
+
--glass-bg: rgba(0, 0, 0, 0.05);
|
|
159
|
+
--glass-border: rgba(0, 0, 0, 0.1);
|
|
160
|
+
--shadow-base: rgba(0, 0, 0, 0.05);
|
|
161
|
+
--shadow-hover: rgba(0, 0, 0, 0.1);
|
|
162
|
+
--primary: var(--color-primary);
|
|
163
|
+
--primary-10: rgba(29, 218, 188, 0.1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.theme-dark {
|
|
167
|
+
--bg-main: #000;
|
|
168
|
+
--bg-page: #000;
|
|
169
|
+
--bg-surface: #0f172a;
|
|
170
|
+
--bg-muted: #1e293b;
|
|
171
|
+
--text-main: #fff;
|
|
172
|
+
--text-base: #fff;
|
|
173
|
+
--text-muted: #94a3b8;
|
|
174
|
+
--text-dimmed: #64748b;
|
|
175
|
+
--border-base: #1e293b;
|
|
176
|
+
--border-color: #1e293b;
|
|
177
|
+
--glass-bg: rgba(255, 255, 255, 0.03);
|
|
178
|
+
--glass-border: rgba(255, 255, 255, 0.08);
|
|
179
|
+
--shadow-base: rgba(0, 0, 0, 0.3);
|
|
180
|
+
--shadow-hover: rgba(0, 0, 0, 0.4);
|
|
181
|
+
--primary: var(--color-primary);
|
|
182
|
+
--primary-10: rgba(29, 218, 188, 0.1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
*, *::before, *::after {
|
|
186
|
+
box-sizing: border-box;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
html {
|
|
190
|
+
background: var(--bg-main);
|
|
191
|
+
color: var(--text-main);
|
|
192
|
+
scroll-behavior: smooth;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
body.production-page {
|
|
196
|
+
min-width: 320px;
|
|
197
|
+
min-height: 100vh;
|
|
198
|
+
margin: 0;
|
|
199
|
+
padding-top: 4.5rem;
|
|
200
|
+
background: var(--bg-main);
|
|
201
|
+
color: var(--text-main);
|
|
202
|
+
font-family: var(--font-family, Inter, system-ui, sans-serif);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
body.production-page main {
|
|
206
|
+
width: 100%;
|
|
207
|
+
min-height: 100vh;
|
|
208
|
+
}
|
|
209
|
+
</style>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionPage from "./ProductionPage.astro";
|
|
3
|
+
import { FAQSection, UtilityHeader } from "@jjlmoya/utils-shared";
|
|
4
|
+
import { Icon } from "astro-icon/components";
|
|
5
|
+
import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
|
|
6
|
+
import ProductionWidget from "../components/ProductionWidget.astro";
|
|
7
|
+
import type { KnownLocale, ToolLocaleContent } from "../types";
|
|
8
|
+
import { getCategoryUi } from "../mfe/category-ui";
|
|
9
|
+
import { getCategoryPath, getUtilityPath } from "../mfe/routes";
|
|
10
|
+
import { getUtilityCssPath } from "../mfe/assets";
|
|
11
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
12
|
+
|
|
13
|
+
interface RelatedTool { title: string; description: string; href: string; icon: string; }
|
|
14
|
+
interface Props { locale: KnownLocale; content: ToolLocaleContent; englishSlug: string; categoryTitle: string; categorySlug: string; categoryHref: string; relatedTools: RelatedTool[]; Component: AstroComponentFactory; SEOComponent: AstroComponentFactory; BibliographyComponent: AstroComponentFactory; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
|
|
15
|
+
const { locale, content, englishSlug, categoryTitle, categorySlug, categoryHref, relatedTools, Component, SEOComponent, BibliographyComponent, alternates, image } = Astro.props;
|
|
16
|
+
const words = content.title.split(/\s+/).filter(Boolean);
|
|
17
|
+
const titleHighlight = words[0] ?? content.title;
|
|
18
|
+
const titleBase = words.slice(1).join(" ");
|
|
19
|
+
const publicPath = getUtilityPath(locale, categorySlug, content.slug);
|
|
20
|
+
const publicCategoryPath = getCategoryPath(locale, categorySlug);
|
|
21
|
+
const ui = getCategoryUi(locale);
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<ProductionPage title={content.title} description={content.description} {locale} {publicPath} {alternates} {image} toolCss={getUtilityCssPath(englishSlug)}>
|
|
25
|
+
<div class="utility-production">
|
|
26
|
+
<div class="utility-production-inner">
|
|
27
|
+
<ProductionBreadcrumb locale={locale} categoryTitle={categoryTitle} categoryHref={categoryHref || publicCategoryPath} currentTitle={content.title} />
|
|
28
|
+
<UtilityHeader titleHighlight={titleHighlight} titleBase={titleBase} description={content.description} gradientFrom="#10b981" gradientTo="#0d9488" />
|
|
29
|
+
<section class="utility-tool-production"><Component ui={content.ui} locale={locale} /></section>
|
|
30
|
+
<div class="utility-zoom-production" aria-label="Zoom controls">
|
|
31
|
+
<span class="utility-zoom-label">Zoom</span>
|
|
32
|
+
<div class="utility-zoom-controls"><button type="button" data-zoom="out" aria-label="Zoom out">−</button><button type="button" data-zoom="reset" aria-label="Reset zoom">100%</button><button type="button" data-zoom="in" aria-label="Zoom in">+</button></div>
|
|
33
|
+
</div>
|
|
34
|
+
<ProductionWidget locale={locale} slug={content.slug} />
|
|
35
|
+
{content.faq.length > 0 && <section class="utility-section-production"><FAQSection items={content.faq} /></section>}
|
|
36
|
+
<Fragment slot="head">
|
|
37
|
+
{(content.schemas ?? []).map((schema) => <script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />)}
|
|
38
|
+
</Fragment>
|
|
39
|
+
<section class="utility-section-production"><SEOComponent locale={locale} /></section>
|
|
40
|
+
{content.bibliography.length > 0 && <section class="utility-section-production"><BibliographyComponent locale={locale} /></section>}
|
|
41
|
+
{relatedTools.length > 0 && <section class="utility-related-production" aria-labelledby="related-tools-title"><div class="utility-related-heading"><span class="utility-related-eyebrow">Keep exploring</span><h2 id="related-tools-title">More tools in {categoryTitle}</h2></div><div class="utility-related-grid">{relatedTools.map((tool, index) => <a href={tool.href} class="utility-related-card"><span class="utility-related-index">{String(index + 1).padStart(2, "0")}</span><span class="utility-related-icon"><Icon name={tool.icon} /></span><h3>{tool.title}</h3><p>{tool.description}</p><span class="utility-related-cta">{ui.openTool} <Icon name="mdi:arrow-top-right" /></span></a>)}</div></section>}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</ProductionPage>
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
const container = document.querySelector<HTMLElement>(".utility-tool-production");
|
|
48
|
+
let zoom = Number(localStorage.getItem("gamebob-utility-zoom") ?? 1);
|
|
49
|
+
const applyZoom = () => { if (container) container.style.zoom = String(zoom); };
|
|
50
|
+
document.querySelector('[data-zoom="out"]')?.addEventListener("click", () => { zoom = Math.max(0.75, zoom - 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
|
|
51
|
+
document.querySelector('[data-zoom="in"]')?.addEventListener("click", () => { zoom = Math.min(1.5, zoom + 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
|
|
52
|
+
document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
|
|
53
|
+
applyZoom();
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.utility-production {
|
|
58
|
+
min-height: 100vh;
|
|
59
|
+
overflow-x: clip;
|
|
60
|
+
background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.utility-production-inner {
|
|
64
|
+
max-width: 72rem;
|
|
65
|
+
margin: 0 auto;
|
|
66
|
+
padding: var(--space-xl) var(--space-lg) var(--space-4xl);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.utility-tool-production {
|
|
70
|
+
width: 100%;
|
|
71
|
+
margin-top: var(--space-2xl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.utility-zoom-production {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
gap: 0.75rem;
|
|
79
|
+
margin: var(--space-xl) auto 0;
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
font-size: 0.8rem;
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: 0.08em;
|
|
84
|
+
text-transform: uppercase;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.utility-zoom-controls {
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
border: 1px solid var(--border-base);
|
|
91
|
+
border-radius: 0.75rem;
|
|
92
|
+
background: color-mix(in srgb, var(--bg-surface) 90%, transparent);
|
|
93
|
+
box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.utility-zoom-production button {
|
|
97
|
+
min-width: 2.5rem;
|
|
98
|
+
border: 0;
|
|
99
|
+
border-right: 1px solid var(--border-base);
|
|
100
|
+
padding: 0.5rem 0.75rem;
|
|
101
|
+
background: transparent;
|
|
102
|
+
color: var(--text-main);
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
font-size: 1rem;
|
|
105
|
+
}
|
|
106
|
+
.utility-zoom-production button:last-child { border-right: 0; }
|
|
107
|
+
|
|
108
|
+
.utility-zoom-production button:hover,
|
|
109
|
+
.utility-zoom-production button:focus-visible {
|
|
110
|
+
background: color-mix(in srgb, var(--tabletop-accent) 12%, transparent);
|
|
111
|
+
color: var(--tabletop-accent);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.utility-section-production, .utility-related-production {
|
|
115
|
+
max-width: 56rem;
|
|
116
|
+
margin: var(--space-4xl) auto 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.utility-section-production {
|
|
120
|
+
font-size: 1.125rem;
|
|
121
|
+
line-height: 1.75;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.utility-related-heading {
|
|
125
|
+
margin-bottom: var(--space-lg);
|
|
126
|
+
text-align: center;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.utility-related-eyebrow {
|
|
130
|
+
display: block;
|
|
131
|
+
margin-bottom: 0.45rem;
|
|
132
|
+
color: var(--tabletop-accent);
|
|
133
|
+
font-size: 0.75rem;
|
|
134
|
+
font-weight: 800;
|
|
135
|
+
letter-spacing: 0.14em;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.utility-related-production h2 {
|
|
140
|
+
margin: 0;
|
|
141
|
+
color: var(--text-main);
|
|
142
|
+
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.utility-related-grid {
|
|
146
|
+
display: grid;
|
|
147
|
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
148
|
+
gap: 1rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.utility-related-card {
|
|
152
|
+
position: relative;
|
|
153
|
+
display: flex;
|
|
154
|
+
min-height: 238px;
|
|
155
|
+
flex-direction: column;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
padding: 1.25rem;
|
|
158
|
+
border: 1px solid color-mix(in srgb, var(--tabletop-accent) 18%, var(--border-base));
|
|
159
|
+
border-radius: 1rem;
|
|
160
|
+
background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--tabletop-accent) 4%, var(--bg-surface)));
|
|
161
|
+
color: inherit;
|
|
162
|
+
text-decoration: none;
|
|
163
|
+
box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--shadow-base, #000) 30%, transparent);
|
|
164
|
+
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.utility-related-card:hover,
|
|
168
|
+
.utility-related-card:focus-visible {
|
|
169
|
+
border-color: var(--tabletop-accent);
|
|
170
|
+
box-shadow: 0 1rem 2rem color-mix(in srgb, var(--tabletop-accent) 16%, transparent);
|
|
171
|
+
transform: translateY(-3px);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.utility-related-card:focus-visible {
|
|
175
|
+
outline: 3px solid color-mix(in srgb, var(--tabletop-accent) 55%, transparent);
|
|
176
|
+
outline-offset: 3px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.utility-related-index {
|
|
180
|
+
position: absolute;
|
|
181
|
+
top: 1.25rem;
|
|
182
|
+
right: 1.25rem;
|
|
183
|
+
color: var(--text-dimmed, var(--text-muted));
|
|
184
|
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
185
|
+
font-size: 0.7rem;
|
|
186
|
+
letter-spacing: 0.12em;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.utility-related-icon {
|
|
190
|
+
display: grid;
|
|
191
|
+
width: 2.75rem;
|
|
192
|
+
height: 2.75rem;
|
|
193
|
+
place-items: center;
|
|
194
|
+
margin-bottom: 1rem;
|
|
195
|
+
border: 1px solid color-mix(in srgb, var(--tabletop-accent) 28%, transparent);
|
|
196
|
+
border-radius: 0.8rem;
|
|
197
|
+
background: color-mix(in srgb, var(--tabletop-accent) 12%, var(--bg-surface));
|
|
198
|
+
color: var(--tabletop-accent);
|
|
199
|
+
font-size: 1.35rem;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.utility-related-card h3 {
|
|
203
|
+
margin: 0 0 0.5rem;
|
|
204
|
+
color: var(--text-main);
|
|
205
|
+
font-size: 1rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.utility-related-card p {
|
|
209
|
+
margin: 0 0 1rem;
|
|
210
|
+
color: var(--text-muted);
|
|
211
|
+
font-size: 0.9rem;
|
|
212
|
+
line-height: 1.5;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.utility-related-cta {
|
|
216
|
+
display: inline-flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 0.4rem;
|
|
219
|
+
margin-top: auto;
|
|
220
|
+
color: var(--tabletop-accent);
|
|
221
|
+
font-size: 0.75rem;
|
|
222
|
+
font-weight: 800;
|
|
223
|
+
letter-spacing: 0.08em;
|
|
224
|
+
text-transform: uppercase;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.utility-related-cta svg {
|
|
228
|
+
width: 1rem;
|
|
229
|
+
height: 1rem;
|
|
230
|
+
transition: transform 0.2s ease;
|
|
231
|
+
}
|
|
232
|
+
.utility-related-card:hover .utility-related-cta svg { transform: translate(2px, -2px); }
|
|
233
|
+
|
|
234
|
+
@media (width <= 640px) {
|
|
235
|
+
.utility-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
|
|
236
|
+
}
|
|
237
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import packageMetadata from "../../package.json";
|
|
2
|
+
|
|
3
|
+
export const TABLETOP_ASSET_VERSION = encodeURIComponent(
|
|
4
|
+
import.meta.env.PUBLIC_APP_VERSION ?? packageMetadata.version,
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
const withAssetVersion = (path: string): string => `${path}?version=${TABLETOP_ASSET_VERSION}`;
|
|
8
|
+
|
|
9
|
+
export const getUtilityOgImage = (englishSlug: string): string =>
|
|
10
|
+
withAssetVersion(`/_utilities/tabletop/images/${englishSlug}.webp`);
|
|
11
|
+
|
|
12
|
+
export const getUtilityCssPath = (englishSlug: string): string =>
|
|
13
|
+
withAssetVersion(`/_utilities/tabletop/styles/${englishSlug}.css`);
|
|
14
|
+
|
|
15
|
+
export const CATEGORY_OG_IMAGE = getUtilityOgImage("tabletop");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { KnownLocale } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface CategoryUi {
|
|
4
|
+
useTool: string;
|
|
5
|
+
openTool: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
|
|
9
|
+
es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta' },
|
|
10
|
+
en: { useTool: 'Use tool', openTool: 'Open tool' },
|
|
11
|
+
fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil" },
|
|
12
|
+
de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen' },
|
|
13
|
+
it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento' },
|
|
14
|
+
pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta' },
|
|
15
|
+
nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen' },
|
|
16
|
+
sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget' },
|
|
17
|
+
pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie' },
|
|
18
|
+
id: { useTool: 'Gunakan alat', openTool: 'Buka alat' },
|
|
19
|
+
tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç' },
|
|
20
|
+
ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент' },
|
|
21
|
+
ja: { useTool: 'ツールを使う', openTool: 'ツールを開く' },
|
|
22
|
+
ko: { useTool: '도구 사용', openTool: '도구 열기' },
|
|
23
|
+
zh: { useTool: '使用工具', openTool: '打开工具' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCategoryNamespace,
|
|
3
|
+
getCategoryPath as getSharedCategoryPath,
|
|
4
|
+
getUtilitiesPath as getSharedUtilitiesPath,
|
|
5
|
+
getUtilityNamespace,
|
|
6
|
+
getUtilityPath as getSharedUtilityPath,
|
|
7
|
+
type UtilityLocale,
|
|
8
|
+
} from "@jjlmoya/utils-shared/routing";
|
|
9
|
+
|
|
10
|
+
export type { UtilityLocale };
|
|
11
|
+
export { getCategoryNamespace, getUtilityNamespace };
|
|
12
|
+
|
|
13
|
+
export const INTERNAL_LOCALES = [
|
|
14
|
+
"en", "fr", "de", "it", "pt", "nl", "sv", "pl", "id", "tr", "ru", "ja", "ko", "zh",
|
|
15
|
+
] as const satisfies readonly UtilityLocale[];
|
|
16
|
+
|
|
17
|
+
const getDevelopmentPath = (url: string): string => {
|
|
18
|
+
if (!import.meta.env.DEV) return url;
|
|
19
|
+
try {
|
|
20
|
+
return new URL(url, "http://localhost:4324").pathname;
|
|
21
|
+
} catch {
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getUtilitiesPath = (locale: UtilityLocale): string =>
|
|
27
|
+
getDevelopmentPath(getSharedUtilitiesPath(locale));
|
|
28
|
+
|
|
29
|
+
export const getCategoryPath = (locale: UtilityLocale, categorySlug: string): string =>
|
|
30
|
+
getDevelopmentPath(getSharedCategoryPath(locale, categorySlug));
|
|
31
|
+
|
|
32
|
+
export const getUtilityPath = (
|
|
33
|
+
locale: UtilityLocale,
|
|
34
|
+
categorySlug: string,
|
|
35
|
+
toolSlug: string,
|
|
36
|
+
): string => getDevelopmentPath(getSharedUtilityPath(locale, categorySlug, toolSlug));
|
|
37
|
+
|
|
38
|
+
export const getCategoryRoute = (locale: UtilityLocale, categorySlug: string): string =>
|
|
39
|
+
getCategoryPath(locale, categorySlug);
|
|
40
|
+
|
|
41
|
+
export const getUtilityRoute = (locale: UtilityLocale, categorySlug: string, toolSlug: string): string =>
|
|
42
|
+
getUtilityPath(locale, categorySlug, toolSlug);
|
|
43
|
+
|
|
44
|
+
export const getUtilityIndexRoute = (locale: UtilityLocale): string => getUtilitiesPath(locale);
|
|
45
|
+
|
|
46
|
+
export const getInternalCategoryRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string): string =>
|
|
47
|
+
getCategoryPath(locale, categorySlug);
|
|
48
|
+
|
|
49
|
+
export const getInternalUtilityRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string, toolSlug: string): string =>
|
|
50
|
+
getUtilityPath(locale, categorySlug, toolSlug);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionUtilityPage from "../../../../../layouts/ProductionUtilityPage.astro";
|
|
3
|
+
import { ALL_TOOLS, tabletopCategory } from "../../../../../index";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../../../../i18n/languages";
|
|
5
|
+
import type { CategoryLocaleContent, ToolLocaleContent } from "../../../../../types";
|
|
6
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
7
|
+
import { getCategoryNamespace, getCategoryPath, getUtilityNamespace, getUtilityPath } from "../../../../../mfe/routes";
|
|
8
|
+
import { getUtilityOgImage } from "../../../../../mfe/assets";
|
|
9
|
+
|
|
10
|
+
type Loader<T> = () => Promise<{ default: T }>;
|
|
11
|
+
type LoadedTool = ToolLocaleContent;
|
|
12
|
+
|
|
13
|
+
export async function getStaticPaths() {
|
|
14
|
+
const paths = [];
|
|
15
|
+
for (const { entry, Component: componentLoader, SEOComponent: seoLoader, BibliographyComponent: bibliographyLoader } of ALL_TOOLS) {
|
|
16
|
+
const contents = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => {
|
|
17
|
+
const loader = entry.i18n[language] ?? entry.i18n.en;
|
|
18
|
+
if (!loader) throw new Error(`Missing ${language} locale for ${entry.id}`);
|
|
19
|
+
return [language, await loader()];
|
|
20
|
+
}))) as Record<Language, LoadedTool>;
|
|
21
|
+
const categories = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
|
|
22
|
+
language,
|
|
23
|
+
await tabletopCategory.i18n[language]!(),
|
|
24
|
+
]))) as Record<Language, CategoryLocaleContent>;
|
|
25
|
+
const { default: Component } = await (componentLoader as Loader<AstroComponentFactory>)();
|
|
26
|
+
const { default: SEOComponent } = await (seoLoader as Loader<AstroComponentFactory>)();
|
|
27
|
+
const { default: BibliographyComponent } = await (bibliographyLoader as Loader<AstroComponentFactory>)();
|
|
28
|
+
for (const locale of LANGUAGE_CODES.filter((candidate) => candidate !== "es")) {
|
|
29
|
+
const content = contents[locale];
|
|
30
|
+
const category = categories[locale];
|
|
31
|
+
paths.push({
|
|
32
|
+
params: {
|
|
33
|
+
locale,
|
|
34
|
+
utilities: getUtilityNamespace(locale),
|
|
35
|
+
categories: getCategoryNamespace(locale),
|
|
36
|
+
category: category.slug,
|
|
37
|
+
slug: content.slug,
|
|
38
|
+
},
|
|
39
|
+
props: {
|
|
40
|
+
locale,
|
|
41
|
+
content,
|
|
42
|
+
englishSlug: contents.en.slug,
|
|
43
|
+
category,
|
|
44
|
+
categorySlug: category.slug,
|
|
45
|
+
Component,
|
|
46
|
+
SEOComponent,
|
|
47
|
+
BibliographyComponent,
|
|
48
|
+
alternates: LANGUAGE_CODES.map((language) => ({
|
|
49
|
+
language,
|
|
50
|
+
url: getUtilityPath(language, categories[language].slug, contents[language].slug),
|
|
51
|
+
})),
|
|
52
|
+
image: getUtilityOgImage(contents.en.slug),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return paths;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { locale, content, englishSlug, category, categorySlug, Component, SEOComponent, BibliographyComponent, relatedTools, alternates, image } = Astro.props as {
|
|
61
|
+
locale: Exclude<Language, "es">;
|
|
62
|
+
content: LoadedTool;
|
|
63
|
+
englishSlug: string;
|
|
64
|
+
category: CategoryLocaleContent;
|
|
65
|
+
categorySlug: string;
|
|
66
|
+
Component: AstroComponentFactory;
|
|
67
|
+
SEOComponent: AstroComponentFactory;
|
|
68
|
+
BibliographyComponent: AstroComponentFactory;
|
|
69
|
+
relatedTools?: { title: string; description: string; href: string }[];
|
|
70
|
+
alternates: { language: Language; url: string }[];
|
|
71
|
+
image?: string;
|
|
72
|
+
};
|
|
73
|
+
const related = relatedTools ?? (await Promise.all(ALL_TOOLS.map(async ({ entry: relatedEntry }) => {
|
|
74
|
+
const loader = relatedEntry.i18n[locale] ?? relatedEntry.i18n.en;
|
|
75
|
+
if (!loader) return null;
|
|
76
|
+
const relatedContent = await loader();
|
|
77
|
+
if (relatedContent.slug === content.slug) return null;
|
|
78
|
+
return {
|
|
79
|
+
icon: relatedEntry.icons.fg,
|
|
80
|
+
title: relatedContent.title,
|
|
81
|
+
description: relatedContent.description,
|
|
82
|
+
href: getUtilityPath(locale, categorySlug, relatedContent.slug),
|
|
83
|
+
};
|
|
84
|
+
}))).filter((tool): tool is { icon: string; title: string; description: string; href: string } => tool !== null);
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<ProductionUtilityPage
|
|
88
|
+
{locale}
|
|
89
|
+
{content}
|
|
90
|
+
{englishSlug}
|
|
91
|
+
categoryTitle={category.title}
|
|
92
|
+
{categorySlug}
|
|
93
|
+
categoryHref={getCategoryPath(locale, categorySlug)}
|
|
94
|
+
relatedTools={related}
|
|
95
|
+
{Component}
|
|
96
|
+
{SEOComponent}
|
|
97
|
+
{BibliographyComponent}
|
|
98
|
+
{alternates}
|
|
99
|
+
{image}
|
|
100
|
+
/>
|