refactor site2

This commit is contained in:
babayaga 2025-08-23 18:11:49 +02:00
parent 3ed545b9ca
commit 48c77f446a
33 changed files with 3114 additions and 105 deletions

View File

@ -8,7 +8,9 @@
},
"exports": {
".": "./dist/index.js",
"./plugins/*": "./plugins/*",
"./base/*": "./dist/base/*",
"./model/*": "./dist/model/*",
"./config/*": "./dist/config/*",
"./components/*": "./src/components/*"
},
@ -19,6 +21,7 @@
"dependencies": {
"@astrojs/compiler": "^2.12.2",
"@astrojs/react": "^4.3.0",
"@astrojs/rss": "^4.0.12",
"@polymech/cache": "file:../../../polymech-mono/packages/cache",
"@polymech/cad": "file:../../../polymech-mono/packages/cad",
"@polymech/commons": "file:../../../polymech-mono/packages/commons",
@ -36,15 +39,18 @@
"html-entities": "^2.5.2",
"imagetools": "file:../imagetools",
"marked": "^16.1.2",
"mdast-util-to-string": "^4.0.0",
"node-xlsx": "^0.24.0",
"p-map": "^7.0.3",
"react-jsx-parser": "^2.4.0",
"reading-time": "^1.5.0",
"rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"showdown": "^2.1.0",
"tslog": "^4.9.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"yargs": "^18.0.0"
}
}

View File

@ -0,0 +1,36 @@
import { visit } from 'unist-util-visit';
import path from 'path';
export default function rehypeCustomImg() {
return (tree, file) => {
if(!file.path.endsWith('.mdx')) {
return;
}
const contentDir = path.join(process.cwd(), 'src', 'content');
const entryPath = path.relative(contentDir, file.history[0]).replace(/\\/g, '/');
// 1. Add the import statement for RelativeImage.
tree.children.unshift({
type: 'mdxjsEsm',
value: "import RelativeImage from '~/components/imagetools/RelativeImage.astro';"
});
// 2. Visit all JSX nodes and inject entryPath into <img> tags.
visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name === 'img') {
node.attributes.push({
type: 'mdxJsxAttribute',
name: 'entryPath',
value: entryPath
});
}
});
// 3. Export the components mapping.
tree.children.push({
type: 'mdxjsEsm',
value: 'export const components = { img: RelativeImage };'
});
};
}

View File

@ -0,0 +1,30 @@
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime() {
return function (tree, { data }) {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
// Round up minutes and remove seconds
const roundedMinutes = Math.ceil(readingTime.minutes);
const friendlyText = `${roundedMinutes} min read`;
// Ensure data.astro exists
if (!data.astro) {
data.astro = {};
}
// Set the reading time in the frontmatter
data.astro.frontmatter = data.astro.frontmatter || {};
data.astro.frontmatter.minutesRead = friendlyText;
// Also try setting it directly on data for compatibility
data.minutesRead = friendlyText;
// Store the raw rounded minutes for translation later
data.astro.frontmatter.rawMinutes = roundedMinutes;
data.rawMinutes = roundedMinutes;
};
}

View File

@ -165,4 +165,36 @@ export const IMAGE_SETTINGS =
SIZES_LARGE: O_IMAGE.sizes_large,
SIZES_REGULAR: O_IMAGE.sizes
}
}
/////////////////////////////////////////////
//
// Collection Filters
// Collection filter configuration
export const COLLECTION_FILTERS = {
// Enable/disable specific default filters
ENABLE_VALID_FRONTMATTER_CHECK: true,
ENABLE_FOLDER_FILTER: true,
ENABLE_DRAFT_FILTER: true,
ENABLE_TITLE_FILTER: true, // Enabled by default to filter out "Untitled" entries
// Content validation filters
ENABLE_BODY_FILTER: false, // Require entries to have body content
ENABLE_DESCRIPTION_FILTER: false, // Require entries to have descriptions
ENABLE_IMAGE_FILTER: false, // Require entries to have images
ENABLE_AUTHOR_FILTER: false, // Require entries to have real authors (not "Unknown")
ENABLE_PUBDATE_FILTER: false, // Require entries to have valid publication dates
ENABLE_TAGS_FILTER: false, // Require entries to have tags
ENABLE_FILE_EXTENSION_FILTER: true, // Require valid .md/.mdx extensions
// Additional filter settings
REQUIRED_FIELDS: [], // Array of required frontmatter fields
REQUIRED_TAGS: [], // Array of required tags
EXCLUDE_TAGS: [], // Array of tags to exclude
// Date filtering
FILTER_FUTURE_POSTS: false, // Filter out posts with future publication dates
FILTER_OLD_POSTS: false, // Filter out posts older than a certain date
OLD_POST_CUTOFF_DAYS: 365, // Days to consider a post "old"
}

View File

