Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions scripts/sync-knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { execSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -32,7 +32,27 @@ function error(message: string) {
* Recursively copy YAML files from source to destination,
* preserving subdirectory structure (e.g., equipment/, species/, origin/)
*/
function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath: string = ''): number {
/**
* Stamp dest with the source file's last git-commit timestamp so the build can
* surface a meaningful "Last updated" date instead of the sync wall-clock time.
* Falls back silently if the file is not tracked or git isn't available.
*/
function stampGitMtime(sourcePath: string, destPath: string, repoRoot: string): void {
try {
const isoStamp = execSync(`git log -1 --format=%cI -- "${sourcePath}"`, {
cwd: repoRoot,
encoding: 'utf-8',
}).trim();
if (!isoStamp) return;
const ts = new Date(isoStamp);
if (Number.isNaN(ts.getTime())) return;
utimesSync(destPath, ts, ts);
} catch {
// ignore — leaves cpSync's default mtime in place
}
}

function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath: string = '', repoRoot?: string): number {
let count = 0;
const currentSource = join(sourceDir, relativePath);
const currentDest = join(destDir, relativePath);
Expand All @@ -50,13 +70,16 @@ function copyYamlFilesRecursive(sourceDir: string, destDir: string, relativePath

if (stat.isDirectory()) {
// Recurse into subdirectory
count += copyYamlFilesRecursive(sourceDir, destDir, join(relativePath, entry));
count += copyYamlFilesRecursive(sourceDir, destDir, join(relativePath, entry), repoRoot);
} else if (entry.endsWith('.yaml') || entry.endsWith('.yml')) {
// Copy YAML file
if (!existsSync(currentDest)) {
mkdirSync(currentDest, { recursive: true });
}
cpSync(sourcePath, destPath);
if (repoRoot) {
stampGitMtime(sourcePath, destPath, repoRoot);
}
const displayPath = relativePath ? `${relativePath}/${entry}` : entry;
log(` Copied: ${displayPath}`);
count++;
Expand All @@ -82,7 +105,7 @@ function syncFromLocal(): boolean {
}

// Copy YAML files recursively (handles flat files and subdirectories)
const fileCount = copyYamlFilesRecursive(localSource, CONTENT_DEST);
const fileCount = copyYamlFilesRecursive(localSource, CONTENT_DEST, '', LOCAL_KNOWLEDGE_DIR);

if (fileCount === 0) {
log('No YAML files found in local source');
Expand Down Expand Up @@ -129,7 +152,7 @@ function syncFromGit(): boolean {
}

// Copy YAML files recursively (handles flat files and subdirectories)
const fileCount = copyYamlFilesRecursive(CONTENT_SOURCE, CONTENT_DEST);
const fileCount = copyYamlFilesRecursive(CONTENT_SOURCE, CONTENT_DEST, '', KNOWLEDGE_DIR);

if (fileCount === 0) {
log('No YAML files found in source');
Expand Down
24 changes: 21 additions & 3 deletions src/components/gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,34 @@ import { Pagination } from '../ui/Pagination';
import { useFamilies } from '../../hooks/useFamilies';
import { useFilters } from '../../hooks/useCrystalDB';
import { usePagination } from '../../hooks/usePagination';
import type { MineralFamily } from '../../lib/db';

interface GalleryProps {
initialSystem?: string;
initialSearch?: string;
initialOrigin?: string;
/** SSG-fetched families — when present, the wasm DB only loads on filter/search. */
initialFamilies?: MineralFamily[];
initialSystems?: string[];
initialOrigins?: string[];
initialGroups?: string[];
}

export function Gallery({ initialSystem = '', initialSearch = '', initialOrigin = '' }: GalleryProps) {
const { families, loading, error, search, filterBySystem, filterByOrigin, filterByGroup } = useFamilies();
const { systems, origins, mineralGroups, loading: filtersLoading } = useFilters();
export function Gallery({
initialSystem = '',
initialSearch = '',
initialOrigin = '',
initialFamilies,
initialSystems,
initialOrigins,
initialGroups,
}: GalleryProps) {
const { families, loading, error, search, filterBySystem, filterByOrigin, filterByGroup } = useFamilies(initialFamilies);
const { systems, origins, mineralGroups, loading: filtersLoading } = useFilters({
systems: initialSystems,
origins: initialOrigins,
mineralGroups: initialGroups,
});
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [selectedSystem, setSelectedSystem] = useState<string | null>(initialSystem || null);
const [selectedOrigin, setSelectedOrigin] = useState<string | null>(initialOrigin || null);
Expand Down
18 changes: 16 additions & 2 deletions src/components/playground/CDLPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ interface CDLPreviewProps {
loading?: boolean;
error?: string | null;
className?: string;
/** Re-run the render request — surfaced as a Retry button in the error state. */
onRetry?: () => void;
}

export function CDLPreview({ svgContent, loading, error, className }: CDLPreviewProps) {
export function CDLPreview({ svgContent, loading, error, className, onRetry }: CDLPreviewProps) {
return (
<div
className={clsx(
Expand Down Expand Up @@ -55,7 +57,19 @@ export function CDLPreview({ svgContent, loading, error, className }: CDLPreview
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="mt-2 text-sm text-red-600">{error}</p>
<p className="mt-2 text-sm text-red-700">{error}</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-4 inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Retry
</button>
)}
</div>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions src/components/playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export function Playground({ initialCDL }: PlaygroundProps) {
loading={previewLoading}
error={previewError}
className="h-full"
onRetry={() => generatePreview(cdlCode)}
/>
) : (
<div className="h-full relative">
Expand Down
10 changes: 8 additions & 2 deletions src/components/seo/LearnSchema.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ interface Props {
subcategory?: string;
difficulty?: string;
slug: string;
/** ISO 8601 datetime — when the article was first published. */
datePublished?: string;
/** ISO 8601 datetime — when the article was last meaningfully updated. */
dateModified?: string;
}

const { title, description, category, subcategory, difficulty, slug } = Astro.props;
const { title, description, category, subcategory, difficulty, slug, datePublished, dateModified } = Astro.props;
const url = `https://gemmology.dev/learn/${slug}`;

// Helper to format category names
Expand Down Expand Up @@ -57,7 +61,9 @@ const articleSchema = {
"about": {
"@type": "Thing",
"name": formatName(category)
}
},
...(datePublished ? { "datePublished": datePublished } : {}),
...(dateModified ? { "dateModified": dateModified } : {}),
};

// Build breadcrumb from category/subcategory
Expand Down
29 changes: 22 additions & 7 deletions src/hooks/useCrystalDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,29 @@ interface UseFiltersResult {
loading: boolean;
}

export function useFilters(): UseFiltersResult {
const [systems, setSystems] = useState<string[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [origins, setOrigins] = useState<string[]>([]);
const [mineralGroups, setMineralGroups] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
interface UseFiltersInitial {
systems?: string[];
categories?: string[];
origins?: string[];
mineralGroups?: string[];
}

/**
* When initial filter lists are provided (SSG case), skips the wasm-DB hit and
* serves the filter dropdowns synchronously.
*/
export function useFilters(initial: UseFiltersInitial = {}): UseFiltersResult {
const hasInitial = Boolean(
initial.systems || initial.categories || initial.origins || initial.mineralGroups,
);
const [systems, setSystems] = useState<string[]>(initial.systems ?? []);
const [categories, setCategories] = useState<string[]>(initial.categories ?? []);
const [origins, setOrigins] = useState<string[]>(initial.origins ?? []);
const [mineralGroups, setMineralGroups] = useState<string[]>(initial.mineralGroups ?? []);
const [loading, setLoading] = useState(!hasInitial);

useEffect(() => {
if (hasInitial) return;
(async () => {
try {
const [systemsData, categoriesData, originsData, groupsData] = await Promise.all([
Expand All @@ -175,7 +190,7 @@ export function useFilters(): UseFiltersResult {
setLoading(false);
}
})();
}, []);
}, [hasInitial]);

return { systems, categories, origins, mineralGroups, loading };
}
24 changes: 19 additions & 5 deletions src/hooks/useFamilies.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getAllFamilies,
getFamilyById,
Expand Down Expand Up @@ -27,13 +27,26 @@ interface UseFamiliesResult {
/**
* Hook for loading and filtering mineral families in the gallery.
* Returns families with expression counts (no duplicates like the old minerals approach).
*
* When `initialFamilies` is provided (build-time SSG case), skips the initial
* sql.js fetch and serves them synchronously on first paint. The wasm DB only
* loads on the first dynamic action (search/filter).
*/
export function useFamilies(): UseFamiliesResult {
const [families, setFamilies] = useState<MineralFamily[]>([]);
const [loading, setLoading] = useState(true);
export function useFamilies(initialFamilies?: MineralFamily[]): UseFamiliesResult {
const hasInitial = Array.isArray(initialFamilies);
const initialRef = useRef<MineralFamily[] | undefined>(initialFamilies);
const [families, setFamilies] = useState<MineralFamily[]>(initialFamilies ?? []);
const [loading, setLoading] = useState(!hasInitial);
const [error, setError] = useState<Error | null>(null);

const loadAll = useCallback(async () => {
// Reset to SSG-fetched data without touching wasm DB when available.
if (initialRef.current) {
setFamilies(initialRef.current);
setLoading(false);
setError(null);
return;
}
try {
setLoading(true);
setError(null);
Expand Down Expand Up @@ -115,8 +128,9 @@ export function useFamilies(): UseFamiliesResult {
}, [loadAll]);

useEffect(() => {
if (hasInitial) return;
loadAll();
}, [loadAll]);
}, [loadAll, hasInitial]);

return {
families,
Expand Down
15 changes: 15 additions & 0 deletions src/lib/db-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,21 @@ export async function getCategories(): Promise<string[]> {
return result[0].values.map((row) => row[0] as string);
}

/**
* Get distinct origin classifications from the families table.
*/
export async function getOrigins(): Promise<string[]> {
const database = await getDB();
const result = database.exec(
`SELECT DISTINCT origin FROM mineral_families
WHERE origin IS NOT NULL AND origin != ''
ORDER BY origin`
);

if (result.length === 0) return [];
return result[0].values.map((row) => row[0] as string);
}

/**
* Get distinct mineral groups from the database.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/lib/sanitize-svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ const SVG_CONFIG: DOMPurify.Config = {
export function sanitizeSvg(svgContent: string): string {
if (!svgContent) return '';

// SSR / Node has no window — DOMPurify cannot run without jsdom.
// The SVGs we render in SSG come from our own minerals.db (built by
// crystal-renderer), so we can pass them through unchanged on the server
// and let the browser-side hydration re-sanitise once the DOM is available.
if (typeof window === 'undefined') {
return svgContent;
}

// DOMPurify.sanitize returns a string when not using RETURN_DOM options
return DOMPurify.sanitize(svgContent, SVG_CONFIG) as string;
}
Expand Down
23 changes: 17 additions & 6 deletions src/pages/404.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,34 @@ const popularPages = [

<BaseLayout title="Page Not Found" description="The page you're looking for doesn't exist.">
<Container size="md" padding="lg" class="min-h-[60vh] flex flex-col items-center justify-center text-center">
<h1 class="text-6xl font-bold text-slate-300 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-slate-900 mb-4">Page Not Found</h2>
<div class="relative mb-8" aria-hidden="true">
<svg class="w-32 h-32 text-crystal-200" viewBox="0 0 200 200" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="100,30 160,80 130,160 70,160 40,80" fill="currentColor" fill-opacity="0.18"/>
<polygon points="100,30 160,80 130,160 70,160 40,80"/>
<line x1="100" y1="30" x2="100" y2="160" stroke-opacity="0.4"/>
<line x1="40" y1="80" x2="160" y2="80" stroke-opacity="0.4"/>
<line x1="70" y1="160" x2="160" y2="80" stroke-opacity="0.3"/>
<line x1="130" y1="160" x2="40" y2="80" stroke-opacity="0.3"/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-4xl font-bold text-slate-700">404</span>
</div>
<h1 class="text-3xl sm:text-4xl font-semibold text-slate-900 mb-3">Lost in the lattice</h1>
<p class="text-slate-600 mb-8 max-w-md">
The page you're looking for doesn't exist or has been moved.
The page you're looking for doesn't exist or has been moved. Try one of the routes below or head back to the gallery.
</p>

<div class="flex gap-4 mb-12">
<div class="flex flex-wrap justify-center gap-4 mb-12">
<Button href="/" variant="primary">Go Home</Button>
<Button href="/gallery" variant="secondary">Browse Gallery</Button>
<Button href="/learn" variant="ghost">Open Learn</Button>
</div>

<div class="w-full max-w-2xl">
<h3 class="text-lg font-semibold text-slate-900 mb-4">Popular Pages</h3>
<h2 class="text-lg font-semibold text-slate-900 mb-4">Popular Pages</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{popularPages.map(page => (
<Card href={page.href} hover padding="md">
<h4 class="font-medium text-slate-900">{page.title}</h4>
<h3 class="font-medium text-slate-900">{page.title}</h3>
<p class="text-sm text-slate-600 mt-1">{page.description}</p>
</Card>
))}
Expand Down
20 changes: 20 additions & 0 deletions src/pages/gallery/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import { Gallery } from '../../components/gallery/Gallery';
import { Container, SectionHeader } from '../../components/ui';
import { getSiteStats } from '../../lib/stats';
import {
getAllFamilies,
getCrystalSystems,
getOrigins,
getMineralGroups,
} from '../../lib/db-server';

const url = Astro.url;
const initialSystem = url.searchParams.get('system') || '';
Expand All @@ -13,6 +19,16 @@ const initialOrigin = url.searchParams.get('origin') || '';
const canonicalURL = 'https://gemmology.dev/gallery';

const { familyCount } = await getSiteStats();

// SSG-fetch families + filter dropdowns at build time so the gallery first
// paint does not block on sql.js + the 8 MB minerals.db. The wasm DB only
// loads client-side once the user actually searches or filters.
const [initialFamilies, initialSystems, initialOrigins, initialGroups] = await Promise.all([
getAllFamilies(),
getCrystalSystems(),
getOrigins(),
getMineralGroups(),
]);
---

<BaseLayout
Expand All @@ -31,6 +47,10 @@ const { familyCount } = await getSiteStats();
initialSystem={initialSystem}
initialSearch={initialSearch}
initialOrigin={initialOrigin}
initialFamilies={initialFamilies}
initialSystems={initialSystems}
initialOrigins={initialOrigins}
initialGroups={initialGroups}
/>
</Container>
</div>
Expand Down
Loading
Loading