This commit is contained in:
babayaga 2025-08-16 09:16:16 +02:00
parent 9a6fc75b4c
commit 7ff02e2ea9
11 changed files with 703 additions and 1 deletions

View File

@ -7,6 +7,7 @@
},
"exports": {
".": "./dist/index.js",
"./base/*": "./dist/base/*",
"./components/*": "./src/components/*"
},
"files": [

View File

@ -0,0 +1,273 @@
import { parse } from 'node-xlsx'
import { sync as exists } from '@polymech/fs/exists'
import pkg from 'showdown'
const { Converter } = pkg
export const md2html = (content) => {
let converter = new Converter({ tables: true });
converter.setOption('literalMidWordUnderscores', 'true');
return converter.makeHtml(content);
}
/**
* @typedef MarkdownTableOptions
* @property {string|null|Array.<string|null|undefined>} [align]
* @property {boolean} [padding=true]
* @property {boolean} [delimiterStart=true]
* @property {boolean} [delimiterStart=true]
* @property {boolean} [delimiterEnd=true]
* @property {boolean} [alignDelimiters=true]
* @property {(value: string) => number} [stringLength]
*/
/**
* Create a table from a matrix of strings.
*
* from : https://github.com/wooorm/markdown-table/blob/main/index.js
*
*
*
* @param {Array.<Array.<string|null|undefined>>} table
* @param {MarkdownTableOptions} [options]
* @returns {string}
*/
export const markdownTable = (table, options: any = {}) => {
const align = (options.align || []).concat()
const stringLength = options.stringLength || defaultStringLength
/** @type {Array<number>} Character codes as symbols for alignment per column. */
const alignments: number[] = []
/** @type {Array<Array<string>>} Cells per row. */
const cellMatrix: string[][] = []
/** @type {Array<Array<number>>} Sizes of each cell per row. */
const sizeMatrix: number[][] = []
/** @type {Array<number>} */
const longestCellByColumn: number[] = []
let mostCellsPerRow = 0
let rowIndex = -1
// This is a superfluous loop if we don’t align delimiters, but otherwise we’d
// do superfluous work when aligning, so optimize for aligning.
while (++rowIndex < table.length) {
/** @type {Array<string>} */
const row: string[] = []
/** @type {Array<number>} */
const sizes: number[] = []
let columnIndex = -1
if (table[rowIndex].length > mostCellsPerRow) {
mostCellsPerRow = table[rowIndex].length
}
while (++columnIndex < table[rowIndex].length) {
const cell = serialize(table[rowIndex][columnIndex])
if (options.alignDelimiters !== false) {
const size = stringLength(cell)
sizes[columnIndex] = size
if (
longestCellByColumn[columnIndex] === undefined ||
size > longestCellByColumn[columnIndex]
) {
longestCellByColumn[columnIndex] = size
}
}
row.push(cell)
}
cellMatrix[rowIndex] = row
sizeMatrix[rowIndex] = sizes
}
// Figure out which alignments to use.
let columnIndex = -1
if (typeof align === 'object' && 'length' in align) {
while (++columnIndex < mostCellsPerRow) {
alignments[columnIndex] = toAlignment(align[columnIndex])
}
} else {
const code = toAlignment(align)
while (++columnIndex < mostCellsPerRow) {
alignments[columnIndex] = code
}
}
// Inject the alignment row.
columnIndex = -1
/** @type {Array<string>} */
const row: string[] = []
/** @type {Array<number>} */
const sizes: number[] = []
while (++columnIndex < mostCellsPerRow) {
const code = alignments[columnIndex]
let before = ''
let after = ''
if (code === 99 /* `c` */) {
before = ':'
after = ':'
} else if (code === 108 /* `l` */) {
before = ':'
} else if (code === 114 /* `r` */) {
after = ':'
}
// There *must* be at least one hyphen-minus in each alignment cell.
let size =
options.alignDelimiters === false
? 1
: Math.max(
1,
longestCellByColumn[columnIndex] - before.length - after.length
)
const cell = before + '-'.repeat(size) + after
if (options.alignDelimiters !== false) {
size = before.length + size + after.length
if (size > longestCellByColumn[columnIndex]) {
longestCellByColumn[columnIndex] = size
}
sizes[columnIndex] = size
}
row[columnIndex] = cell
}
// Inject the alignment row.
cellMatrix.splice(1, 0, row)
sizeMatrix.splice(1, 0, sizes)
rowIndex = -1
/** @type {Array<string>} */
const lines: string[] = []
while (++rowIndex < cellMatrix.length) {
const row = cellMatrix[rowIndex]
const sizes = sizeMatrix[rowIndex]
columnIndex = -1
/** @type {Array<string>} */
const line: string[] = []
while (++columnIndex < mostCellsPerRow) {
const cell = row[columnIndex] || ''
let before = ''
let after = ''
if (options.alignDelimiters !== false) {
const size =
longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0)
const code = alignments[columnIndex]
if (code === 114 /* `r` */) {
before = ' '.repeat(size)
} else if (code === 99 /* `c` */) {
if (size % 2) {
before = ' '.repeat(size / 2 + 0.5)
after = ' '.repeat(size / 2 - 0.5)
} else {
before = ' '.repeat(size / 2)
after = before
}
} else {
after = ' '.repeat(size)
}
}
if (options.delimiterStart !== false && !columnIndex) {
line.push('|')
}
if (
options.padding !== false &&
// Don’t add the opening space if we’re not aligning and the cell is
// empty: there will be a closing space.
!(options.alignDelimiters === false && cell === '') &&
(options.delimiterStart !== false || columnIndex)
) {
line.push(' ')
}
if (options.alignDelimiters !== false) {
line.push(before)
}
line.push(cell)
if (options.alignDelimiters !== false) {
line.push(after)
}
if (options.padding !== false) {
line.push(' ')
}
if (
options.delimiterEnd !== false ||
columnIndex !== mostCellsPerRow - 1
) {
line.push('|')
}
}
lines.push(
options.delimiterEnd === false
? line.join('').replace(/ +$/, '')
: line.join('')
)
}
return lines.join('\n')
}
/**
* @param {string|null|undefined} [value]
* @returns {string}
*/
function serialize(value) {
return value === null || value === undefined ? '' : String(value)
}
/**
* @param {string} value
* @returns {number}
*/
function defaultStringLength(value) {
return value.length
}
/**
* @param {string|null|undefined} value
* @returns {number}
*/
function toAlignment(value) {
const code = typeof value === 'string' ? value.codePointAt(0) : 0
return code === 67 /* `C` */ || code === 99 /* `c` */
? 99 /* `c` */
: code === 76 /* `L` */ || code === 108 /* `l` */
? 108 /* `l` */
: code === 82 /* `R` */ || code === 114 /* `r` */
? 114 /* `r` */
: 0
}
export const specs = (path: string) => {
if (!path || !exists(path)) {
return '';
} else {
let data = parse(path) as any;
data[0].data = data[0].data.filter((d) => !!d.length);
data = markdownTable(data[0].data);
const ret = md2html(data);
return ret
}
}