@ -1,6 +1,8 @@
import type { CollectionEntry } from 'astro:content';
import { isFolder } from '@polymech/commons';
import { parseFrontmatter } from '@astrojs/markdown-remark';
import { translate } from './i18n.js';
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
// Filter function type
export type CollectionFilter<T = any> = (entry: CollectionEntry<T>, astroConfig?: any) => boolean;
@ -32,7 +34,7 @@ export const hasValidFrontMatter: CollectionFilter = (entry) => {
// For MD/MDX files, Astro automatically parses frontmatter
// If data exists and is not empty, consider it valid
if (!entry.data) return false;
// Check for basic required fields (can be customized)
// At minimum, we expect some data to exist
return typeof entry.data === 'object' && Object.keys(entry.data).length > 0;
@ -49,7 +51,7 @@ export const hasValidParsedFrontMatter: CollectionFilter = (entry) => {
if (entry.data && typeof entry.data === 'object' && Object.keys(entry.data).length > 0) {
return true;
}
// For entries that might need raw frontmatter parsing
// This is more applicable when working with raw markdown content
// In most Astro cases, entry.data is already parsed
@ -94,22 +96,22 @@ export function createRawFrontmatterValidator<T = any>(
try {
const rawContent = rawContentGetter(entry);
if (!rawContent) return false;
const parsed = parseFrontmatter(rawContent);
// Check if frontmatter exists and is valid
if (!parsed.frontmatter || typeof parsed.frontmatter !== 'object') {
return false;
}
// Apply custom validator if provided
if (validator) {
return validator(parsed.frontmatter);
}
// Default validation: frontmatter should have at least one property
return Object.keys(parsed.frontmatter).length > 0;
} catch (error) {
console.warn(`Raw frontmatter parsing failed for entry ${entry.id}:`, error);
return false;
@ -132,25 +134,25 @@ export function createFileBasedFrontmatterValidator<T = any>(
console.warn(`No filePath available for entry ${entry.id}`);
return false;
}
// This would require fs import in a Node.js environment
// For now, we'll rely on the entry.data which is already parsed
// In a real implementation, you could use:
// const fs = await import('fs');
// const rawContent = fs.readFileSync(entry.filePath, 'utf-8');
// const parsed = parseFrontmatter(rawContent);
// Fallback to using the already parsed data
if (!entry.data || typeof entry.data !== 'object') {
return false;
}
if (validator) {
return validator(entry.data);
}
return Object.keys(entry.data).length > 0;
} catch (error) {
console.warn(`File-based frontmatter validation failed for entry ${entry.id}:`, error);
return false;
@ -172,9 +174,9 @@ export const isNotDraft: CollectionFilter = (entry) => {
export const hasTitle: CollectionFilter = (entry) => {
// Check if entry has a title and it's not the default "Untitled"
return !!entry.data?.title &&
entry.data.title.trim() !== '' &&
entry.data.title.trim() !== 'Untitled';
return !!entry.data?.title &&
entry.data.title.trim() !== '' &&
entry.data.title.trim() !== 'Untitled';
};
export const hasBody: CollectionFilter = (entry) => {
@ -185,7 +187,7 @@ export const hasBody: CollectionFilter = (entry) => {
export const hasValidFileExtension: CollectionFilter = (entry) => {
// Check if the entry has a valid markdown/mdx file extension
if (!entry.filePath) return true; // If no filePath, assume it's valid
const validExtensions = ['.md', '.mdx'];
return validExtensions.some(ext => entry.filePath.endsWith(ext));
};
@ -202,15 +204,15 @@ export const hasDescription: CollectionFilter = (entry) => {
export const hasAuthor: CollectionFilter = (entry) => {
// Check if entry has an author (and it's not the default "Unknown")
return !!entry.data?.author &&
entry.data.author.trim() !== '' &&
entry.data.author !== 'Unknown';
return !!entry.data?.author &&
entry.data.author.trim() !== '' &&
entry.data.author !== 'Unknown';
};
export const hasPubDate: CollectionFilter = (entry) => {
// Check if entry has a valid publication date
if (!entry.data?.pubDate) return false;
try {
const date = new Date(entry.data.pubDate);
return !isNaN(date.getTime());
@ -278,7 +280,7 @@ export function combineFilters<T = any>(
export function createRequiredFieldsFilter<T = any>(requiredFields: string[]): CollectionFilter<T> {
return (entry) => {
if (!entry.data) return false;
return requiredFields.every(field =>
return requiredFields.every(field =>
entry.data[field] !== undefined && entry.data[field] !== null && entry.data[field] !== ''
);
};
@ -294,7 +296,7 @@ export function createTagFilter<T = any>(requiredTags: string[], matchAll: boole
return (entry) => {
const entryTags = entry.data?.tags || [];
if (!Array.isArray(entryTags)) return false;
if (matchAll) {
return requiredTags.every(tag => entryTags.includes(tag));
} else {
@ -313,12 +315,12 @@ export function createDateFilter<T = any>(beforeDate?: Date, afterDate?: Date):
return (entry) => {
const pubDate = entry.data?.pubDate;
if (!pubDate) return false;
const entryDate = new Date(pubDate);
if (beforeDate && entryDate > beforeDate) return false;
if (afterDate && entryDate < afterDate) return false;
return true;
};
}
@ -332,7 +334,7 @@ export function createExcludeTagsFilter<T = any>(excludeTags: string[]): Collect
return (entry) => {
const entryTags = entry.data?.tags || [];
if (!Array.isArray(entryTags)) return true;
return !excludeTags.some(tag => entryTags.includes(tag));
};
}
@ -343,10 +345,10 @@ export function createExcludeTagsFilter<T = any>(excludeTags: string[]): Collect
export const isNotFuture: CollectionFilter = (entry) => {
const pubDate = entry.data?.pubDate;
if (!pubDate) return true; // If no date, include it
const entryDate = new Date(pubDate);
const now = new Date();
return entryDate <= now;
};
@ -359,11 +361,11 @@ export function createOldPostFilter<T = any>(cutoffDays: number): CollectionFilt
return (entry) => {
const pubDate = entry.data?.pubDate;
if (!pubDate) return true; // If no date, include it
const entryDate = new Date(pubDate);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - cutoffDays);
return entryDate > cutoffDate;
};
}
@ -375,78 +377,78 @@ export function createOldPostFilter<T = any>(cutoffDays: number): CollectionFilt
*/
export function buildFiltersFromConfig<T = any>(config: CollectionFilterConfig): CollectionFilter<T>[] {
const filters: CollectionFilter<T>[] = [];
// Add default filters based on config
if (config.ENABLE_VALID_FRONTMATTER_CHECK !== false) {
filters.push(hasValidFrontMatter);
}
if (config.ENABLE_FOLDER_FILTER !== false) {
filters.push(isNotFolder);
}
if (config.ENABLE_DRAFT_FILTER !== false) {
filters.push(isNotDraft);
}
if (config.ENABLE_TITLE_FILTER) {
filters.push(hasTitle);
}
// Add content validation filters
if (config.ENABLE_BODY_FILTER) {
filters.push(hasBody);
}
if (config.ENABLE_DESCRIPTION_FILTER) {
filters.push(hasDescription);
}
if (config.ENABLE_IMAGE_FILTER) {
filters.push(hasImage);
}
if (config.ENABLE_AUTHOR_FILTER) {
filters.push(hasAuthor);
}
if (config.ENABLE_PUBDATE_FILTER) {
filters.push(hasPubDate);
}
if (config.ENABLE_TAGS_FILTER) {
filters.push(hasTags);
}
if (config.ENABLE_FILE_EXTENSION_FILTER !== false) {
filters.push(hasValidFileExtension);
}
// Add required fields filter
if (config.REQUIRED_FIELDS && config.REQUIRED_FIELDS.length > 0) {
filters.push(createRequiredFieldsFilter(config.REQUIRED_FIELDS));
}
// Add required tags filter
if (config.REQUIRED_TAGS && config.REQUIRED_TAGS.length > 0) {
filters.push(createTagFilter(config.REQUIRED_TAGS, true)); // matchAll = true
}
// Add exclude tags filter
if (config.EXCLUDE_TAGS && config.EXCLUDE_TAGS.length > 0) {
filters.push(createExcludeTagsFilter(config.EXCLUDE_TAGS));
}
// Add future posts filter
if (config.FILTER_FUTURE_POSTS) {
filters.push(isNotFuture);
}
// Add old posts filter
if (config.FILTER_OLD_POSTS && config.OLD_POST_CUTOFF_DAYS) {
filters.push(createOldPostFilter(config.OLD_POST_CUTOFF_DAYS));
}
return filters;
}
@ -465,3 +467,127 @@ export function filterCollectionWithConfig<T = any>(
const filters = buildFiltersFromConfig<T>(config);
return filterCollection(collection, filters, astroConfig);
}
// Utility functions
export async function generateBreadcrumbs(
folderPath: string | undefined,
locale: string,
collectionName: string
): Promise<Array<{ label: string; href: string | undefined; isLast: boolean }>> {
const collectionLabel = await translate(collectionName, I18N_SOURCE_LANGUAGE, locale);
const breadcrumbs = [
{
label: collectionLabel,
href: `/${locale}/${collectionName}/`,
isLast: !folderPath
}
] as Array<{ label: string; href: string | undefined; isLast: boolean }>;
if (folderPath) {
const segments = folderPath.split('/').filter(segment => segment !== '');
segments.forEach((segment, index, arr) => {
const isLast = index === arr.length - 1;
const segmentPath = arr.slice(0, index + 1).join('/');
const label = segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
breadcrumbs.push({
label,
href: isLast ? undefined : `/${locale}/${collectionName}/${segmentPath}/`,
isLast
});
});
}
return breadcrumbs;
}
export async function calculateReadingTime(
entry: any,
rendered: any,
locale: string
): Promise<string> {
// Try multiple possible locations for the reading time
const readingTime = rendered.remarkPluginFrontmatter?.minutesRead ||
rendered.remarkPluginFrontmatter?.data?.minutesRead ||
rendered.remarkPluginFrontmatter?.data?.astro?.frontmatter?.minutesRead;
// Get raw minutes for translation
const rawMinutes = rendered.remarkPluginFrontmatter?.rawMinutes ||
rendered.remarkPluginFrontmatter?.data?.rawMinutes ||
rendered.remarkPluginFrontmatter?.data?.astro?.frontmatter?.rawMinutes;
if (rawMinutes) {
// Use raw minutes to create translated reading time
return await translate(`${rawMinutes} min read`, I18N_SOURCE_LANGUAGE, locale);
} else if (readingTime) {
// Fallback to the English text from plugin
return readingTime;
} else {
// Fallback: If no reading time from plugin, calculate it manually
const textContent = entry.body || '';
const wordCount = textContent.split(/\s+/).length;
const estimatedMinutes = Math.ceil(wordCount / 200); // Rough estimate: 200 words per minute
return await translate(`${estimatedMinutes} min read`, I18N_SOURCE_LANGUAGE, locale);
}
}
export async function getStaticPaths_fs(get, collectionName: string, languages: string[] = ['en'], filters: CollectionFilterConfig = {}) {
const allResourceEntries = await get(collectionName);
const resourceEntries = filterCollectionWithConfig(allResourceEntries, filters);
const paths: any[] = [];
languages.forEach((lang) => {
resourceEntries.forEach((entry) => {
paths.push({
params: {
locale: lang,
slug: entry.id,
},
props: {
entry,
locale: lang,
type: 'article',
// Pass the crucial entryPath for image resolution
entryPath: `${collectionName}/${entry.id}`,
}
});
});
// Add folder paths
const folders = new Set<string>();
resourceEntries.forEach(post => {
const segments = post.id.split('/').filter(segment => segment !== '');
if (segments.length > 1) {
// Add all possible folder paths
for (let i = 1; i < segments.length; i++) {
const folderPath = segments.slice(0, i).join('/');
folders.add(folderPath);
}
}
});
// Add folder paths with trailing slash
Array.from(folders).forEach(folder => {
paths.push({
params: {
locale: lang,
slug: folder + '/',
},
props: {
folderPath: folder,
posts: resourceEntries.filter(post => {
const postFolder = post.id.split('/').slice(0, -1).join('/');
return postFolder === folder;
}),
locale: lang,
type: 'folder'
}
});
});
});
return paths;
}

View File

@ -1,6 +1,6 @@
---
import config from "@/app/config.json"
import { get } from "@/model/registry.js"
import { get } from "../model/registry.js"
import { default_image } from "@/app/config.js"
const { frontmatter } = Astro.props
@ -13,6 +13,7 @@ const itemData = frontmatter ? await get("json-ld", {} as any, frontmatter,
url: pageUrl.href,
}) : null
const meta = config.metadata || { }
let data = itemData || {

View File

@ -0,0 +1,182 @@
---
import "../styles/flowbite.css"
import "../styles/global.css"
import "../styles/custom.scss"
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { translate } from '@polymech/astro-base/base/i18n.js'
import { item_defaults } from '@/base/index.js'
import { LANGUAGES_PROD } from "config/config.js"
import config from "config/config.json"
import { plainify } from "@polymech/astro-base/base/strings.js"
import { sync as read } from '@polymech/fs/read'
import { AstroSeo } from "@astrolib/seo"
import StructuredData from '@polymech/astro-base/components/ArticleStructuredData.astro'
import Hreflang from '@polymech/astro-base/components/hreflang.astro'
export interface Props {
title?: string;
meta_title?: string;
description?: string;
image?: string;
noindex?: boolean;
canonical?: string;
view: string;
path: string;
frontmatter?: {
title?: string;
description?: string;
keywords?: string[];
images?: { url: string; alt?: string }[];
};
}
const env = import.meta.env
const { frontmatter, view, path } = Astro.props
const { url } = Astro.request
const REDIRECT = false //import.meta.env.I18N_REDIRECT
const _url = Astro.url
const canonicalUrl = _url.origin
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE
const hreflangs = LANGUAGES_PROD.filter((lang)=>lang!==Astro.currentLocale).map((lang) => ({
lang,
url: `${canonicalUrl}/${lang}/${view}/${path}`,
}))
const image = frontmatter?.image || config.site.image
const image_url = image.url
const image_alt = image.alt
const title = frontmatter?.title || config.site.title
const description = frontmatter?.description || config.metadata.description
let system_keywords = ""
const item_config = frontmatter as any || {}
if(item_config.PRODUCT_ROOT){
const defaultsJson = await item_defaults(item_config.PRODUCT_ROOT);
let defaults:Record<string,string> = read(defaultsJson, 'json') || {}
const defaultsKeywords = (defaults.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const configKeywords = (config.metadata.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const itemKeywords = (item_config.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const allKeywords = Array.from(new Set([
...defaultsKeywords,
...configKeywords,
...itemKeywords
])).join(',');
system_keywords = await translate(allKeywords, I18N_SOURCE_LANGUAGE, locale)
}
const keywords = [ ...new Set([item_config.name, ...system_keywords.split(',')])].join(',')
---
<AstroSeo
title={title}
description={description}
openGraph={{
url,
title: title,
description: plainify(description),
images: [
{
url: image_url || '',
width: 1200,
height: 630,
alt: image_alt,
type: "image/jpeg",
},
],
site_name: title,
type: "website",
locale: "en_US",
}}
twitter={{
handle: "@polymech",
site: "@polymech",
cardType: "summary_large_image",
}}
/>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Theme script to prevent flash of unstyled content -->
<script is:inline>
// Check for saved theme preference or default to 'light'
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- Canonical URL -->
<Hreflang canonical={canonicalURL} hreflangs={hreflangs} />
<!-- meta-description -->
<meta name="description" content={plainify(description)}/>
<!-- meta-keywords -->
<meta name="keywords" content={plainify( keywords )}/>
<meta name="author" content={plainify( "" )} />
<!-- Favicons -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Favicon for IE -->
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<!-- Favicons for different sizes -->
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
<!-- Additional SEO -->
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow" />
<!-- Apple Touch Icon (already included in favicons, but keeping for backwards compatibility) -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Theme Color for Mobile Browsers -->
<meta name="theme-color" content="#ffffff" />
<!-- For IE -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--- Inter fonr from rsms.me/inter
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
-->
<!--- IBM Plex Mono font from fonts.google.com -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet"
/>
<script is:inline src="https://cdn.jsdelivr.net/npm/flowbite@3.0.0/dist/flowbite.min.js"></script>
<!-- alpine JS -->
<script src="//unpkg.com/alpinejs" defer></script>
<StructuredData frontmatter={frontmatter} />
{ REDIRECT && <script>
const currentPath = window.location.pathname;
if (!/^\/[a-z]{2}(\/|$)/i.test(currentPath)) {
let language = navigator.language || navigator.userLanguage
language = language.split('-')[0]
language = "en"
window.location.href = `/${language}`;
}
</script>
}

View File

@ -106,7 +106,7 @@ const breadcrumbs = generateBreadcrumbs(currentPath, collection, title);
}
.breadcrumb-link {
color: #6b7280;
text-decoration: none;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
@ -126,7 +126,7 @@ const breadcrumbs = generateBreadcrumbs(currentPath, collection, title);
}
.breadcrumb-current {
color: #111827;
font-weight: 500;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;

View File

@ -102,7 +102,7 @@ const locale = Astro.currentLocale || "en";
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"
class="flex-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();"
@ -113,12 +113,12 @@ const locale = Astro.currentLocale || "en";
<Img
src={image.src}
alt={image.alt,I18N_SOURCE_LANGUAGE,locale}
objectFit="contain"
objectFit="cover"
format="avif"
placeholder="blurred"
sizes={mergedGallerySettings.SIZES_REGULAR}
attributes={{
img: { class: "main-image p-1 rounded-lg max-h-[60vh] aspect-square" }
img: { class: "main-image p-4 rounded-lg max-h-[60vh] aspect-square" }
}}
/>
</div>
@ -136,14 +136,14 @@ const locale = Astro.currentLocale || "en";
</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">
<div class="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-200">
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 p-2 mt-2 ml-2 mr-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"
class="thumbnail thumbnail-btn rounded-lg"
>
<Img
src={image.src}
@ -151,11 +151,11 @@ const locale = Astro.currentLocale || "en";
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"
class="w-full h-full 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",
class: "w-full h-full rounded-lg hover:ring-2 hover:ring-blue-500 thumbnail-img aspect-square",
}
}}
loading="lazy"

View File

@ -0,0 +1,118 @@
---
import Wrapper from "@/components/containers/Wrapper.astro";
import { footer_left, footer_right } from "@/app/navigation.js";
import { ISO_LANGUAGE_LABELS } from "@polymech/i18n";
import {
LANGUAGES_PROD,
I18N_SOURCE_LANGUAGE,
} from "config/config.js";
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE;
const currentUrl = new URL(Astro.url);
/**
* Returns the path segments of a URL with an optional language prefix removed.
* @param {URL} url - The current URL.
* @returns {string[]} The URL path segments without the language code (if present).
*/
const getCleanPathSegments = (url) => {
const segments = url.pathname.split('/').filter(Boolean);
if (segments.length && LANGUAGES_PROD.includes(segments[0])) {
segments.shift();
}
return segments;
};
/**
* Constructs a localized URL by prepending the given language code to the existing path.
* @param {string} lang - The target language code.
* @param {string[]} segments - The clean URL path segments.
* @returns {string} The localized URL as a string.
*/
const buildLocalizedUrl = (lang, segments) => {
const newUrl = new URL(Astro.url);
// Prepend the language code and join with existing segments, removing any trailing slash.
newUrl.pathname = `/${lang}/${segments.join('/')}`.replace(/\/+$/, '');
return newUrl.toString();
};
const cleanSegments = getCleanPathSegments(currentUrl);
const languages = LANGUAGES_PROD.filter(
(lang) => lang !== locale,
).map((lang) => ({
lang: ISO_LANGUAGE_LABELS[lang] || lang,
url: buildLocalizedUrl(lang, cleanSegments),
}));
const footerLeft = await footer_left(locale);
const footerRight = await footer_right(locale);
---
<Wrapper variant="standard" class="py-12">
<footer class="py-2">
<div class="p-4 xl:pb-0 bg-white dark:bg-gray-800 overflow-hidden rounded-xl">
<div class="grid md:grid-cols-3 gap-6">
<div
class="flex flex-col h-full justify-between xl:pb-2 order-last md:order-none"
>
<nav role="navigation">
<ul class="text-xs space-y-1 uppercase dark:text-gray-400">
{
footerRight.map((link) => (
<li>
<a class=" hover:text-orange-500" href={link.href}>
{link.title}
</a>
</li>
))
}
</ul>
<div class="mt-2">
{
languages.map((link) => (
<span>
<a class=" hover:text-orange-500 p-2 dark:text-gray-400" href={link.url}>
{link.lang}
</a>
</span><br/>
))
}
</div>
</nav>
<div class="flex gap-4 mt-12 items-center">
<img src="/logos/transparent.svg" alt="logo" class="size-4" />
<p
class="text-xs leading-5 text-neutral-400 dark:text-gray-500 text-pretty uppercase"
>
</p>
</div>
</div>
<img
src="/logos/transparent.svg"
alt="logo"
class="size-12 md:mx-auto fill-orange-600"
/>
<div class="flex flex-col h-full justify-between md:text-right xl:pb-2">
<nav role="navigation">
<ul class="text-xs space-y-1 uppercase dark:text-gray-400">
{
footerLeft.map((link) => (
<li>
<a class=" hover:text-orange-500" href={link.href}>
{link.title}
</a>
</li>
))
}
</ul>
</nav>
</div>
</div>
</div>
</footer>
</Wrapper>

View File

@ -0,0 +1,32 @@
---
import Wrapper from "@/components/containers/Wrapper.astro";
import { I18N_SOURCE_LANGUAGE } from "@/app/config";
import { items } from "config/navigation.js";
import ThemeToggle from "./ThemeToggle.astro";
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE;
const navItems = await items({ locale });
---
<Wrapper>
<section>
<div
class="relative flex w-full items-center flex-row py-4 text-xs text-neutral-600 tracking-tight uppercase"
>
<nav class="items-center flex-grow flex justify-end flex-row gap-4">
<ThemeToggle />
{
navItems.map((item: any) => (
<a
href={item.href}
title={item.title}
aria-label={item.ariaLabel}
class={item.class}
>
{item.title}
</a>
))
}
</nav>
</div>
</section>
</Wrapper>

View File

@ -0,0 +1,86 @@
---
// Theme toggle component with sun/moon icons
---
<button
id="theme-toggle"
type="button"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium text-gray-500 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-gray-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="Toggle theme"
>
<!-- Sun icon (light mode) -->
<svg
id="theme-toggle-light-icon"
class="w-4 h-4 hidden"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
></path>
</svg>
<!-- Moon icon (dark mode) -->
<svg
id="theme-toggle-dark-icon"
class="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
></path>
</svg>
</button>
<script>
// Theme toggle functionality
(function() {
const themeToggleBtn = document.getElementById('theme-toggle');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
// Check for saved theme preference or default to 'light'
const currentTheme = localStorage.getItem('theme') || 'light';
console.log(currentTheme);
// Apply the theme on page load
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
themeToggleLightIcon?.classList.remove('hidden');
themeToggleDarkIcon?.classList.add('hidden');
console.log('Applied dark theme on load');
} else {
document.documentElement.classList.remove('dark');
themeToggleLightIcon?.classList.add('hidden');
themeToggleDarkIcon?.classList.remove('hidden');
console.log('Applied light theme on load');
}
console.log('Initial HTML classes:', document.documentElement.className);
// Toggle theme when button is clicked
themeToggleBtn?.addEventListener('click', function() {
// Toggle theme
const isDark = document.documentElement.classList.toggle('dark');
// Debug logging
console.log('Theme toggled to:', isDark ? 'dark' : 'light');
console.log('HTML classes:', document.documentElement.className);
console.log('Body classes:', document.body.className);
// Update localStorage
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Update icons
if (isDark) {
themeToggleLightIcon?.classList.remove('hidden');
themeToggleDarkIcon?.classList.add('hidden');
} else {
themeToggleLightIcon?.classList.add('hidden');
themeToggleDarkIcon?.classList.remove('hidden');
}
});
})();
</script>

View File

@ -0,0 +1,269 @@
---
/**
* ResourceCard Component
*
* A flexible card component for displaying content from any collection.
*
* Usage examples:
*
* // For resources collection (default)
* <ResourceCard
* title="Article Title"
* url="/en/resources/article"
* // ... other props
* />
*
* // For store collection
* <ResourceCard
* title="Product Name"
* url="/en/store/product"
* collectionName="store"
* // ... other props
* />
*
* // For helpcenter collection
* <ResourceCard
* title="Help Article"
* url="/en/helpcenter/article"
* collectionName="helpcenter"
* // ... other props
* />
*/
import { Img } from "imagetools/components";
import { translate } from "@polymech/astro-base/base/i18n.js";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
import { resolveImagePath } from '@/utils/path-resolution';
export interface Props {
title: string;
url: string;
author: string;
pubDate: string;
description: string;
image?: string;
alt?: string;
tags?: string[];
path?: string; // File path for breadcrumb display
locale?: string; // Locale for i18n support
contentId?: string; // Content collection ID for resolving relative images
collectionName?: string; // Collection name for dynamic routing (e.g., 'resources', 'store', 'helpcenter'). Defaults to 'resources' for backward compatibility.
}
const { title, url, author, pubDate, description, image, alt, tags = [], path, locale, contentId, collectionName = 'resources' } = Astro.props;
// Translate title, description, and author
const currentLocale = locale || Astro.currentLocale;
const translatedTitle = await translate(title, I18N_SOURCE_LANGUAGE, currentLocale);
const translatedDescription = await translate(description, I18N_SOURCE_LANGUAGE, currentLocale);
const translatedAuthor = await translate(author, I18N_SOURCE_LANGUAGE, currentLocale);
// Default image fallback
const defaultImage = "https://picsum.photos/640/360";
// Construct entryPath for the resolver, e.g., "resources/cassandra/home" or "store/product"
const entryPath = contentId ? `${collectionName}/${contentId}` : undefined;
// Use the centralized, robust path resolver
const resolvedImage = image ? resolveImagePath(image, entryPath, Astro.url) : defaultImage;
const displayImage = resolvedImage;
const displayAlt = alt || translatedTitle;
const isDefaultImage = !image;
// Check if image is external URL (imagetools can't process external URLs at build time)
const isExternalImage = displayImage.startsWith('http://') || displayImage.startsWith('https://');
const useImagetools = !isExternalImage;
// Generate breadcrumb from path
function generateBreadcrumb(filePath?: string) {
if (!filePath) return null;
const segments = filePath.split('/').filter(segment => segment !== '');
if (segments.length <= 1) return null; // No breadcrumb for root level files
// Remove the filename and file extension from the last segment
const pathSegments = segments.slice(0, -1);
return pathSegments.map(segment =>
segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
).join(' / ');
}
const breadcrumb = generateBreadcrumb(path);
// Check if we're currently viewing the parent folder of this card
// If so, don't show the breadcrumb as it's redundant
const currentPath = Astro.url.pathname;
const isInParentFolder = path && currentPath.includes(path.split('/').slice(0, -1).join('/'));
// The URL prop already points to the correct article page.
const cardUrl = url;
// Generate breadcrumb URL for the parent folder
const breadcrumbUrl = locale
? `/${locale}/${collectionName}/${path?.split('/').slice(0, -1).join('/') || ''}/`
: `/${collectionName}/${path?.split('/').slice(0, -1).join('/') || ''}/`;
---
<article class="group relative bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100">
<a href={cardUrl} class="block aspect-[3/2] overflow-hidden bg-gray-100 relative">
{useImagetools ? (
<!-- Use imagetools for local images -->
<Img
src={displayImage}
alt={displayAlt}
width={480}
height={320}
format="avif"
placeholder="blurred"
objectFit="cover"
loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
attributes={{
img: {
class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300",
style: "aspect-ratio: 3/2;"
}
}}
/>
) : (
<!-- Use regular img for external URLs with error handling -->
<div class="image-container w-full h-full">
<img
src={displayImage}
alt={displayAlt}
class="primary-image w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
style="aspect-ratio: 3/2;"
loading="lazy"
onerror={`
this.style.display='none';
this.nextElementSibling.style.display='block';
`}
/>
<img
src={defaultImage}
alt={displayAlt}
class="fallback-image w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
style="aspect-ratio: 16/9; display: none;"
loading="lazy"
/>
</div>
)}
</a>
<div class="p-4 flex flex-col min-h-[150px]">
<!-- 1. Folder / Title -->
<div class="mb-2">
{breadcrumb && !isInParentFolder && (
<div class="mb-1 flex items-center">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"/>
</svg>
<a
href={breadcrumbUrl}
class="text-gray-900/55 hover:text-gray-900 hover:underline transition-colors relative z-10"
onclick="event.stopPropagation();"
>
{breadcrumb}
</a>
</div>
)}
<h3 class="font-semibold text-gray-900/55 group-hover:text-gray-900 transition-colors duration-200 line-clamp-2">
<a href={cardUrl} class="stretched-link hover:no-underline">
{translatedTitle}
</a>
</h3>
</div>
<!-- 2. Description -->
<p class="text-gray-900/55 line-clamp-2 mb-3 flex-1">
{translatedDescription}
</p>
<!-- 3. Tags row -->
{tags.length > 0 && (
<div class="mb-1">
<div class="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<a
href={`/${currentLocale}/${collectionName}/tags/${tag}`}
class="px-1 py-0.5 tiny-text bg-gray-100 text-gray-900/55 rounded hover:bg-gray-200 hover:text-gray-900 transition-colors cursor-pointer"
onclick="event.stopPropagation();"
>
#{tag}
</a>
))}
{tags.length > 3 && (
<span class="px-1 py-0.5 tiny-text bg-gray-100 text-gray-600 rounded">
+{tags.length - 3}
</span>
)}
</div>
</div>
)}
<!-- 4. Date row -->
<div class="mb-1 pt-2 border-t border-gray-200 mt-auto">
<div class="bg-gray-50 px-2 py-1 tiny-text text-gray-900/55 rounded">
{pubDate}
</div>
</div>
<!-- 5. Author row -->
{author && author.toLowerCase() !== 'unknown' && (
<div>
<div class="bg-gray-50 px-2 py-1 tiny-text text-gray-900/55 rounded">
By {translatedAuthor}
</div>
</div>
)}
</div>
</article>
<style>
.stretched-link::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
content: "";
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Image error handling */
.image-container {
position: relative;
}
.fallback-image {
position: absolute;
top: 0;
left: 0;
}
/* Extra small text for metadata */
.tiny-text {
font-size: 0.825rem; /* 10px - smaller than text-xs (12px) */
line-height: 1.2;
}
</style>

View File

@ -70,8 +70,6 @@ const { group, isNested = false } = Astro.props;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background-color: #f9fafb;
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
@ -79,8 +77,7 @@ const { group, isNested = false } = Astro.props;
}
.sidebar-subgroup-title:hover {
background-color: #f3f4f6;
color: #111827;
/* Hover styles handled by global.css */
}
.sidebar-subgroup-title .caret {
@ -96,7 +93,6 @@ const { group, isNested = false } = Astro.props;
margin-top: 0.5rem;
margin-left: 0.75rem;
padding-left: 0.75rem;
border-left: 2px solid #e5e7eb;
}
.sidebar-subgroup-content .sidebar-links {
@ -114,27 +110,17 @@ const { group, isNested = false } = Astro.props;
/* Page-level navigation styling */
.page-level {
border-bottom: 2px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.page-level .sidebar-group-title {
color: #3b82f6; /* blue-500 */
font-size: 0.8rem;
border-bottom: 1px solid #dbeafe; /* blue-100 */
}
.page-level .sidebar-link {
color: #1e40af; /* blue-800 */
font-weight: 500;
background-color: #f0f9ff; /* blue-50 */
border-radius: 0.375rem;
margin-bottom: 0.25rem;
}
.page-level .sidebar-link:hover {
background-color: #dbeafe; /* blue-100 */
color: #1d4ed8; /* blue-700 */
}
</style>

View File

@ -13,7 +13,7 @@ interface Props {
const {
headings,
title = "toc.on-this-page",
title = "",
minHeadingLevel = 2,
maxHeadingLevel = 4
} = Astro.props;

View File

@ -14,7 +14,7 @@ interface Props {
const {
headings,
title = "toc.on-this-page",
title = "",
minHeadingLevel = 2,
maxHeadingLevel = 4
} = Astro.props;

View File

@ -0,0 +1,19 @@
---
import BaseHead from "../components/BaseHead.astro";
import Navigation from "../components/global/Navigation.astro";
import Footer from "../components/global/Footer.astro";
import { isRTL } from "config/config.js"
const { frontmatter: frontmatter, ...rest } = Astro.props;
---
<html lang={Astro.currentLocale} class="scroll-smooth" dir={isRTL(Astro.currentLocale) ? "rtl" : "ltr"}>
<head>
<BaseHead frontmatter={frontmatter} {...rest} />
</head>
<body class="bg-white dark:bg-gray-900 mx-auto 2xl:max-w-7xl flex flex-col min-h-svh p-4 transition-colors duration-200">
<Navigation />
<main class="grow"><slot /></main>
<Footer />
</body>
</html>

View File

@ -0,0 +1,599 @@
---
/**
* Resources Layout
*
* Features:
* - Automatic top-level Table of Contents for articles with 20+ headings
* - Sidebar navigation with page-level navigation support
* - Breadcrumb navigation
* - Image lightbox functionality
* - Responsive design with mobile sidebar toggle
*/
import Sidebar from "@polymech/astro-base/components/sidebar/Sidebar.astro"
import MobileToggle from "@polymech/astro-base/components/sidebar/MobileToggle.astro"
import Breadcrumb from "@polymech/astro-base/components/Breadcrumb.astro";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { getSidebarConfig } from '@polymech/astro-base/config/sidebar';
import { generateToC } from '@polymech/astro-base/components/sidebar/utils/generateToC.js';
import RelativeImage from '@polymech/astro-base/components/RelativeImage.astro';
import type { MarkdownHeading } from 'astro';
import { getCollection } from 'astro:content';
import BaseLayout from "./BaseLayout.astro"
interface Props {
frontmatter: {
title: string;
description?: string;
author?: string;
pubDate?: Date;
tags?: string[];
image?: {
url: string;
alt: string;
};
sidebar?: any; // Page-level sidebar configuration
breadcrumb?: boolean; // Enable/disable breadcrumb (default: true)
bottomNav?: 'PREV/NEXT' | 'TOP' | false; // Bottom navigation type
minutesRead?: string; // Reading time from remark plugin
};
headings?: MarkdownHeading[];
entryPath?: string;
collectionName?: string; // Collection name for dynamic functionality
}
const { frontmatter, headings = [], entryPath, collectionName = 'resources' } = Astro.props; // Updated to get entryPath and collectionName
const locale = Astro.currentLocale;
const sidebarConfig = getSidebarConfig();
// Extract page-level sidebar configuration from frontmatter
const pageNavigation = frontmatter.sidebar ? [frontmatter.sidebar].flat() : [];
// Format the date
const formattedDate = frontmatter.pubDate ? new Date(frontmatter.pubDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : '';
// Generate top-level TOC for articles with many headings (10+)
const shouldShowTopToc = headings.length > 10;
const topToc = shouldShowTopToc ? generateToC(headings, {
minHeadingLevel: 2,
maxHeadingLevel: 3,
title: 'Table of Contents'
}) : null;
// Bottom navigation configuration
const BOTTOM_NAV = frontmatter.bottomNav || 'PREV/NEXT';
const showBottomNav = BOTTOM_NAV !== false;
const isPrevNext = BOTTOM_NAV === 'PREV/NEXT';
const isTop = BOTTOM_NAV === 'TOP';
// Get previous and next articles for navigation
let prevArticle: any = null;
let nextArticle: any = null;
if (isPrevNext && entryPath) {
try {
// Get all articles from the collection
const allArticles = await getCollection(collectionName);
// Extract the current article path without the collection prefix
const currentArticlePath = entryPath.replace(`${collectionName}/`, '');
// Sort articles by their full path to maintain hierarchical order
const sortedArticles = allArticles.sort((a, b) => a.id.localeCompare(b.id));
// Find current article index
const currentIndex = sortedArticles.findIndex(article => article.id === currentArticlePath);
if (currentIndex !== -1) {
// Get previous article
if (currentIndex > 0) {
prevArticle = sortedArticles[currentIndex - 1];
}
// Get next article
if (currentIndex < sortedArticles.length - 1) {
nextArticle = sortedArticles[currentIndex + 1];
}
}
} catch (error) {
console.warn('Could not fetch collection for navigation:', error);
}
}
---
<BaseLayout frontmatter={frontmatter}>
<div class="layout-with-sidebar">
<!-- Mobile Toggle -->
<MobileToggle />
<!-- Sidebar -->
<div class="sidebar-wrapper">
<Sidebar
config={sidebarConfig}
currentUrl={Astro.url}
headings={headings}
pageNavigation={pageNavigation}
/>
</div>
<!-- Main Content -->
<main class="main-content-with-sidebar">
<div class="px-4 py-4 md:px-6 md:py-6">
{/* Breadcrumb - enabled by default, can be disabled with breadcrumb: false */}
{frontmatter.breadcrumb !== false && (
<Breadcrumb
currentPath={Astro.url.pathname}
collection={collectionName}
title={frontmatter.title}
/>
)}
<article class="prose prose-lg max-w-full md:max-w-4xl mx-auto overflow-x-hidden" id="top">
<header class="mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
{frontmatter.title}
</h1>
{/* Reading time and metadata */}
{(frontmatter.author && frontmatter.author.toLowerCase() !== 'unknown') || formattedDate || frontmatter.minutesRead ? (
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
{frontmatter.minutesRead && (
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<Translate>{frontmatter.minutesRead}</Translate>
</span>
)}
{frontmatter.author && frontmatter.author.toLowerCase() !== 'unknown' && <span>By {frontmatter.author}</span>}
{formattedDate && <span>{formattedDate}</span>}
</div>
) : null}
<p class="text-xl text-gray-700 mb-6">{frontmatter.description}</p>
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-6">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-blue-100 text-blue-800 rounded-full hover:bg-blue-200 hover:text-blue-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
</div>
)}
{/* Top-level Table of Contents for long articles */}
{shouldShowTopToc && topToc && (
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
<Translate>Table of Contents</Translate>
</h2>
<nav class="top-toc-nav">
{topToc.length >= 10 ? (
/* 2-column layout for 10+ items with even distribution */
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
{/* Debug info */}
{/* Left column: first half of items */}
<div class="space-y-2">
{topToc.slice(0, Math.floor(topToc.length / 2)).map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{index + 1}.</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
{/* Right column: second half of items */}
<div class="space-y-2">
{topToc.slice(Math.floor(topToc.length / 2)).map((item, index) => (
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 rounded px-2 hover:bg-blue-50 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{Math.floor(topToc.length / 2) + index + 1}.</span>
<span class="toc-text">{item.text}</span>
</a>
))}
</div>
</div>
) : (
/* Single column for fewer items */
<ul class="space-y-2">
{topToc.map((item, index) => (
<li>
<a
href={`#${item.slug}`}
class="text-blue-600 hover:text-blue-800 hover:underline transition-colors block py-1 leading-relaxed"
>
<span class="font-mono text-gray-500 mr-2 flex-shrink-0">{index + 1}.</span>
<span class="inline-block">{item.text}</span>
</a>
</li>
))}
</ul>
)}
</nav>
</div>
)}
</header>
{frontmatter.image?.url && (
<RelativeImage
src={frontmatter.image.url}
alt={frontmatter.image.alt || frontmatter.title}
class="w-full h-auto rounded-lg mb-8 object-cover"
entryPath={entryPath}
/>
)}
<div class="markdown-content">
<slot />
</div>
{/* Tags at bottom of article */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="mt-8 pt-6 border-t border-gray-200">
<h3 class="text-sm font-medium text-gray-700 mb-3">Tags:</h3>
<div class="flex flex-wrap gap-2">
{frontmatter.tags.map((tag: string) => (
<a
href={`/${locale}/${collectionName}/tags/${tag}`}
class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 hover:text-gray-900 transition-colors cursor-pointer"
>
{tag}
</a>
))}
</div>
</div>
)}
{/* Bottom Navigation */}
{showBottomNav && (
<div class="mt-12 pt-8 border-t border-gray-200">
{isPrevNext ? (
<div class="flex justify-between items-center">
<div class="flex-1">
{prevArticle ? (
<a href={`/${locale}/${collectionName}/${prevArticle.id}`} class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<svg class="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<span class="text-sm font-medium">← {prevArticle.data.title}</span>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>First Article</Translate> ←
</div>
)}
</div>
<div class="flex-1 text-right">
{nextArticle ? (
<a href={`/${locale}/${collectionName}/${nextArticle.id}`} class="group inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<span class="text-sm font-medium">{nextArticle.data.title} →</span>
<svg class="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
) : (
<div class="text-gray-400 text-sm">
<Translate>Last Article</Translate> →
</div>
)}
</div>
</div>
) : isTop ? (
<div class="text-center">
<a href="#top" class="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
<span class="text-sm font-medium">
<Translate>Back to Top</Translate>
</span>
</a>
</div>
) : null}
</div>
)}
</article>
</div>
</main>
</div>
<!-- Global Lightbox -->
<div id="global-lightbox" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" style="display: none;">
<div class="relative max-w-full max-h-full">
<img id="lightbox-image" src="" alt="" class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg" />
<!-- Close Button -->
<button id="lightbox-close" class="absolute -top-4 -right-4 text-2xl p-2 bg-gray-800/75 rounded-full">&times;</button>
<!-- Navigation Buttons -->
<button id="lightbox-prev" class="absolute left-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg">&#10094;</button>
<button id="lightbox-next" class="absolute right-0 top-1/2 -translate-y-1/2 p-4 text-3xl bg-gray-800/75 rounded-lg">&#10095;</button>
</div>
</div>
</BaseLayout>
<style>
/* Top-level TOC styling */
.top-toc-nav ul {
list-style: none;
padding: 0;
margin: 0;
}
.top-toc-nav li {
margin: 0;
}
.top-toc-nav a {
display: block;
text-decoration: none;
border-radius: 0.375rem;
transition: all 0.2s ease;
color: #3b82f6; /* text-blue-600 */
}
.top-toc-nav a:hover {
background-color: #f3f4f6;
color: #1d4ed8; /* text-blue-800 */
}
.top-toc-nav a:visited {
color: #8b5cf6; /* text-violet-600 */
}
.top-toc-nav a:visited:hover {
color: #7c3aed; /* text-violet-700 */
}
/* Visited links styling */
.top-toc-nav a.visited {
color: #8b5cf6; /* text-violet-600 */
}
.top-toc-nav a.visited:hover {
color: #7c3aed; /* text-violet-700 */
}
/* Active/current section styling */
.top-toc-nav a.active {
background-color: #eff6ff; /* bg-blue-50 */
color: #1d4ed8; /* text-blue-800 */
font-weight: 500;
border-left: 3px solid #3b82f6; /* border-blue-500 */
padding-left: 0.75rem;
}
/* 2-column grid styling */
.top-toc-nav .grid {
align-items: start;
}
/* CSS Columns for natural left-to-right flow */
.toc-columns {
column-count: 2;
column-gap: 2rem;
column-fill: auto;
}
.toc-columns a {
break-inside: avoid;
margin-bottom: 0.5rem;
display: block;
}
/* Better TOC text wrapping */
.toc-text {
display: inline;
word-wrap: break-word;
hyphens: auto;
line-height: 1.4;
}
/* Prevent awkward line breaks */
.top-toc-nav a {
min-height: 2.5rem;
display: flex;
align-items: flex-start;
}
.top-toc-nav a .font-mono {
margin-top: 0;
line-height: 1.4;
align-self: flex-start;
}
.top-toc-nav a .toc-text {
flex: 1;
line-height: 1.4;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.top-toc-nav .grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.toc-columns {
column-count: 1;
column-gap: 0;
}
}
/* Reading time styling */
.reading-time {
display: inline-flex;
align-items: center;
color: #6b7280; /* text-gray-500 */
font-size: 0.875rem;
font-weight: 500;
}
.reading-time svg {
color: #9ca3af; /* text-gray-400 */
margin-right: 0.25rem;
}
/* Bottom navigation styling */
.bottom-nav {
border-top: 1px solid #e5e7eb;
padding-top: 2rem;
margin-top: 3rem;
}
.bottom-nav a {
transition: all 0.2s ease;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
}
.bottom-nav a:hover {
background-color: #f3f4f6;
}
.bottom-nav svg {
transition: transform 0.2s ease;
}
.bottom-nav .group:hover svg {
transform: translateX(-4px);
}
.bottom-nav .group:hover svg:last-child {
transform: translateX(4px);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
// TOC functionality
const tocLinks = document.querySelectorAll('.top-toc-nav a[href^="#"]');
// Track visited sections
tocLinks.forEach(link => {
link.addEventListener('click', () => {
const targetId = link.getAttribute('href')?.substring(1);
if (targetId) {
// Mark as visited in localStorage
const visited = JSON.parse(localStorage.getItem('toc-visited') || '[]');
if (!visited.includes(targetId)) {
visited.push(targetId);
localStorage.setItem('toc-visited', JSON.stringify(visited));
}
// Update visited state
updateTocStates();
}
});
});
// Update TOC states based on visited sections and current position
function updateTocStates() {
const visited = JSON.parse(localStorage.getItem('toc-visited') || '[]');
tocLinks.forEach(link => {
const targetId = link.getAttribute('href')?.substring(1);
if (targetId) {
// Remove existing classes
link.classList.remove('active', 'visited');
// Add visited class if section has been visited
if (visited.includes(targetId)) {
link.classList.add('visited');
}
}
});
}
// Intersection Observer for active section highlighting
const headings = document.querySelectorAll('h1[id], h2[id], h3[id]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
// Update active state
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${id}`) {
link.classList.add('active');
}
});
}
});
}, {
rootMargin: '-20% 0% -35% 0%',
threshold: 0.1
});
headings.forEach(heading => observer.observe(heading));
// Initialize TOC states
updateTocStates();
// Lightbox functionality
const lightbox = document.getElementById('global-lightbox') as HTMLElement;
const lightboxImage = document.getElementById('lightbox-image') as HTMLImageElement;
const closeButton = document.getElementById('lightbox-close') as HTMLButtonElement;
const prevButton = document.getElementById('lightbox-prev') as HTMLButtonElement;
const nextButton = document.getElementById('lightbox-next') as HTMLButtonElement;
if (!lightbox || !lightboxImage || !closeButton || !prevButton || !nextButton) return;
let images: { src: string, alt: string }[] = [];
let currentIndex = 0;
function updateLightbox() {
if (images.length > 0 && images[currentIndex]) {
lightboxImage.src = images[currentIndex].src;
lightboxImage.alt = images[currentIndex].alt;
prevButton.style.display = currentIndex > 0 ? 'block' : 'none';
nextButton.style.display = currentIndex < images.length - 1 ? 'block' : 'none';
}
}
window.addEventListener('open-lightbox', (event: CustomEvent) => {
images = event.detail.images;
currentIndex = event.detail.currentIndex;
updateLightbox();
lightbox.style.display = 'flex';
});
closeButton.addEventListener('click', () => {
lightbox.style.display = 'none';
});
prevButton.addEventListener('click', () => {
if (currentIndex > 0) {
currentIndex--;
updateLightbox();
}
});
nextButton.addEventListener('click', () => {
if (currentIndex < images.length - 1) {
currentIndex++;
updateLightbox();
}
});
window.addEventListener('keydown', (event) => {
if (lightbox.style.display === 'none') return;
if (event.key === 'Escape') closeButton.click();
if (event.key === 'ArrowLeft') prevButton.click();
if (event.key === 'ArrowRight') nextButton.click();
});
});
</script>

View File

@ -0,0 +1,368 @@
---
import "flowbite";
import { createMarkdownComponent } from "@/base/index.js";
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import LGallery from "@polymech/astro-base/components/GalleryK.astro";
import BaseLayout from "./BaseLayout.astro";
import Wrapper from "@/components/containers/Wrapper.astro";
import Readme from "@/components/polymech/readme.astro";
import Breadcrumb from "@/components/Breadcrumb.astro";
import Resources from "@/components/polymech/resources.astro";
import Specs from "@polymech/astro-base/components/specs.astro";
import TabButton from "@polymech/astro-base/components/tab-button.astro";
import TabContent from "@polymech/astro-base/components/tab-content.astro";
import {
I18N_SOURCE_LANGUAGE,
SHOW_3D_PREVIEW,
SHOW_FILES,
SHOW_GALLERY,
SHOW_RENDERINGS,
SHOW_LICENSE,
SHOW_DESCRIPTION,
SHOW_TABS,
SHOW_SPECS,
SHOW_DEBUG,
SHOW_SAMPLES,
SHOW_RESOURCES,
SHOW_CHECKOUT,
SHOW_README,
isRTL,
} from "config/config.js";
const { frontmatter: data, ...rest } = Astro.props;
const content = await translate(
data.content || "",
I18N_SOURCE_LANGUAGE,
Astro.currentLocale,
);
const Body = createMarkdownComponent(content);
const str_debug =
"```json\n" +
JSON.stringify(
{
...data,
config: null,
},
null,
2,
) +
"\n```";
const Content_Debug = await createMarkdownComponent(str_debug);
---
<BaseLayout frontmatter={data} description={data.description} {...rest}>
<Wrapper>
<Breadcrumb
currentPath={Astro.url.pathname}
collection="store"
title={data.title}
showHome={true}
/>
<section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<!-- Left Column: Description -->
<div class="flex flex-col gap-4">
<div>
<h1 class=" font-semibold mb-2 text-2xl">
<Translate>{`${data.title}`}</Translate>
</h1>
{
isRTL(Astro.currentLocale) && (
<div class=" font-semibold mb-2">
"{data.title}"
</div>
)
}
</div>
<article class="markdown-content bg-white dark:bg-gray-800 rounded-xl p-4">
<Body />
</article>
<div class="flex flex-col gap-2">
{
SHOW_3D_PREVIEW &&
data.Preview3d &&
data.cad &&
data.cad[0] &&
data.cad[0][".html"] && (
<a
href={data.cad[0][".html"]}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white dark:bg-gray-800 hover:bg-neutral-200 dark:hover:bg-gray-700 duration-300 rounded-xl w-full justify-between rounded-xl"
>
<span class="relative uppercase text-xs">
<Translate>3D Preview</Translate>
</span>
<div
aria-hidden="true"
class="w-12 text-orange-600 transition duration-300 -translate-y-7 group-hover:translate-y-7"
>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
/>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
/>
</svg>
</div>
</div>
</a>
)
}
{
SHOW_CHECKOUT && data.checkout && (
<a
href={data.checkout}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white dark:bg-gray-800 hover:bg-black dark:hover:bg-gray-700 duration-300 rounded-xl w-full justify-between"
>
<span class="relative uppercase text-xs ">
<Translate>Add to cart</Translate>
</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7"
>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
/>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
/>
</svg>
</div>
</div>
</a>
)
}
</div>
{
SHOW_LICENSE && (
<div class="bg-white dark:bg-gray-800 rounded-xl p-4">
<h3 class="text-lg text-neutral-600 dark:text-gray-300 uppercase tracking-tight">
License
</h3>
<p class=" mt-4 text-sm text-gray-700 dark:text-gray-300">{data.license}</p>
</div>
)
}
</div>
<!-- Right Column: Gallery and Actions -->
<div class="flex flex-col gap-4">
{
SHOW_RENDERINGS && data.assets?.renderings && (
<div class="bg-white dark:bg-gray-800 rounded-xl p-4">
<LGallery
images={data.assets.renderings}
gallerySettings={{ SHOW_TITLE: false }}
/>
</div>
)
}
</div>
</div>
</section>
{data.assets.showcase && data.assets.showcase.length > 0 && (
<section>
<div
class="bg-white dark:bg-gray-800 rounded-xl p-4 md:mb-16 mt-0 p-2 md:p-4 border-b border-gray-200 dark:border-gray-700"
>
<LGallery
images={data.assets.showcase}
lightboxSettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
SIZES_THUMB: "w-32 h-32",
}}
gallerySettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
//SIZES_THUMB: "w-32 h-32",
}}
/>{" "}
</div>
</section>
)}
<section id="tabs-view">
<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-styled-tab"
data-tabs-toggle="#default-styled-tab-content"
data-tabs-active-classes="text-orange-600 hover:text-orange-600 dark:text-purple-500 dark:hover:text-purple-500 border-orange-600 dark:border-purple-500"
data-tabs-inactive-classes="dark:border-transparent text-gray-500 hover:text-gray-600 dark:text-gray-400 border-gray-100 hover:border-gray-300 dark:border-gray-700 dark:hover:text-gray-300"
role="tablist"
>
{SHOW_README && <TabButton title="Overview" />}
<TabButton title="Specs" />
<TabButton title="Gallery" />
<TabButton title="Resources" />
{SHOW_SAMPLES && <TabButton title="Samples" />}
{SHOW_DEBUG && <TabButton title="Debug" />}
</ul>
</div>
<div id="default-styled-tab-content">
<TabContent title="Overview">
{
SHOW_README && data.readme && (
<Readme markdown={data.readme} data={data} />
)
}
</TabContent>
<div
class="hidden bg-white rounded-xl dark:bg-gray-800 "
id="specs-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Specs frontmatter={data} />
</div>
<div
class="hidden p-0 md:p-4 rounded-lg bg-white dark:bg-gray-800"
id="gallery-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.gallery} />
</div>
{
SHOW_SAMPLES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="samples-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<LGallery images={data.assets.samples} />
</div>
)
}
{
SHOW_RESOURCES && (
<div
class="hidden p-4 bg-white rounded-xl dark:bg-gray-800"
id="resources-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Resources frontmatter={data} />
</div>
)
}
{
SHOW_DEBUG && (
<div
class="hidden rounded-lg bg-white p-4 dark:bg-gray-800"
id="debug-view"
role="tabpanel"
aria-labelledby="dashboard-tab"
>
<Content_Debug />
</div>
)
}
</div>
<script>
window.addEventListener("hashchange", () => {
const hash = window.location.hash.substring(1);
if (hash) {
const tabTrigger = document.querySelector(
`a[href="#${hash}"], a[data-tabs-target="#${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
}
});
document.addEventListener("DOMContentLoaded", () => {
const hash = window.location.hash;
if (hash) {
const tabTrigger = document.querySelector(
`a[href="${hash}"], a[data-tabs-target="${hash}"]`,
);
if (tabTrigger) {
setTimeout(() => {
(tabTrigger as HTMLElement).click();
}, 100);
}
}
document.querySelectorAll("a[data-tabs-target]").forEach((tab) => {
tab.addEventListener("click", () => {
const href = tab.getAttribute("href");
if (href) {
window.location.hash = href;
}
});
});
});
</script>
</section>
</Wrapper>
</BaseLayout>

View File

@ -0,0 +1,36 @@
---
import BaseLayout from './BaseLayout.astro';
import Sidebar from '@/components/sidebar/Sidebar.astro';
import MobileToggle from '@/components/sidebar/MobileToggle.astro';
import { getSidebarConfig } from '@/components/sidebar/config';
interface Props {
frontmatter: {
title: string;
description?: string;
[key: string]: any;
};
}
const { frontmatter } = Astro.props;
const sidebarConfig = getSidebarConfig();
---
<BaseLayout frontmatter={frontmatter}>
<div class="layout-with-sidebar">
<!-- Mobile Toggle -->
<MobileToggle />
<!-- Sidebar -->
<div class="sidebar-wrapper">
<Sidebar config={sidebarConfig} currentUrl={Astro.url} />
</div>
<!-- Main Content -->
<main class="main-content-with-sidebar">
<div class="container mx-auto px-4 py-8 max-w-4xl">
<slot />
</div>
</main>
</div>
</BaseLayout>

View File

@ -0,0 +1,17 @@
---
interface Props {
variant?: "standard"; // Define any variants you need
class?: string; // Optional prop for additional classes
}
const { variant = "standard", class: extraClass = "" } = Astro.props;
// Map each variant to its specific classes
const variantClasses = {
standard: "max-w-2xl 2xl:max-w-3xl mx-auto px-8 w-full",
};
// Combine the classes for the specified variant with any extra classes
const classes = `${variantClasses[variant]} ${extraClass}`.trim();
---
<div class={classes}>
<slot />
</div>

View File

@ -0,0 +1,52 @@
import type { IComponentNode, IComponentConfig } from '@polymech/commons'
import config from "../app/config.json"
interface ProductJsonLD {
'@context': 'https://schema.org'
'@type': 'Product'
name: string
description?: string
sku?: string
image?: string[]
brand?: {
'@type': 'Brand'
name: string
}
offers?: {
'@type': 'Offer'
price?: number
priceCurrency?: string
availability?: string
url?: string
}
}
export const get = async (node: IComponentNode, component: IComponentConfig, opts:{
url?:string
}): Promise<ProductJsonLD> => {
const jsonLD: ProductJsonLD = {
'@context': 'https://schema.org',
'@type': 'Product',
name: component.name,
description: component.keywords,
sku: component.code,
brand: {
'@type': 'Brand',
name: config.ecommerce?.brand || config.site.title
}
}
if (component.image?.url) {
jsonLD.image = [component.image.url]
}
if (component.price) {
jsonLD.offers = {
'@type': 'Offer',
price: component.price,
priceCurrency: config.ecommerce?.currencyCode || 'EU',
availability: 'https://schema.org/InStock',
url: opts.url || config.site.base_url
}
}
return jsonLD
}

View File

@ -0,0 +1,33 @@
import type { IComponentNode, IComponentConfig } from '@polymech/commons'
import config from "../app/config.json"
interface GoogleMerchantProduct {
id: string
title: string
description?: string
link: string
image_link?: string
price?: string // price + ISO currency
availability?: string
brand?: string
condition?: string
gtin?: string // Global Trade Item Number
}
export const get = async (node: IComponentNode, config: IComponentConfig, opts: {
url?:string
}): Promise<GoogleMerchantProduct> => {
const product: GoogleMerchantProduct = {
id: config.code,
title: config.name,
description: config.keywords,
link: node.path,
image_link: config.image?.url,
price: config.price?.toString() + ' USD',
availability: 'in_stock',
brand: 'Polymech',
condition: 'new'
}
return product
}

View File

@ -0,0 +1,23 @@
//import { get as handleRSS } from './rss.js'
import { get as handleMerchant } from './merchant.js'
import { get as handleJsonLd } from './json-ld.js'
import type { IComponentNode, IComponentConfig } from '@polymech/commons/'
export type Handler = (node: IComponentNode, config: IComponentConfig, opts: { url?: string }) => Promise<any>
export const registry: Record<string, Handler> = {
//'rss': handleRSS,
'merchant': handleMerchant,
'json-ld': handleJsonLd
}
export const get = async (type: string, node: IComponentNode, config: IComponentConfig, opts: {
url?: string
}) => {
const handler = registry[type]
if (!handler) {
return false
}
return await handler(node, config, opts)
}

View File

@ -0,0 +1,12 @@
import type { IComponentNode, IComponentConfig } from '@polymech/commons'
export const get = async (node: IComponentNode, config: IComponentConfig): Promise<any> => {
return {
title: config.name,
description: config.keywords || '',
items: [{
title: config.name,
description: config.keywords || '',
link: node.path
}]
}
}

View File

@ -0,0 +1,110 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
Astro.redirect("/en/home");
---
<BaseLayout>
<section>
<div
class="flex flex-col gap-12 h-full justify-between p-4 text-center py-20">
<div class="max-w-xl mx-auto">
<h1
class="text-lg text-neutral-600 tracking-tight text-balance">
404 Page not found
</h1>
<p class="text-sm text-balance ">
The page you are looking for does not exist. Please try again. If the
problem persists, please contact us.
</p>
<div class="gap-2 flex flex-col h-full justify-end mt-12">
<a
href="/"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 duration-300 rounded-xl w-full justify-between">
<span class="relative uppercase text-xs text-orange-600"
>Go home</span
>
<div
aria-hidden="true"
class="w-12 text-orange-600 transition duration-300 -translate-y-7 group-hover:translate-y-7">
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
</div>
</a>
<a
href="/forms/contact"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 h-14 flex space-x-6 items-center hover:bg-black duration-300 rounded-xl w-full justify-between">
<span class="relative uppercase text-xs "
>Contact us</span
>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
</div>
</a>
</div>
</div>
</div>
</section>
</BaseLayout>

View File

@ -0,0 +1,66 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import Wrapper from "@/components/containers/Wrapper.astro";
import { LANGUAGES_PROD } from "config/config.js";
import { getCollection } from "astro:content";
import StoreEntries from "@/components/store/StoreEntries.astro";
import { translate } from "@/base/i18n.js"
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { slugify } from "@/base/strings.js"
export function getStaticPaths() {
const all: unknown[] = [];
LANGUAGES_PROD.forEach((lang) => {
all.push({
params: {
locale: lang,
path: "home",
},
});
});
return all;
}
const allProducts = await getCollection("store");
const locale = Astro.currentLocale
const store = `/${locale}/store/`;
const group_label = async (text: string) => await translate(slugify(text), I18N_SOURCE_LANGUAGE, locale)
const group = async (items) => {
return items.reduce(async (accPromise, item: any) => {
const acc = await accPromise
const id = item.id.split("/")[1]
let key:string = (await group_label(id))
key = key.charAt(0).toUpperCase() + key.slice(1)
if (!acc[key]) {
acc[key] = []
}
acc[key].push(item)
return acc
}, {})
}
const items = await group(allProducts)
---
<BaseLayout>
<Wrapper variant="standard" class="py-4">
{
Object.keys(items).map((relKey) => (
<section class="mb-12">
<h1 class="mb-6 text-2xl font-semibold"> {relKey} </h1>
<div class="grid sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-6">
{items[relKey].map((post) => (
<StoreEntries
key={post.id}
url={store + post.id}
title={post.data.title}
type={post.data.type}
alt={post.data.title}
model={post.data}
/>
))}
</div>
</section>
))
}
</Wrapper>
</BaseLayout>

View File

@ -0,0 +1,347 @@
---
import { getCollection, render } from "astro:content"
import Resources from "@/layouts/Resources.astro"
import BaseLayout from "@/layouts/BaseLayout.astro";
import Sidebar from "@polymech/astro-base/components/sidebar/Sidebar.astro"
import MobileToggle from "@polymech/astro-base/components/sidebar/MobileToggle.astro"
import { getSidebarConfig } from '@polymech/astro-base/config/sidebar';
import ResourceCard from "@/components/resources/ResourceCard.astro";
import { LANGUAGES_PROD, COLLECTION_FILTERS } from "config/config.js"
import { generateBreadcrumbs, calculateReadingTime, getStaticPaths_fs } from '@polymech/astro-base/base/collections';
import { translate } from "@polymech/astro-base/base/i18n.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { I18N_SOURCE_LANGUAGE } from "config/config.js";
const collectionName = 'resources';
const collectionDescription = 'Discover insights, tutorials, and best practices from our collection of technical articles and resources.';
export async function getStaticPaths() {
const collectionName = 'resources';
const paths = await getStaticPaths_fs(getCollection, collectionName, LANGUAGES_PROD, COLLECTION_FILTERS);
// Add root path for each language
LANGUAGES_PROD.forEach((lang) => {
paths.push({
params: {
locale: lang,
slug: '', // Empty slug for root
},
props: {
folderPath: '', // Empty for root
posts: [], // Will be populated in the component
locale: lang,
type: 'folder'
}
});
});
return paths;
}
const { entry, folderPath, posts, locale, type } = Astro.props as any;
// Initialize variables for both types
let sidebarConfig: any;
let folderTitle: string = '';
let sortedPosts: any[] = [];
let breadcrumbs: any[] = [];
let Content: any;
let headings: any;
let finalReadingTime: string | undefined;
if (type === 'folder') {
// Handle folder view
sidebarConfig = getSidebarConfig();
// If root path, get all posts and group them
if (!folderPath || folderPath === '') {
// Get all posts for root view
const allPosts = await getCollection(collectionName);
sortedPosts = allPosts.sort((a, b) =>
new Date(b.data.pubDate).getTime() - new Date(b.data.pubDate).getTime()
);
// Group posts by folder for root view
const groupedPosts = allPosts.reduce((groups: Record<string, any[]>, post: any) => {
const segments = post.id.split('/').filter((segment: string) => segment !== '');
const postFolder = segments.length > 1 ? segments.slice(0, -1).join('/') : 'root';
if (!groups[postFolder]) {
groups[postFolder] = [];
}
groups[postFolder].push(post);
return groups;
}, {} as Record<string, any[]>);
// Sort posts within each group by date
Object.keys(groupedPosts).forEach(folder => {
groupedPosts[folder].sort((a, b) =>
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
);
});
// Store grouped posts for rendering
(Astro.props as any).groupedPosts = groupedPosts;
folderTitle = 'Resources & Articles';
breadcrumbs = await generateBreadcrumbs('', locale, collectionName);
} else {
// Handle specific folder
folderTitle = (folderPath as string)
.split('/')
.map(segment => segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
)
.join(' / ');
// Sort posts by publication date (newest first)
sortedPosts = (posts as any[]).sort((a, b) =>
new Date(b.data.pubDate).getTime() - new Date(b.data.pubDate).getTime()
);
breadcrumbs = await generateBreadcrumbs(folderPath as string, locale, collectionName);
}
} else {
// Handle individual article
const rendered = await render(entry);
Content = rendered.Content;
headings = rendered.headings;
finalReadingTime = await calculateReadingTime(entry, rendered, locale);
}
---
{type === 'folder' ? (
<BaseLayout frontmatter={{
title: `${folderTitle} - ${await translate(collectionName.charAt(0).toUpperCase() + collectionName.slice(1), I18N_SOURCE_LANGUAGE, locale)}`,
description: await translate(`Browse articles in the ${folderTitle} category`, I18N_SOURCE_LANGUAGE, locale, `Browse articles in the ${folderTitle} category`)
}}>
<div class="layout-with-sidebar">
<!-- Mobile Toggle -->
<MobileToggle />
<!-- Sidebar -->
<div class="sidebar-wrapper">
<Sidebar config={sidebarConfig} currentUrl={Astro.url} />
</div>
<!-- Main Content -->
<main class="main-content-with-sidebar">
<div class="container mx-auto px-4 py-4 md:px-6 md:py-6">
<section class="py-8">
<!-- Header -->
<div class="mb-8">
<!-- Breadcrumb -->
<nav class="text-sm mb-4">
{breadcrumbs.map((crumb, index) => (
<span>
{crumb.href ? (
<a href={crumb.href} class="text-gray-500 hover:text-gray-700">
{crumb.label}
</a>
) : (
<span class="text-gray-900">{crumb.label}</span>
)}
{index < breadcrumbs.length - 1 && (
<span class="text-gray-400 mx-2">/</span>
)}
</span>
))}
</nav>
<h1 class="text-3xl font-bold text-gray-900 mb-4">
{folderTitle}
</h1>
<p class="text-lg text-gray-600 mb-6">
{!folderPath || folderPath === '' ? (
<Translate>
{collectionDescription}
</Translate>
) : (
<Translate>
{`${(posts as any[]).length} article${(posts as any[]).length !== 1 ? 's' : ''} in this category`}
</Translate>
)}
</p>
</div>
{/* Articles Grid - Different layout for root vs folder */}
{!folderPath || folderPath === '' ? (
// Root view: Show grouped posts by folder
(Astro.props as any).groupedPosts ? (
Object.keys((Astro.props as any).groupedPosts).map((folderPath) => {
const posts = (Astro.props as any).groupedPosts[folderPath];
const formatFolderName = (path: string) => {
if (path === 'root') return 'Latest Articles';
return path
.split('/')
.map(segment => segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
)
.join(' / ');
};
return (
<div class="mb-12">
{/* Folder Header */}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"/>
</svg>
{folderPath === 'root' ? (
<h2 class="text-2xl font-semibold text-gray-900">
{formatFolderName(folderPath)}
</h2>
) : (
<a
href={`/${locale}/${collectionName}/${folderPath}/`}
class="text-2xl font-semibold text-gray-900 hover:text-orange-600 transition-colors"
>
{formatFolderName(folderPath)}
</a>
)}
</div>
<div class="flex items-center text-sm text-gray-500">
<span class="bg-gray-100 px-2 py-1 rounded-full">
{posts.length} article{posts.length !== 1 ? 's' : ''}
</span>
{folderPath !== 'root' && (
<a
href={`/${locale}/${collectionName}/${folderPath}/`}
class="ml-3 text-orange-600 hover:text-orange-700 font-medium"
>
View All →
</a>
)}
</div>
</div>
{/* Articles Grid for this folder */}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post: any) => (
<ResourceCard
url={`/${locale}/${collectionName}/` + post.id}
title={post.data.title}
description={post.data.description}
alt={post.data.image?.alt || post.data.title}
pubDate={post.data.pubDate.toString().slice(0, 10)}
author={post.data.author}
image={post.data.image?.url}
tags={post.data.tags}
path={post.id}
locale={locale}
contentId={post.id}
collectionName={collectionName}
/>
))}
</div>
</div>
);
})
) : (
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
<Translate>No articles available</Translate>
</h3>
<p class="text-gray-600">
<Translate>Check back soon for new content!</Translate>
</p>
</div>
)
) : (
// Folder view: Show posts in simple grid
(posts as any[]).length > 0 ? (
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedPosts.map((post) => (
<ResourceCard
url={`/${locale}/${collectionName}/` + post.id}
title={post.data.title}
description={post.data.description}
alt={post.data.image?.alt || post.data.title}
pubDate={post.data.pubDate.toString().slice(0, 10)}
author={post.data.author}
image={post.data.image?.url}
tags={post.data.tags}
path={post.id}
locale={locale}
contentId={post.id}
collectionName={collectionName}
/>
))}
</div>
) : (
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
<Translate>No articles in this category</Translate>
</h3>
<p class="text-gray-600">
<Translate>This folder appears to be empty.</Translate>
</p>
</div>
)
)}
{/* Back to Resources - Only show when not on root */}
{folderPath && folderPath !== '' && (
<div class="mt-12 text-center">
<a
href={`/${locale}/${collectionName}/`}
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<Translate>Back to Resources</Translate>
</a>
</div>
)}
</section>
</div>
</main>
</div>
</BaseLayout>
) : (
<Resources
frontmatter={{
...(entry as any).data,
minutesRead: finalReadingTime
}}
headings={headings}
entryPath={(Astro.props as any).entryPath}
collectionName={collectionName}
>
<Content />
{/* Editor Overlay Metadata */}
<script type="application/json" id="editor-metadata">
{JSON.stringify({
srcPath: (Astro.props as any).entryPath,
lang: locale,
author: entry.data.author || 'Unknown',
date: entry.data.pubDate ? new Date(entry.data.pubDate).toISOString() : null,
title: entry.data.title,
description: entry.data.description,
tags: entry.data.tags || [],
collection: collectionName,
slug: entry.id,
readingTime: finalReadingTime
})}
</script>
</Resources>
)}

View File

@ -0,0 +1,86 @@
---
import Resources from "@/layouts/Resources.astro";
import ResourceCard from "@/components/resources/ResourceCard.astro";
import { getCollection } from "astro:content";
import { LANGUAGES_PROD } from "config/config.js";
import Translate from "@polymech/astro-base/components/i18n.astro";
export async function getStaticPaths() {
const allPosts = await getCollection("resources");
const uniqueTags = [
...new Set(allPosts.map((post) => post.data.tags).flat()),
];
const paths: any[] = [];
// Generate paths for each locale and tag combination
LANGUAGES_PROD.forEach((locale) => {
uniqueTags.forEach((tag) => {
const filteredPosts = allPosts.filter((post) =>
post.data.tags.includes(tag)
);
paths.push({
params: {
locale,
tag
},
props: {
posts: filteredPosts,
locale,
tag
},
});
});
});
return paths;
}
const { tag, locale } = Astro.params;
const { posts } = Astro.props;
const collectionName = posts.length > 0 ? posts[0].collection : 'resources';
---
<Resources
frontmatter={{
title: `${collectionName.charAt(0).toUpperCase() + collectionName.slice(1)} tagged with ${tag}`,
description: `${posts.length} ${collectionName}${posts.length !== 1 ? 's' : ''} found`,
breadcrumb: true,
bottomNav: 'TOP'
}}
collectionName={collectionName}
>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-6 py-4">
{posts.map((post) => (
<ResourceCard
url={`/${locale}/${collectionName}/${post.id}`}
title={post.data.title}
description={post.data.description}
alt={post.data.image?.alt || post.data.title}
pubDate={post.data.pubDate.toLocaleDateString()}
author={post.data.author}
image={post.data.image?.url}
tags={post.data.tags}
path={post.id}
locale={locale}
contentId={post.id}
collectionName={collectionName}
/>
))}
</div>
<!-- Back to Tags -->
<div class="mt-12 text-center">
<a
href={`/${locale}/${collectionName}/tags/`}
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<Translate>Back to All Tags</Translate>
</a>
</div>
</Resources>

View File

@ -0,0 +1,215 @@
---
import StoreLayout from '@/layouts/StoreLayout.astro'
import { getCollection } from 'astro:content'
import { LANGUAGES_PROD } from "config/config.js"
import StoreEntries from "@/components/store/StoreEntries.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import Wrapper from "@/components/containers/Wrapper.astro";
import Translate from "@polymech/astro-base/components/i18n.astro";
import { translate } from "@/base/i18n.js"
import { I18N_SOURCE_LANGUAGE } from "config/config.js"
import { slugify } from "@/base/strings.js"
export async function getStaticPaths()
{
const view = 'store'
const allProducts = await getCollection(view)
const all: unknown[] = []
LANGUAGES_PROD.forEach((lang) => {
// Add individual product routes
allProducts.forEach((product) => {
all.push({
params: {
locale: lang,
path: product.id,
},
props: {
page: product,
locale: lang,
path: product.id,
view,
type: 'product'
}
})
})
// Add folder routes for categories
const folders = new Set<string>()
allProducts.forEach(product => {
const segments = product.id.split('/').filter(segment => segment !== '')
if (segments.length > 1) {
// Add all possible folder paths (e.g., products, products/sheetpress, etc.)
for (let i = 1; i < segments.length; i++) {
const folderPath = segments.slice(0, i).join('/')
folders.add(folderPath)
}
}
})
// Add folder paths
Array.from(folders).forEach(folder => {
all.push({
params: {
locale: lang,
path: folder,
},
props: {
folderPath: folder,
products: allProducts.filter(product => {
const productFolder = product.id.split('/').slice(0, -1).join('/')
return productFolder === folder
}),
locale: lang,
view,
type: 'folder'
}
})
})
// Add special case for store/ (root store) - shows all products
all.push({
params: {
locale: lang,
path: '',
},
props: {
folderPath: '',
products: allProducts, // All products
locale: lang,
view,
type: 'folder',
isRootStore: true
}
})
// Add special case for store/products/ - shows all products
all.push({
params: {
locale: lang,
path: 'products',
},
props: {
folderPath: 'products',
products: allProducts, // All products
locale: lang,
view,
type: 'folder',
isProductsFolder: true
}
})
})
return all
}
const { page, folderPath, products, locale, type, isRootStore, isProductsFolder, ...rest } = Astro.props as any
---
{type === 'folder' ? (
(() => {
// Handle folder view - show all products in category
let categoryTitle: string
const categoryDescription = 'Browse our complete collection of products'
if (isRootStore) {
// Root store - all products
categoryTitle = 'All Products'
} else if (isProductsFolder) {
// Products folder - all products
categoryTitle = 'Products'
} else {
// Regular category folder
const categoryName = folderPath.split('/').pop() || folderPath
categoryTitle = categoryName
}
categoryTitle = categoryTitle.charAt(0).toUpperCase() + categoryTitle.slice(1)
// Group products by category for all products views
let groupedProducts: any = {}
if (isRootStore || isProductsFolder) {
// Use the same grouping logic as homepage - simplified for now
groupedProducts = products.reduce((acc, item: any) => {
const id = item.id.split("/")[1]
const key = slugify(id)
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
if (!acc[key]) {
acc[key] = []
}
acc[key].push(item)
return acc
}, {})
} else {
// For category views, just sort products by title
groupedProducts = {
[categoryTitle]: products.sort((a: any, b: any) =>
a.data.title.localeCompare(b.data.title)
)
}
}
const store = `/${locale}/store/`;
return (
<BaseLayout>
<Wrapper variant="standard" class="py-4">
<section class="mb-12">
<h1 class="mb-6 text-2xl font-semibold">
<Translate>{categoryTitle}</Translate>
</h1>
<p class="text-lg text-gray-600 mb-8">
<Translate>{categoryDescription}</Translate>
</p>
{Object.keys(groupedProducts).length > 0 ? (
Object.keys(groupedProducts).map((categoryKey) => (
<section class="mb-12">
<div class="grid sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-6">
{groupedProducts[categoryKey].map((product: any) => (
<StoreEntries
key={product.id}
url={store + product.id}
title={product.data.title}
type={product.data.type}
alt={product.data.title}
model={product.data}
/>
))}
</div>
</section>
))
) : (
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
<Translate>No products in this category</Translate>
</h3>
<p class="text-gray-600">
<Translate>This category appears to be empty.</Translate>
</p>
</div>
)}
<div class="mt-12 text-center">
<a
href={`/${locale}/store/`}
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<Translate>Back to Store</Translate>
</a>
</div>
</section>
</Wrapper>
</BaseLayout>
)
})()
) : (
(() => {
const { data } = page
return <StoreLayout frontmatter={data} {...rest}/>
})()
)}

View File

@ -0,0 +1,76 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro"
import { getCollection } from "astro:content"
import StoreEntries from "@/components/store/StoreEntries.astro"
const allProducts = await getCollection("store")
const locale = Astro.currentLocale || "en"
---
<BaseLayout>
<section>
<div class="py-2 space-y-2">
<div class="grid md:grid-cols-2 lg:grid-cols-2 gap-2 ">
</div>
<a
href="/blog/home"
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 justify-between text-xs text-orange-600 h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 hover:text-orange-600 duration-300 rounded-xl">
<span class="relative uppercase text-xs">Read all articles</span>
<div
aria-hidden="true"
class="w-12 transition duration-300 -translate-y-7 group-hover:translate-y-7">
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
<div class="h-14 flex">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6 m-auto fill-white">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3"
></path>
</svg>
</div>
</div>
</a>
</div>
</section>
<section>
<div class="grid sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-2">
{
allProducts.map((post) => (
<StoreEntries
url={ locale + "/store/" + post.id}
title={post.data.title}
price={post.data.price}
type={post.data.type}
alt={post.data.title}
model={post.data}
/>
))
}
</div>
</section>
</BaseLayout>

View File

@ -0,0 +1,23 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content'
import sanitizeHtml from 'sanitize-html';
import MarkdownIt from 'markdown-it';
const parser = new MarkdownIt()
import { RSS_CONFIG } from '../app/config'
export async function GET(context) {
const blog = await getCollection('posts')
const store = await getCollection('store')
const all = [ ...blog ]
return rss({
site: context.site,
items: all.map((post) => ({
link: `/blog/${post.id}/`,
content: sanitizeHtml(parser.render(post.body), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img'])
}),
...post.data,
})),
...RSS_CONFIG
});
}

View File

@ -1,30 +1,53 @@
{
"compilerOptions": {
"strictNullChecks": true,
// Enable top-level await, and other modern ESM features.
"target": "ESNext",
"allowJs": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"jsx": "react",
// Enable node-style module resolution, for things like npm package imports.
// Enable stricter transpilation for better output.
"isolatedModules": true,
// Astro will directly run your TypeScript code, no transpilation needed.
"outDir": "./dist",
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"lib": ["DOM", "ES2015"],
"declaration": true,
"paths": {
"@/*": ["src/*"],
"config/*": ["src/app/*"],
"components/*": ["src/components/*"]
}
},
"include": [".astro/types.d.ts", "**/*.ts", "**/*.tsx", "**/*.astro"],
"files": [
"src/index.ts"
]
}
{
"compilerOptions": {
"strictNullChecks": true,
// Enable top-level await, and other modern ESM features.
"target": "ESNext",
"allowJs": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"jsx": "react",
// Enable node-style module resolution, for things like npm package imports.
// Enable stricter transpilation for better output.
"isolatedModules": true,
// Astro will directly run your TypeScript code, no transpilation needed.
"outDir": "./dist",
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"lib": [
"DOM",
"ES2015"
],
"declaration": true,
"paths": {
"@/*": [
"src/*"
],
"config/*": [
"src/app/*"
],
"app/*": [
"src/app/*"
],
"model/*": [
"src/model/*"
],
"plugins/*": [
"plugins/*"
],
"components/*": [
"src/components/*"
]
}
},
"include": [
".astro/types.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.astro"
],
"files": [
"src/index.ts"
]
}