refactor site2
This commit is contained in:
parent
3ed545b9ca
commit
48c77f446a
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
36
packages/polymech/plugins/rehype-custom-img.mjs
Normal file
36
packages/polymech/plugins/rehype-custom-img.mjs
Normal 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 };'
|
||||
});
|
||||
};
|
||||
}
|
||||
30
packages/polymech/plugins/remark-reading-time.mjs
Normal file
30
packages/polymech/plugins/remark-reading-time.mjs
Normal 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;
|
||||
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 || {
|
||||
|
||||
182
packages/polymech/src/components/BaseHead.astro
Normal file
182
packages/polymech/src/components/BaseHead.astro
Normal 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>
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
118
packages/polymech/src/components/global/Footer.astro
Normal file
118
packages/polymech/src/components/global/Footer.astro
Normal 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>
|
||||
32
packages/polymech/src/components/global/Navigation.astro
Normal file
32
packages/polymech/src/components/global/Navigation.astro
Normal 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>
|
||||
86
packages/polymech/src/components/global/ThemeToggle.astro
Normal file
86
packages/polymech/src/components/global/ThemeToggle.astro
Normal 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>
|
||||
269
packages/polymech/src/components/resources/ResourceCard.astro
Normal file
269
packages/polymech/src/components/resources/ResourceCard.astro
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -13,7 +13,7 @@ interface Props {
|
||||
|
||||
const {
|
||||
headings,
|
||||
title = "toc.on-this-page",
|
||||
title = "",
|
||||
minHeadingLevel = 2,
|
||||
maxHeadingLevel = 4
|
||||
} = Astro.props;
|
||||
|
||||
@ -14,7 +14,7 @@ interface Props {
|
||||
|
||||
const {
|
||||
headings,
|
||||
title = "toc.on-this-page",
|
||||
title = "",
|
||||
minHeadingLevel = 2,
|
||||
maxHeadingLevel = 4
|
||||
} = Astro.props;
|
||||
|
||||
19
packages/polymech/src/layouts/BaseLayout.astro
Normal file
19
packages/polymech/src/layouts/BaseLayout.astro
Normal 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>
|
||||
599
packages/polymech/src/layouts/Resources.astro
Normal file
599
packages/polymech/src/layouts/Resources.astro
Normal 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">×</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">❮</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">❯</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>
|
||||
368
packages/polymech/src/layouts/StoreLayout.astro
Normal file
368
packages/polymech/src/layouts/StoreLayout.astro
Normal 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>
|
||||
36
packages/polymech/src/layouts/WithSidebar.astro
Normal file
36
packages/polymech/src/layouts/WithSidebar.astro
Normal 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>
|
||||
17
packages/polymech/src/layouts/Wrapper.astro
Normal file
17
packages/polymech/src/layouts/Wrapper.astro
Normal 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>
|
||||
52
packages/polymech/src/model/json-ld.ts
Normal file
52
packages/polymech/src/model/json-ld.ts
Normal 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
|
||||
}
|
||||
33
packages/polymech/src/model/merchant.ts
Normal file
33
packages/polymech/src/model/merchant.ts
Normal 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
|
||||
}
|
||||
23
packages/polymech/src/model/registry.ts
Normal file
23
packages/polymech/src/model/registry.ts
Normal 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)
|
||||
}
|
||||
12
packages/polymech/src/model/rss.ts
Normal file
12
packages/polymech/src/model/rss.ts
Normal 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
|
||||
}]
|
||||
}
|
||||
}
|
||||
110
packages/polymech/src/pages/404.astro
Normal file
110
packages/polymech/src/pages/404.astro
Normal 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>
|
||||
66
packages/polymech/src/pages/[locale].astro
Normal file
66
packages/polymech/src/pages/[locale].astro
Normal 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>
|
||||
347
packages/polymech/src/pages/[locale]/resources/[...slug].astro
Normal file
347
packages/polymech/src/pages/[locale]/resources/[...slug].astro
Normal 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>
|
||||
)}
|
||||
@ -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>
|
||||
215
packages/polymech/src/pages/[locale]/store/[...path].astro
Normal file
215
packages/polymech/src/pages/[locale]/store/[...path].astro
Normal 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}/>
|
||||
})()
|
||||
)}
|
||||
76
packages/polymech/src/pages/index.astro
Normal file
76
packages/polymech/src/pages/index.astro
Normal 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>
|
||||
23
packages/polymech/src/pages/rss.xml.js
Normal file
23
packages/polymech/src/pages/rss.xml.js
Normal 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
|
||||
});
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user