Skip to content

Commit 5449ed9

Browse files
authored
Merge pull request #131 from SimplePhotoGallery/feature/themes-support
Custom themes support
2 parents 645df16 + d3a8311 commit 5449ed9

101 files changed

Lines changed: 5773 additions & 983 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/check-cli.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434

3535
- name: Run checks (common)
3636
working-directory: ./common
37-
run: yarn check
37+
run: yarn check && yarn build
3838

3939
- name: Run checks (gallery)
4040
working-directory: ./gallery

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,78 @@ This will:
6262
- Ubuntu/Debian: `sudo apt install ffmpeg`
6363
- Windows: [Download from ffmpeg.org](https://ffmpeg.org/download.html)
6464

65+
## Development
66+
67+
This is a monorepo using Yarn workspaces. To set up the development environment:
68+
69+
1. **Clone the repository**
70+
71+
```bash
72+
git clone https://github.com/SimplePhotoGallery/core.git
73+
cd spg-core
74+
```
75+
76+
2. **Install dependencies**
77+
78+
```bash
79+
yarn install
80+
```
81+
82+
3. **Build the `common` package** (required for TypeScript/ESLint to resolve `@simple-photo-gallery/common`)
83+
84+
```bash
85+
yarn workspace @simple-photo-gallery/common build
86+
```
87+
88+
4. **Build the gallery package** (optional, for testing CLI changes)
89+
90+
```bash
91+
yarn workspace simple-photo-gallery build
92+
```
93+
94+
5. **Run the CLI in development mode**
95+
```bash
96+
yarn workspace simple-photo-gallery gallery
97+
```
98+
99+
### Workspace Packages
100+
101+
- **`common/`** - Shared types, schemas, and utilities used by both the CLI and themes
102+
- Gallery types and Zod validation schemas
103+
- Theme utilities (data loading, path resolution, markdown parsing)
104+
- Client-side utilities (PhotoSwipe, blurhash, CSS helpers)
105+
- See [common/README.md](common/README.md) for full API documentation
106+
- **`gallery/`** - CLI tool (`simple-photo-gallery`)
107+
- Includes base theme template bundled at `gallery/src/modules/create-theme/templates/base/`
108+
- **`themes/modern/`** - Default theme package (reference implementation)
109+
110+
### Building Packages
111+
112+
Each workspace package can be built individually:
113+
114+
- `yarn workspace @simple-photo-gallery/common build`
115+
- `yarn workspace simple-photo-gallery build`
116+
- `yarn workspace @simple-photo-gallery/theme-modern build`
117+
65118
## Supported Formats
66119

67120
**Images:** JPEG, PNG, WebP, GIF, TIFF
68121
**Videos:** MP4, MOV, AVI, WebM, MKV
69122

123+
## Architecture
124+
125+
This project uses a multi-theme architecture:
126+
- **Common package** provides shared utilities for all themes
127+
- **Themes** focus only on layout and presentation
128+
- **CLI** handles gallery generation and theme orchestration
129+
130+
See the [Architecture Documentation](./docs/architecture.md) for details on how the system works, including:
131+
- Package structure and dependencies
132+
- Data flow from photos to static HTML
133+
- Theme system design and resolution
134+
- Multi-theme support implementation
135+
- Guidelines for adding new features
136+
70137
## Detailed Documentation
71138

72139
For advanced usage, customization, and deployment options, see the comprehensive [documentation](./docs/README.md):
@@ -76,7 +143,11 @@ For advanced usage, customization, and deployment options, see the comprehensive
76143
- [`build`](./docs/commands/build.md) - Generate static HTML galleries
77144
- [`thumbnails`](./docs/commands/thumbnails.md) - Generate optimized thumbnails
78145
- [`clean`](./docs/commands/clean.md) - Remove gallery files
146+
- [`create-theme`](./docs/commands/create-theme.md) - Scaffold a new theme package
147+
- [`telemetry`](./docs/commands/telemetry.md) - Manage anonymous telemetry preferences
79148
- **[Gallery Configuration](./docs/configuration.md)** - Manual editing of `gallery.json` and advanced features like sections
149+
- **[Custom Themes](./docs/themes.md)** - Create and use custom themes
150+
- **[Common Package API](./common/README.md)** - Utilities and types for theme development
80151
- **[Deployment Guide](./docs/deployment.md)** - Guidelines for hosting your gallery
81152

82153
## Python Version

common/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# @simple-photo-gallery/common
2+
3+
Shared utilities and types for Simple Photo Gallery themes and CLI.
4+
5+
## Installation
6+
7+
```bash
8+
npm install @simple-photo-gallery/common
9+
```
10+
11+
## Package Exports
12+
13+
- `.` - Gallery types and Zod validation schemas
14+
- `./theme` - Theme utilities for data loading and resolution
15+
- `./client` - Browser-side utilities (PhotoSwipe, blurhash, CSS)
16+
- `./styles/photoswipe` - PhotoSwipe CSS bundle

common/package.json

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@simple-photo-gallery/common",
3-
"version": "1.0.6",
3+
"version": "2.1.0",
44
"description": "Shared utilities and types for Simple Photo Gallery",
55
"license": "MIT",
66
"author": "Vladimir Haltakov, Tomasz Rusin",
@@ -16,7 +16,16 @@
1616
"types": "./dist/gallery.d.ts",
1717
"import": "./dist/gallery.js",
1818
"require": "./dist/gallery.cjs"
19-
}
19+
},
20+
"./theme": {
21+
"types": "./dist/theme.d.ts",
22+
"import": "./dist/theme.js"
23+
},
24+
"./client": {
25+
"types": "./dist/client.d.ts",
26+
"import": "./dist/client.js"
27+
},
28+
"./styles/photoswipe": "./dist/styles/photoswipe/photoswipe.css"
2029
},
2130
"files": [
2231
"dist"
@@ -33,12 +42,26 @@
3342
"prepublish": "yarn build"
3443
},
3544
"dependencies": {
45+
"marked": "^16.0.0",
3646
"zod": "^4.0.14"
3747
},
48+
"peerDependencies": {
49+
"blurhash": "^2.0.5",
50+
"photoswipe": "^5.4.4"
51+
},
52+
"peerDependenciesMeta": {
53+
"blurhash": {
54+
"optional": true
55+
},
56+
"photoswipe": {
57+
"optional": true
58+
}
59+
},
3860
"devDependencies": {
3961
"@eslint/eslintrc": "^3.3.1",
4062
"@eslint/js": "^9.30.1",
4163
"@types/node": "^24.0.10",
64+
"@types/photoswipe": "^4.1.6",
4265
"@typescript-eslint/eslint-plugin": "^8.38.0",
4366
"@typescript-eslint/parser": "^8.38.0",
4467
"eslint": "^9.30.1",

common/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './client/index';

common/src/client/blurhash.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { decode } from 'blurhash';
2+
3+
/**
4+
* Decode a single blurhash and draw it to a canvas element.
5+
*
6+
* @param canvas - The canvas element with a data-blur-hash attribute
7+
* @param width - The width to decode at (default: 32)
8+
* @param height - The height to decode at (default: 32)
9+
*/
10+
export function decodeBlurhashToCanvas(canvas: HTMLCanvasElement, width: number = 32, height: number = 32): void {
11+
const blurHashValue = canvas.dataset.blurHash;
12+
if (!blurHashValue) return;
13+
14+
const pixels = decode(blurHashValue, width, height);
15+
const ctx = canvas.getContext('2d');
16+
if (pixels && ctx) {
17+
const imageData = new ImageData(new Uint8ClampedArray(pixels), width, height);
18+
ctx.putImageData(imageData, 0, 0);
19+
}
20+
}
21+
22+
/**
23+
* Decode and render all blurhash canvases on the page.
24+
* Finds all canvas elements with data-blur-hash attribute and draws the decoded image.
25+
*
26+
* @param selector - CSS selector for canvas elements (default: 'canvas[data-blur-hash]')
27+
* @param width - The width to decode at (default: 32)
28+
* @param height - The height to decode at (default: 32)
29+
*/
30+
export function decodeAllBlurhashes(
31+
selector: string = 'canvas[data-blur-hash]',
32+
width: number = 32,
33+
height: number = 32,
34+
): void {
35+
const canvases = document.querySelectorAll<HTMLCanvasElement>(selector);
36+
for (const canvas of canvases) {
37+
decodeBlurhashToCanvas(canvas, width, height);
38+
}
39+
}

common/src/client/css-utils.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* CSS utility functions for client-side theming and color manipulation.
3+
* These utilities are browser-only and require DOM access.
4+
*/
5+
6+
/**
7+
* Normalizes hex color values to 6-digit format (e.g., #abc -> #aabbcc).
8+
* Returns null if the hex value is invalid.
9+
*
10+
* @param hex - The hex color value to normalize (with or without #)
11+
* @returns The normalized 6-digit hex color with # prefix, or null if invalid
12+
*/
13+
export function normalizeHex(hex: string): string | null {
14+
hex = hex.replace('#', '');
15+
if (hex.length === 3) hex = [...hex].map((c) => c + c).join('');
16+
return hex.length === 6 && /^[0-9A-Fa-f]{6}$/.test(hex) ? `#${hex}` : null;
17+
}
18+
19+
/**
20+
* Parses and validates a color value.
21+
* Supports CSS color names, hex values, rgb/rgba, and 'transparent'.
22+
* Returns null if the color is invalid.
23+
*
24+
* @param colorParam - The color string to parse
25+
* @returns The validated color string, or null if invalid
26+
*/
27+
export function parseColor(colorParam: string | null): string | null {
28+
if (!colorParam) return null;
29+
const normalized = colorParam.toLowerCase().trim();
30+
if (normalized === 'transparent') return 'transparent';
31+
32+
const testEl = document.createElement('div');
33+
testEl.style.color = normalized;
34+
if (testEl.style.color) return normalized;
35+
36+
return normalizeHex(colorParam);
37+
}
38+
39+
/**
40+
* Sets or removes a CSS custom property (variable) on an element.
41+
* Removes the property if value is null.
42+
*
43+
* @param element - The HTML element to modify
44+
* @param name - The CSS variable name (e.g., '--my-color')
45+
* @param value - The value to set, or null to remove
46+
*/
47+
export function setCSSVar(element: HTMLElement, name: string, value: string | null): void {
48+
if (value) {
49+
element.style.setProperty(name, value);
50+
} else {
51+
element.style.removeProperty(name);
52+
}
53+
}
54+
55+
/**
56+
* Derives a color with adjusted opacity from an existing color.
57+
* Converts rgb to rgba if needed, or adjusts existing rgba opacity.
58+
*
59+
* @param color - The source color (rgb, rgba, or other CSS color)
60+
* @param opacity - The target opacity (0-1)
61+
* @returns The color with adjusted opacity, or original if not rgb/rgba
62+
*/
63+
export function deriveOpacityColor(color: string, opacity: number): string {
64+
if (color.startsWith('rgba')) {
65+
return color.replace(/,\s*[\d.]+\)$/, `, ${opacity})`);
66+
}
67+
if (color.startsWith('rgb')) {
68+
return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`);
69+
}
70+
return color;
71+
}

common/src/client/hero-fallback.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/** Options for the hero image fallback behavior */
2+
export interface HeroImageFallbackOptions {
3+
/** CSS selector for the picture element (default: '#hero-bg-picture') */
4+
pictureSelector?: string;
5+
/** CSS selector for the img element within picture (default: 'img.hero__bg-img') */
6+
imgSelector?: string;
7+
/** CSS selector for the blurhash canvas element (default: 'canvas[data-blur-hash]') */
8+
canvasSelector?: string;
9+
}
10+
11+
/**
12+
* Initialize hero image fallback behavior.
13+
* Handles:
14+
* - Hiding blurhash canvas when image loads successfully
15+
* - Removing source elements and retrying with fallback src on error
16+
* - Keeping blurhash visible if final fallback also fails
17+
*
18+
* @param options - Configuration options for selectors
19+
*
20+
* @example
21+
* ```typescript
22+
* import { initHeroImageFallback } from '@simple-photo-gallery/common/client';
23+
*
24+
* // Use default selectors
25+
* initHeroImageFallback();
26+
*
27+
* // Or with custom selectors
28+
* initHeroImageFallback({
29+
* pictureSelector: '#my-hero-picture',
30+
* imgSelector: 'img.my-hero-img',
31+
* canvasSelector: 'canvas.my-blurhash',
32+
* });
33+
* ```
34+
*/
35+
export function initHeroImageFallback(options: HeroImageFallbackOptions = {}): void {
36+
const {
37+
pictureSelector = '#hero-bg-picture',
38+
imgSelector = 'img.hero__bg-img',
39+
canvasSelector = 'canvas[data-blur-hash]',
40+
} = options;
41+
42+
const picture = document.querySelector<HTMLPictureElement>(pictureSelector);
43+
const img = picture?.querySelector<HTMLImageElement>(imgSelector);
44+
const canvas = document.querySelector<HTMLCanvasElement>(canvasSelector);
45+
46+
if (!img) return;
47+
48+
const fallbackSrc = img.getAttribute('src') || '';
49+
let didFallback = false;
50+
51+
const hideBlurhash = () => {
52+
if (canvas) {
53+
canvas.style.display = 'none';
54+
}
55+
};
56+
57+
const doFallback = () => {
58+
if (didFallback) return;
59+
didFallback = true;
60+
61+
if (picture) {
62+
// Remove all <source> elements so the browser does not retry them
63+
for (const sourceEl of picture.querySelectorAll('source')) {
64+
sourceEl.remove();
65+
}
66+
}
67+
68+
// Force reload using the <img> src as the final fallback
69+
const current = img.getAttribute('src') || '';
70+
img.setAttribute('src', '');
71+
img.setAttribute('src', fallbackSrc || current);
72+
73+
// If fallback also fails, keep blurhash visible
74+
img.addEventListener(
75+
'error',
76+
() => {
77+
// Final fallback failed, blurhash stays visible
78+
},
79+
{ once: true },
80+
);
81+
82+
// If fallback succeeds, hide blurhash
83+
img.addEventListener('load', hideBlurhash, { once: true });
84+
};
85+
86+
// Check if image already loaded or failed before script runs
87+
if (img.complete) {
88+
if (img.naturalWidth === 0) {
89+
doFallback();
90+
} else {
91+
hideBlurhash();
92+
}
93+
} else {
94+
img.addEventListener('load', hideBlurhash, { once: true });
95+
}
96+
97+
img.addEventListener('error', doFallback, { once: true });
98+
}

0 commit comments

Comments
 (0)