View File

@ -0,0 +1,7 @@
---
import { createComponent } from "astro/runtime/server/astro-component.js";
import { renderTemplate, unescapeHTML } from "astro/runtime/server/index.js";
const DefaultComponent = createComponent(() => renderTemplate( unescapeHTML(`<div />`), []));
---
<DefaultComponent />

View File

@ -0,0 +1,229 @@
---
import { Img } from "imagetools/components";
import Translate from "./i18n.astro"
import { translate } from "@/base/i18n";
import { createMarkdownComponent, markdownToHtml } from "@/base/index.js";
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js"
interface Image {
alt: string
src: string
title?: string
description?: string
}
export interface Props {
images: Image[];
id?: string;
gallerySettings?: {
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
lightboxSettings?: {
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
}
const { images, gallerySettings = {}, lightboxSettings = {} } = Astro.props;
const mergedGallerySettings = {
SIZES_REGULAR: gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.GALLERY.SIZES_REGULAR,
SIZES_THUMB: gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.GALLERY.SIZES_THUMB,
SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.GALLERY.SHOW_TITLE,
SHOW_DESCRIPTION: gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.GALLERY.SHOW_DESCRIPTION,
};
const mergedLightboxSettings = {
SIZES_LARGE: lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.LIGHTBOX.SIZES_LARGE,
SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_TITLE,
SHOW_DESCRIPTION: lightboxSettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_DESCRIPTION,
};
const locale = Astro.currentLocale || "en";
---
<div
x-data={`
{
open: false,
currentIndex: 0,
total: ${images.length},
lightboxLoaded: false,
touchStartX: 0,
touchEndX: 0,
minSwipeDistance: 50,
isSwiping: false,
images: ${JSON.stringify(images)},
handleSwipe() {
if (!this.isSwiping) return;
const swipeDistance = this.touchEndX - this.touchStartX;
if (Math.abs(swipeDistance) >= this.minSwipeDistance) {
if (swipeDistance > 0 && this.currentIndex > 0) {
// Swiped right, show previous image
this.currentIndex--;
} else if (swipeDistance < 0 && this.currentIndex < this.total - 1) {
// Swiped left, show next image
this.currentIndex++;
}
}
this.isSwiping = false;
},
preloadAndOpen() {
if (this.isSwiping) return;
this.lightboxLoaded = false;
let img = new Image();
img.src = this.images[this.currentIndex].src;
img.onload = () => {
this.lightboxLoaded = true;
this.open = true;
};
}
}
`}
@keydown.escape.window="open = false"
@keydown.window="if(open){
if($event.key === 'ArrowRight' && currentIndex < total - 1){
currentIndex++;
lightboxLoaded = false;
preloadAndOpen();
} else if($event.key === 'ArrowLeft' && currentIndex > 0){
currentIndex--;
lightboxLoaded = false;
preloadAndOpen();
}
}"
class="product-gallery"><div class="flex flex-col h-full">
<!-- Main Image (with swipe functionality) -->
<div
class="flex-1 p-1 flex items-center justify-center cursor-pointer rounded-lg"
@click="preloadAndOpen()"
@touchstart="touchStartX = $event.touches[0].clientX; isSwiping = true;"
@touchend="touchEndX = $event.changedTouches[0].clientX; handleSwipe();"
@touchcancel="isSwiping = false;"
>
{images.map((image, index) => (
<div x-show={`currentIndex === ${index}`} key={index} class="w-full h-full flex items-center justify-center">
<Img
src={image.src}
alt={image.alt,I18N_SOURCE_LANGUAGE,locale}
objectFit="contain"
format="avif"
placeholder="blurred"
sizes={mergedGallerySettings.SIZES_REGULAR}
attributes={{
img: { class: "main-image p-1 rounded-lg max-h-[60vh] aspect-square" }
}}
/>
</div>
))}
</div>
<!-- Image Info -->
{ (mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && (
<div class="text-center py-4">
{images.map((image, index) => (
<div x-show={`currentIndex === ${index}`} key={index}>
{ mergedGallerySettings.SHOW_TITLE && ( <h2 id="imageTitle" class="text-xl font-bold">{image.title}</h2>)}
{ mergedGallerySettings.SHOW_DESCRIPTION && (<p id="imageDescription" class="text-gray-600"><Translate>{ image.description}</Translate></p>)}
</div>
))}
</div>
)}
<!-- Thumbnails -->
<div class="p-1 overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-200">
<div class="flex p-2 mt-2 ml-2 mr-2 gap-2 items-center justify-center">
{images.map((image, index) => (
<button
key={index}
x-on:click={`currentIndex = ${index};`}
:class={`currentIndex === ${index} ? 'ring-2 ring-orange-500' : ''`}
class="thumbnail thumbnail-btn flex-shrink rounded-lg"
>
<Img
src={image.src}
objectFit="cover"
format="avif"
placeholder="blurred"
sizes={mergedGallerySettings.SIZES_THUMB}
class="w-32 h-32 p-1 object-contain rounded hover:ring-2 hover:ring-blue-500"
alt={translate(image.alt,I18N_SOURCE_LANGUAGE,locale)}
attributes={{
img: {
class: "w-32 h-32 rounded-lg hover:ring-2 hover:ring-blue-500 thumbnail-img aspect-square",
}
}}
loading="lazy"
/>
</button>
))}
</div>
</div>
</div>
<!-- Lightbox Modal (with swipe functionality) -->
<div
x-show="open"
x-transition
:class="{ 'lightbox': !lightboxLoaded }"
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center lightbox"
>
<div
class="relative max-w-full max-h-full"
@touchstart="touchStartX = $event.touches[0].clientX; isSwiping = true;"
@touchend="touchEndX = $event.changedTouches[0].clientX; handleSwipe();"
@touchcancel="isSwiping = false;"
>
{images.map((image, index) => (
<div x-show={`currentIndex === ${index}`} key={index}>
<Img
src={image.src}
alt={image.alt}
placeholder="blurred"
format="avif"
objectFit="contain"
sizes={IMAGE_SETTINGS.LIGHTBOX.SIZES_LARGE}
attributes={{
img: { class: "max-w-[90vw] max-h-[90vh] object-contain rounded-lg lightbox-main" }
}}
/>
{ (mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && (
<div class="absolute bottom-0 left-1/2 transform -translate-x-1/2 m-[8px] max-h-[32vh] p-2 text-white bg-black/50 rounded-lg" style="width: 90%;">
{ mergedLightboxSettings.SHOW_TITLE && ( <h3 class="text-xl"><Translate>{image.title}</Translate></h3>)}
{ mergedLightboxSettings.SHOW_DESCRIPTION && (<p><Translate>{image.description}</Translate></p>)} </div>
)}
</div>
))}
<!-- Close Button -->
<button
x-on:click="open = false"
class="absolute top-0 right-0 text-white text-2xl p-4 m-[8px] bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
aria-label="Close"
>
&times;
</button>
<!-- Navigation Buttons -->
<button
x-show="currentIndex > 0"
x-on:click="currentIndex--;"
class="absolute left-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
aria-label="Previous"
>
&#10094;
</button>
<button
x-show="currentIndex < total - 1"
x-on:click="currentIndex++; "
class="absolute right-0 top-1/2 transform -translate-y-1/2 p-4 m-[8px] text-white text-3xl bg-gray-800/75 bg-opacity-75 rounded-lg lightbox-nav"
aria-label="Next"
>
&#10095;
</button>
</div>
</div>

View File

@ -0,0 +1,21 @@
---
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { translate, IOptions } from '@/base/i18n.js'
export interface Props extends IOptions {
language?: string,
clazz?:string
}
const {
language = Astro.currentLocale,
clazz = '',
...rest
} = Astro.props
const content = await Astro.slots.render('default')
const translatedText = await translate(content, I18N_SOURCE_LANGUAGE, language, rest)
---
<div data-widget="polymech.i18n" class={clazz}>
{translatedText}
</div>

View File

@ -0,0 +1,43 @@
---
import * as path from "path"
import { sync as fileExists } from "@polymech/fs/exists"
import { specs } from "@/base/specs.js"
import { render, logger } from "@/base/index.js"
import { translateSheets } from "@/base/i18n.js"
import { I18N_SOURCE_LANGUAGE, LANGUAGES, PRODUCT_SPECS } from "config/config.js"
import DefaultComponent from "./Default.astro"
const { frontmatter: data } = Astro.props
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE
const specs_table = async (relPath) =>
{
let specsPath = path.join(PRODUCT_SPECS(relPath));
if (!fileExists(specsPath)) {
logger.debug(`No specs found for ${specsPath}`);
return null;
}
if (locale !== I18N_SOURCE_LANGUAGE && LANGUAGES.includes(locale)) {
const i18n = await translateSheets(relPath, locale);
if (!i18n) {
logger.debug(`No i18n found for ${relPath} : ${locale}`);
} else {
specsPath = i18n
}
}
return specs(specsPath)
}
const tableHTML = await specs_table(data.rel)
let SpecsComponent
if (tableHTML) {
SpecsComponent = await render(tableHTML)
} else {
SpecsComponent = DefaultComponent
}
---
<div class="bg-white rounded-xl specs specs-table">
<SpecsComponent />
</div>

View File

@ -0,0 +1,33 @@
---
import { translate } from "@/base/i18n.js";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { slugify } from "@/base/strings.js"
export interface Props {
title: string;
}
const { title = "title" } = Astro.props;
const slug = `${slugify(title)}-tab-button`;
const id = `#${slug}-tab`;
const view = `#${slugify(title)}-view`;
const locale = Astro.currentLocale;
const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale);
---
<li>
<a
data-tabs-target={view}
href={id}
class="inline-block p-4 border-b-2
border-transparent rounded-t-lg
hover:text-gray-600
hover:border-gray-300
dark:hover:text-gray-300"
id={slug}
type="button"
role="tab"
aria-controls="settings"
aria-selected="false">{title_i18n}</a
>
</li>

View File

@ -0,0 +1,19 @@
---
import { slugify } from "@/base/strings.js";
export interface Props {
title: string;
}
const { title = "title" } = Astro.props;
const slug = `${slugify(title)}-tab-content`;
const label = `${slug}-tab`;
const view = `${slugify(title)}-view`;
---
<div
class="hidden p-4 rounded-lg bg-gray-50 dark:bg-gray-800 content"
id={view}
role="tabpanel"
aria-labelledby={label}
>
<slot />
</div>

View File

@ -0,0 +1,65 @@
---
import { translate } from '@/base/i18n.js';
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
// A simple slugify helper (you could import this from a utility file instead)
function slugify(text: string) {
return text
.normalize('NFD') // Normalise accented characters
.replace(/[\u0300-\u036f]/g, '') // Strip accents
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
}
export interface Props {
title: string;
clazz?: string;
src?: string; // optional
}
const {
title = 'title',
clazz = 'unstyled',
src,
} = Astro.props;
// Compute the slug from the original title
const id = slugify(title);
// Then retrieve the user's locale and translate the title
const locale = Astro.currentLocale;
const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale);
---
<button
id={id}
slot="tab"
class="inline-flex whitespace-nowrap border-b-2 border-transparent py-2 px-3 text-sm font-medium text-gray-300 transition-all duration-200 ease-in-out hover:border-b-[#9249ed] hover:text-[#9249ed] aria-selected:border-b-[#9249ed] aria-selected:text-[#9249ed]"
>{title_i18n}</button>
<div slot="panel" class={clazz}>
{src ? (
<div class="iframe-container">
<iframe src={src}></iframe>
</div>
) : (
<slot />
)}
</div>
<style>
.iframe-container {
width: 100%;
position: relative;
padding-bottom: 56.25%; /* 16:9 ratio; adjust if needed */
height: 0;
}
.iframe-container iframe {
position: absolute;
width: 100%;
height: 100%;
border: none;
}
</style>

View File

@ -0,0 +1,10 @@
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
id="default-tab"
data-tabs-toggle="#default-tab-content"
role="tablist"
>
<slot/>
</ul>
</div>

View File

@ -19,7 +19,8 @@
"declaration": true,
"paths": {
"@/*": ["src/*"],
"config/*": ["src/app/*"]
"config/*": ["src/app/*"],
"components/*": ["src/components/*"]
}
},
"include": [".astro/types.d.ts", "**/*.ts", "**/*.tsx", "**/*.astro"],