@simple-photo-gallery/theme-modern 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/astro.config.ts +25 -0
- package/package.json +54 -0
- package/public/images/favicon.ico +0 -0
- package/public/images/gallery-placeholder.png +0 -0
- package/public/images/hero-placeholder.png +0 -0
- package/src/config/index.ts +1 -0
- package/src/features/themes/base-theme/components/container/Container.astro +11 -0
- package/src/features/themes/base-theme/components/gallery-section/GallerySection.astro +75 -0
- package/src/features/themes/base-theme/components/gallery-section/GallerySectionHeader.astro +36 -0
- package/src/features/themes/base-theme/components/gallery-section/GallerySectionItem.astro +57 -0
- package/src/features/themes/base-theme/components/hero/Hero.astro +74 -0
- package/src/features/themes/base-theme/components/hero/HeroScrollToGalleryBtn.astro +77 -0
- package/src/features/themes/base-theme/components/lightbox/PhotoSwipe.astro +160 -0
- package/src/features/themes/base-theme/components/sub-galleries/SubGalleries.astro +141 -0
- package/src/features/themes/base-theme/layouts/MainHead.astro +73 -0
- package/src/features/themes/base-theme/layouts/MainLayout.astro +44 -0
- package/src/features/themes/base-theme/pages/index.astro +36 -0
- package/src/features/themes/base-theme/types/gallery.ts +57 -0
- package/src/features/themes/base-theme/utils/index.ts +18 -0
- package/src/lib/photoswipe-video-plugin.ts +319 -0
- package/src/pages/index.astro +5 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Astro Starter Kit: Basics
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
npm create astro@latest -- --template basics
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
|
8
|
+
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
|
9
|
+
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
|
10
|
+
|
|
11
|
+
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## 🚀 Project Structure
|
|
16
|
+
|
|
17
|
+
Inside of your Astro project, you'll see the following folders and files:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
/
|
|
21
|
+
├── public/
|
|
22
|
+
│ └── favicon.svg
|
|
23
|
+
├── src
|
|
24
|
+
│ ├── assets
|
|
25
|
+
│ │ └── astro.svg
|
|
26
|
+
│ ├── components
|
|
27
|
+
│ │ └── Welcome.astro
|
|
28
|
+
│ ├── layouts
|
|
29
|
+
│ │ └── Layout.astro
|
|
30
|
+
│ └── pages
|
|
31
|
+
│ └── index.astro
|
|
32
|
+
└── package.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
|
36
|
+
|
|
37
|
+
## 🧞 Commands
|
|
38
|
+
|
|
39
|
+
All commands are run from the root of the project, from a terminal:
|
|
40
|
+
|
|
41
|
+
| Command | Action |
|
|
42
|
+
| :------------------------ | :----------------------------------------------- |
|
|
43
|
+
| `npm install` | Installs dependencies |
|
|
44
|
+
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
|
45
|
+
| `npm run build` | Build your production site to `./dist/` |
|
|
46
|
+
| `npm run preview` | Preview your build locally, before deploying |
|
|
47
|
+
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
48
|
+
| `npm run astro -- --help` | Get help using the Astro CLI |
|
|
49
|
+
|
|
50
|
+
## 👀 Want to learn more?
|
|
51
|
+
|
|
52
|
+
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
package/astro.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from 'astro/config';
|
|
2
|
+
import relativeLinks from 'astro-relative-links';
|
|
3
|
+
|
|
4
|
+
// Dynamically import gallery.json from source path or fallback to local
|
|
5
|
+
const sourceGalleryPath = process.env.GALLERY_JSON_PATH;
|
|
6
|
+
if (!sourceGalleryPath) throw new Error('GALLERY_JSON_PATH environment variable is not set');
|
|
7
|
+
|
|
8
|
+
const outputDir = process.env.GALLERY_OUTPUT_DIR || sourceGalleryPath.replace('gallery.json', '');
|
|
9
|
+
|
|
10
|
+
// https://astro.build/config
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
output: 'static',
|
|
13
|
+
outDir: outputDir + '/_build',
|
|
14
|
+
build: {
|
|
15
|
+
assets: 'simple-photo-gallery-assets',
|
|
16
|
+
assetsPrefix: 'gallery',
|
|
17
|
+
},
|
|
18
|
+
integrations: [relativeLinks()],
|
|
19
|
+
publicDir: 'public',
|
|
20
|
+
vite: {
|
|
21
|
+
define: {
|
|
22
|
+
'process.env.GALLERY_JSON_PATH': JSON.stringify(sourceGalleryPath),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-photo-gallery/theme-modern",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Modern theme for Simple Photo Gallery",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Vladimir Haltakov, Tomasz Rusin",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/SimplePhotoGallery/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://simple.photo",
|
|
12
|
+
"files": [
|
|
13
|
+
"public",
|
|
14
|
+
"src",
|
|
15
|
+
"astro.config.ts",
|
|
16
|
+
"tsconfig.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "astro dev",
|
|
20
|
+
"build": "astro build",
|
|
21
|
+
"preview": "astro preview",
|
|
22
|
+
"astro": "astro",
|
|
23
|
+
"lint": "eslint . --ext .astro,.js,.jsx,.ts,.tsx",
|
|
24
|
+
"lint:fix": "eslint . --ext .astro,.js,.jsx,.ts,.tsx --fix",
|
|
25
|
+
"format": "prettier --check './**/*.{js,jsx,ts,tsx,css,scss,md,json,astro}' --config ./.prettierrc.mjs --ignore-path ./.prettierignore",
|
|
26
|
+
"format:fix": "prettier --write './**/*.{js,jsx,ts,tsx,css,scss,md,json,astro}' --config ./.prettierrc.mjs --ignore-path ./.prettierignore",
|
|
27
|
+
"check": "npm run lint && npm run format"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@types/photoswipe": "^4.1.6",
|
|
31
|
+
"astro": "^5.11.0",
|
|
32
|
+
"astro-relative-links": "^0.4.2",
|
|
33
|
+
"photoswipe": "^5.4.4"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@eslint/eslintrc": "^3.3.1",
|
|
37
|
+
"@eslint/js": "^9.30.1",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
|
39
|
+
"@typescript-eslint/parser": "^8.35.1",
|
|
40
|
+
"eslint": "^9.30.1",
|
|
41
|
+
"eslint-config-prettier": "^10.1.5",
|
|
42
|
+
"eslint-import-resolver-alias": "^1.1.2",
|
|
43
|
+
"eslint-import-resolver-typescript": "^4.4.4",
|
|
44
|
+
"eslint-plugin-astro": "^1.3.1",
|
|
45
|
+
"eslint-plugin-import": "^2.31.0",
|
|
46
|
+
"eslint-plugin-prettier": "^5.5.1",
|
|
47
|
+
"eslint-plugin-unicorn": "^60.0.0",
|
|
48
|
+
"prettier": "^3.4.2",
|
|
49
|
+
"prettier-plugin-astro": "^0.14.1",
|
|
50
|
+
"typescript": "^5.8.3",
|
|
51
|
+
"typescript-eslint": "^8.35.1"
|
|
52
|
+
},
|
|
53
|
+
"type": "module"
|
|
54
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_STATIC_ASSETS_PATH = 'gallery';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Container from '@/features/themes/base-theme/components/container/Container.astro';
|
|
3
|
+
import GallerySectionHeader from '@/features/themes/base-theme/components/gallery-section/GallerySectionHeader.astro';
|
|
4
|
+
import GallerySectionItem from '@/features/themes/base-theme/components/gallery-section/GallerySectionItem.astro';
|
|
5
|
+
|
|
6
|
+
import type { GallerySection as GallerySectionType } from '@/features/themes/base-theme/types/gallery';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
section: GallerySectionType;
|
|
10
|
+
sectionIndex: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { section, sectionIndex } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<section class={`gallery-section gallery-section-${sectionIndex}`}>
|
|
17
|
+
<Container>
|
|
18
|
+
<GallerySectionHeader section={section} />
|
|
19
|
+
<div class="gallery-section__gallery" id={`gallery-${sectionIndex}`}>
|
|
20
|
+
{section.images.map((image) => <GallerySectionItem image={image} />)}
|
|
21
|
+
</div>
|
|
22
|
+
</Container>
|
|
23
|
+
</section>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
.gallery-section {
|
|
27
|
+
padding: 5rem 1rem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.gallery-section:nth-child(even) {
|
|
31
|
+
background-color: #f9fafb;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.gallery-section__container {
|
|
35
|
+
max-width: 1200px;
|
|
36
|
+
margin: 0 auto;
|
|
37
|
+
padding: 0 1rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.gallery-section__gallery {
|
|
41
|
+
columns: 1;
|
|
42
|
+
column-gap: 1rem;
|
|
43
|
+
margin-bottom: 1rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@media (min-width: 640px) {
|
|
47
|
+
.gallery-section__gallery {
|
|
48
|
+
columns: 2;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (min-width: 768px) {
|
|
53
|
+
.gallery-section__gallery {
|
|
54
|
+
columns: 3;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@media (min-width: 1024px) {
|
|
59
|
+
.gallery-section__gallery {
|
|
60
|
+
columns: 4;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (min-width: 1280px) {
|
|
65
|
+
.gallery-section__gallery {
|
|
66
|
+
columns: 5;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@media (min-width: 1536px) {
|
|
71
|
+
.gallery-section__gallery {
|
|
72
|
+
columns: 6;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { GallerySection as GallerySectionType } from '@/features/themes/base-theme/types/gallery';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
section: GallerySectionType;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { section } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="gallery-section__header">
|
|
12
|
+
<h2 class="gallery-section__header-title">{section.title}</h2>
|
|
13
|
+
<p class="gallery-section__header-description">{section.description}</p>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.gallery-section__header {
|
|
18
|
+
text-align: center;
|
|
19
|
+
margin-bottom: 4rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.gallery-section__header-title {
|
|
23
|
+
font-size: clamp(2.5rem, 5vw, 5rem);
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
margin-bottom: 1.5rem;
|
|
26
|
+
color: #111827;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.gallery-section__header-description {
|
|
30
|
+
font-size: 1.125rem;
|
|
31
|
+
color: #6b7280;
|
|
32
|
+
max-width: 42rem;
|
|
33
|
+
margin: 0 auto;
|
|
34
|
+
line-height: 1.6;
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getGalleryPath } from '@/features/themes/base-theme/utils';
|
|
3
|
+
|
|
4
|
+
import type { GalleryImage } from '@/features/themes/base-theme/types/gallery';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
image: GalleryImage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { image } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div class="gallery-section__item">
|
|
14
|
+
<a
|
|
15
|
+
href={getGalleryPath(image.path)}
|
|
16
|
+
data-pswp-src={getGalleryPath(image.path)}
|
|
17
|
+
data-pswp-width={image.width}
|
|
18
|
+
data-pswp-height={image.height}
|
|
19
|
+
data-pswp-type={image.type}
|
|
20
|
+
data-pswp-caption={image.description ? `<p class="image-caption">${image.description}</p>` : ''}>
|
|
21
|
+
<img
|
|
22
|
+
src={getGalleryPath(image.thumbnail?.path)}
|
|
23
|
+
alt={image.alt}
|
|
24
|
+
loading="lazy"
|
|
25
|
+
width={image.thumbnail?.width}
|
|
26
|
+
height={image.thumbnail?.height}
|
|
27
|
+
/>
|
|
28
|
+
</a>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.gallery-section__item {
|
|
33
|
+
break-inside: avoid;
|
|
34
|
+
margin-bottom: 1rem;
|
|
35
|
+
border-radius: 0.75rem;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
38
|
+
transition: all 0.3s ease;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.gallery-section__item:hover {
|
|
43
|
+
transform: scale(1.02) translateY(-5px);
|
|
44
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.gallery-section__item img {
|
|
48
|
+
width: 100%;
|
|
49
|
+
height: auto;
|
|
50
|
+
display: block;
|
|
51
|
+
transition: transform 0.5s ease;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.gallery-item:hover img {
|
|
55
|
+
transform: scale(1.05);
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { DEFAULT_STATIC_ASSETS_PATH } from '@/config';
|
|
3
|
+
import HeroScrollToGalleryBtn from '@/features/themes/base-theme/components/hero/HeroScrollToGalleryBtn.astro';
|
|
4
|
+
import { getGalleryPath } from '@/features/themes/base-theme/utils';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
backgroundImage: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { title, description, backgroundImage } = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<section class="hero">
|
|
16
|
+
<div
|
|
17
|
+
class="hero__bg"
|
|
18
|
+
style={`background-image: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url('${getGalleryPath(backgroundImage) || `${DEFAULT_STATIC_ASSETS_PATH}/images/hero-placeholder.png`}')`}>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="hero__content">
|
|
21
|
+
<h1 class="hero__title">{title}</h1>
|
|
22
|
+
{description && <p class="hero__description">{description}</p>}
|
|
23
|
+
<HeroScrollToGalleryBtn />
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.hero {
|
|
29
|
+
position: relative;
|
|
30
|
+
min-height: 450px;
|
|
31
|
+
height: 100dvh;
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: center;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.hero__bg {
|
|
39
|
+
position: absolute;
|
|
40
|
+
top: 0;
|
|
41
|
+
left: 0;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: 100%;
|
|
44
|
+
background-size: cover;
|
|
45
|
+
background-position: center;
|
|
46
|
+
background-repeat: no-repeat;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hero__content {
|
|
50
|
+
position: relative;
|
|
51
|
+
z-index: 10;
|
|
52
|
+
text-align: center;
|
|
53
|
+
color: white;
|
|
54
|
+
max-width: 64rem;
|
|
55
|
+
padding: 0 1.5rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.hero__title {
|
|
59
|
+
font-size: clamp(2rem, 8vw, 7rem);
|
|
60
|
+
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
|
61
|
+
text-align: center;
|
|
62
|
+
line-height: 1.2;
|
|
63
|
+
font-weight: 700;
|
|
64
|
+
margin-bottom: 1.5rem;
|
|
65
|
+
letter-spacing: -0.025em;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.hero__description {
|
|
69
|
+
font-size: clamp(1rem, 3vw, 2rem);
|
|
70
|
+
font-weight: 300;
|
|
71
|
+
opacity: 0.9;
|
|
72
|
+
line-height: 1.5;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<button class="hero__scroll-to-gallery-btn" aria-label="Scroll to gallery">
|
|
6
|
+
<span class="hero__scroll-to-gallery-btn-arrow">❯</span>
|
|
7
|
+
</button>
|
|
8
|
+
|
|
9
|
+
<script>
|
|
10
|
+
function initScrollToGallery() {
|
|
11
|
+
const button = document.querySelector('.hero__scroll-to-gallery-btn');
|
|
12
|
+
if (button) {
|
|
13
|
+
button.addEventListener('click', () => {
|
|
14
|
+
const gallery = document.querySelector('.gallery-section-0');
|
|
15
|
+
if (gallery) {
|
|
16
|
+
gallery.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
initScrollToGallery();
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
.hero__scroll-to-gallery-btn {
|
|
27
|
+
margin: auto;
|
|
28
|
+
bottom: 40px;
|
|
29
|
+
transform: rotate(90deg);
|
|
30
|
+
background-color: rgba(0, 0, 0, 0.2);
|
|
31
|
+
color: #fff;
|
|
32
|
+
padding: 15px;
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
text-decoration: none;
|
|
35
|
+
z-index: 10;
|
|
36
|
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
width: 50px;
|
|
43
|
+
height: 50px;
|
|
44
|
+
transition: all 0.3s ease;
|
|
45
|
+
backdrop-filter: blur(10px);
|
|
46
|
+
margin-top: 2rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hero__scroll-to-gallery-btn:hover {
|
|
50
|
+
background-color: rgba(0, 0, 0, 0.3);
|
|
51
|
+
transform: rotate(90deg) scale(1.05);
|
|
52
|
+
border-color: rgba(255, 255, 255, 0.3);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.hero__scroll-to-gallery-btn-arrow {
|
|
56
|
+
font-size: 18px;
|
|
57
|
+
line-height: 0.8;
|
|
58
|
+
animation: bounce 2s infinite;
|
|
59
|
+
transform: rotate(90deg);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@keyframes bounce {
|
|
63
|
+
0%,
|
|
64
|
+
20%,
|
|
65
|
+
50%,
|
|
66
|
+
80%,
|
|
67
|
+
100% {
|
|
68
|
+
transform: translateX(0);
|
|
69
|
+
}
|
|
70
|
+
40% {
|
|
71
|
+
transform: translateX(5px);
|
|
72
|
+
}
|
|
73
|
+
60% {
|
|
74
|
+
transform: translateX(3px);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import PhotoSwipe from 'photoswipe';
|
|
3
|
+
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
|
4
|
+
|
|
5
|
+
import PhotoSwipeVideoPlugin from '@/lib/photoswipe-video-plugin';
|
|
6
|
+
import 'photoswipe/style.css';
|
|
7
|
+
|
|
8
|
+
const lightbox = new PhotoSwipeLightbox({
|
|
9
|
+
gallery: '.gallery-section__gallery',
|
|
10
|
+
children: 'a',
|
|
11
|
+
pswpModule: PhotoSwipe,
|
|
12
|
+
showAnimationDuration: 300,
|
|
13
|
+
hideAnimationDuration: 300,
|
|
14
|
+
wheelToZoom: true,
|
|
15
|
+
bgOpacity: 1,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
new PhotoSwipeVideoPlugin(lightbox, {
|
|
19
|
+
// options
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
lightbox.on('contentDeactivate', ({ content }) => {
|
|
23
|
+
content.element?.classList.remove('pswp__img--in-viewport');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
lightbox.on('contentActivate', ({ content }) => {
|
|
27
|
+
content.element?.classList.add('pswp__img--in-viewport');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
lightbox.on('uiRegister', function () {
|
|
31
|
+
lightbox.pswp?.ui?.registerElement({
|
|
32
|
+
name: 'custom-caption',
|
|
33
|
+
isButton: false,
|
|
34
|
+
className: 'pswp__caption',
|
|
35
|
+
appendTo: 'wrapper',
|
|
36
|
+
onInit: (el) => {
|
|
37
|
+
lightbox.pswp?.on('change', () => {
|
|
38
|
+
const currSlideElement = lightbox.pswp?.currSlide?.data.element;
|
|
39
|
+
let captionHTML = '';
|
|
40
|
+
if (currSlideElement) {
|
|
41
|
+
const caption = currSlideElement?.getAttribute('data-pswp-caption');
|
|
42
|
+
captionHTML = caption ?? currSlideElement?.querySelector('img')?.getAttribute('alt') ?? '';
|
|
43
|
+
el.innerHTML = captionHTML || '';
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
lightbox.init();
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<style is:global>
|
|
54
|
+
.pswp .pswp__bg {
|
|
55
|
+
--pswp-bg: rgba(0, 0, 0, 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.pswp__counter {
|
|
59
|
+
color: white;
|
|
60
|
+
font-size: 1rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.pswp__button {
|
|
64
|
+
color: white;
|
|
65
|
+
opacity: 0.8;
|
|
66
|
+
transition: opacity 0.3s ease;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.pswp__button:hover {
|
|
70
|
+
opacity: 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.pswp__caption {
|
|
74
|
+
position: absolute;
|
|
75
|
+
bottom: 0;
|
|
76
|
+
left: 50%;
|
|
77
|
+
transform: translateX(-50%);
|
|
78
|
+
@media (max-width: 768px) {
|
|
79
|
+
width: calc(100% - 16px);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pswp__caption .image-caption {
|
|
84
|
+
background: rgba(0, 0, 0, 0.4);
|
|
85
|
+
backdrop-filter: blur(20px);
|
|
86
|
+
-webkit-backdrop-filter: blur(20px);
|
|
87
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
88
|
+
color: white;
|
|
89
|
+
padding: 16px;
|
|
90
|
+
border-radius: 16px;
|
|
91
|
+
margin: 1rem;
|
|
92
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
93
|
+
animation: glassSlideIn 0.6s ease-out;
|
|
94
|
+
transform-origin: bottom center;
|
|
95
|
+
transition: all 0.3s ease;
|
|
96
|
+
@media (max-width: 768px) {
|
|
97
|
+
padding: 8px 16px;
|
|
98
|
+
font-size: 0.8rem;
|
|
99
|
+
margin: 8px 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes glassSlideIn {
|
|
104
|
+
0% {
|
|
105
|
+
opacity: 0;
|
|
106
|
+
transform: translateY(20px) scale(0.95);
|
|
107
|
+
}
|
|
108
|
+
100% {
|
|
109
|
+
opacity: 1;
|
|
110
|
+
transform: translateY(0) scale(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.pswp__caption__center {
|
|
115
|
+
text-align: center;
|
|
116
|
+
max-width: 42rem;
|
|
117
|
+
margin: 0 auto;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.pswp__caption h3 {
|
|
121
|
+
font-size: 1.5rem;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
margin-bottom: 0.5rem;
|
|
124
|
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.pswp__caption p {
|
|
128
|
+
font-size: 1rem;
|
|
129
|
+
opacity: 0.95;
|
|
130
|
+
line-height: 1.6;
|
|
131
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
132
|
+
font-weight: 400;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.pswp__caption .description {
|
|
136
|
+
display: block;
|
|
137
|
+
margin-top: 0.5rem;
|
|
138
|
+
font-size: 0.95rem;
|
|
139
|
+
opacity: 0.9;
|
|
140
|
+
font-weight: 300;
|
|
141
|
+
font-style: italic;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.pswp__img {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.pswp__img--in-viewport {
|
|
149
|
+
animation: slideInImage 0.6s ease-out forwards;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@keyframes slideInImage {
|
|
153
|
+
0% {
|
|
154
|
+
opacity: 0;
|
|
155
|
+
}
|
|
156
|
+
100% {
|
|
157
|
+
opacity: 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { DEFAULT_STATIC_ASSETS_PATH } from '@/config';
|
|
3
|
+
import Container from '@/features/themes/base-theme/components/container/Container.astro';
|
|
4
|
+
import { getGalleryPath } from '@/features/themes/base-theme/utils';
|
|
5
|
+
|
|
6
|
+
interface SubGallery {
|
|
7
|
+
title: string;
|
|
8
|
+
headerImage: string;
|
|
9
|
+
path: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
subGalleries: SubGallery[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { title, description, subGalleries } = Astro.props;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<section class="sub-galleries">
|
|
22
|
+
<Container>
|
|
23
|
+
{
|
|
24
|
+
(title || description) && (
|
|
25
|
+
<div class="sub-galleries__header">
|
|
26
|
+
{title && <h2 class="sub-galleries__header-title">{title}</h2>}
|
|
27
|
+
{description && <p class="sub-galleries__header-description">{description}</p>}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
<div class="sub-galleries__grid">
|
|
32
|
+
{
|
|
33
|
+
subGalleries.map((subgallery) => (
|
|
34
|
+
<a href={getGalleryPath(subgallery.path)} class="sub-galleries__item">
|
|
35
|
+
<div class="sub-galleries__item-image">
|
|
36
|
+
<img
|
|
37
|
+
src={getGalleryPath(subgallery.headerImage) || `${DEFAULT_STATIC_ASSETS_PATH}/images/gallery-placeholder.png`}
|
|
38
|
+
alt={subgallery.title}
|
|
39
|
+
loading="lazy"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="sub-galleries__item-content">
|
|
43
|
+
<h3 class="sub-galleries__item-title">{subgallery.title}</h3>
|
|
44
|
+
</div>
|
|
45
|
+
</a>
|
|
46
|
+
))
|
|
47
|
+
}
|
|
48
|
+
</div>
|
|
49
|
+
</Container>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.sub-galleries {
|
|
54
|
+
padding: 5rem 1rem;
|
|
55
|
+
background-color: #f9fafb;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.sub-galleries__header {
|
|
59
|
+
text-align: center;
|
|
60
|
+
margin-bottom: 4rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.sub-galleries__header-title {
|
|
64
|
+
font-size: clamp(2.5rem, 5vw, 5rem);
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
margin-bottom: 1.5rem;
|
|
67
|
+
color: #111827;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.sub-galleries__header-description {
|
|
71
|
+
font-size: 1.125rem;
|
|
72
|
+
color: #6b7280;
|
|
73
|
+
max-width: 42rem;
|
|
74
|
+
margin: 0 auto;
|
|
75
|
+
line-height: 1.6;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sub-galleries__grid {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: repeat(3, 1fr);
|
|
81
|
+
gap: 2rem;
|
|
82
|
+
max-width: 1200px;
|
|
83
|
+
margin: 0 auto;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sub-galleries__item {
|
|
87
|
+
display: block;
|
|
88
|
+
text-decoration: none;
|
|
89
|
+
color: inherit;
|
|
90
|
+
border-radius: 0.75rem;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
93
|
+
transition: all 0.3s ease;
|
|
94
|
+
background-color: white;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.sub-galleries__item:hover {
|
|
98
|
+
transform: scale(1.02) translateY(-5px);
|
|
99
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sub-galleries__item-image {
|
|
103
|
+
position: relative;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
aspect-ratio: 16/9;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.sub-galleries__item-image img {
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: 100%;
|
|
111
|
+
object-fit: cover;
|
|
112
|
+
transition: transform 0.5s ease;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.sub-galleries__item:hover .sub-galleries__item-image img {
|
|
116
|
+
transform: scale(1.05);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.sub-galleries__item-content {
|
|
120
|
+
padding: 1.5rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.sub-galleries__item-title {
|
|
124
|
+
font-size: 1.25rem;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
color: #111827;
|
|
127
|
+
margin: 0;
|
|
128
|
+
line-height: 1.4;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@media (max-width: 640px) {
|
|
132
|
+
.sub-galleries__grid {
|
|
133
|
+
grid-template-columns: 1fr;
|
|
134
|
+
gap: 1.5rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.sub-galleries__item-content {
|
|
138
|
+
padding: 1rem;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { DEFAULT_STATIC_ASSETS_PATH } from '@/config';
|
|
3
|
+
|
|
4
|
+
import type { GalleryMetadata } from '@/features/themes/base-theme/types/gallery';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
metadata?: GalleryMetadata;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { title, metadata } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8" />
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
17
|
+
<title>{title}</title>
|
|
18
|
+
|
|
19
|
+
<base href="/" />
|
|
20
|
+
|
|
21
|
+
{/* Basic SEO */}
|
|
22
|
+
{metadata?.keywords && <meta name="keywords" content={metadata.keywords} />}
|
|
23
|
+
{metadata?.author && <meta name="author" content={metadata.author} />}
|
|
24
|
+
{metadata?.canonicalUrl && <link rel="canonical" href={metadata.canonicalUrl} />}
|
|
25
|
+
{metadata?.robots && <meta name="robots" content={metadata.robots} />}
|
|
26
|
+
{metadata?.language && <meta name="language" content={metadata.language} />}
|
|
27
|
+
|
|
28
|
+
{/* Favicon */}
|
|
29
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
30
|
+
|
|
31
|
+
{/* Open Graph / Facebook */}
|
|
32
|
+
{metadata?.ogType && <meta property="og:type" content={metadata.ogType} />}
|
|
33
|
+
{metadata?.ogUrl && <meta property="og:url" content={metadata.ogUrl} />}
|
|
34
|
+
<meta property="og:title" content={title} />
|
|
35
|
+
{metadata?.description && <meta property="og:description" content={metadata.description} />}
|
|
36
|
+
{metadata?.ogImage && <meta property="og:image" content={metadata.ogImage} />}
|
|
37
|
+
{metadata?.ogImageWidth && <meta property="og:image:width" content={metadata.ogImageWidth.toString()} />}
|
|
38
|
+
{metadata?.ogImageHeight && <meta property="og:image:height" content={metadata.ogImageHeight.toString()} />}
|
|
39
|
+
{metadata?.ogSiteName && <meta property="og:site_name" content={metadata.ogSiteName} />}
|
|
40
|
+
|
|
41
|
+
{/* Twitter */}
|
|
42
|
+
{metadata?.twitterCard && <meta name="twitter:card" content={metadata.twitterCard} />}
|
|
43
|
+
{metadata?.twitterSite && <meta name="twitter:site" content={metadata.twitterSite} />}
|
|
44
|
+
{metadata?.twitterCreator && <meta name="twitter:creator" content={metadata.twitterCreator} />}
|
|
45
|
+
<meta name="twitter:title" content={title} />
|
|
46
|
+
{metadata?.description && <meta name="twitter:description" content={metadata.description} />}
|
|
47
|
+
{metadata?.ogImage && <meta name="twitter:image" content={metadata.ogImage} />}
|
|
48
|
+
|
|
49
|
+
<script is:inline>
|
|
50
|
+
(function () {
|
|
51
|
+
let base = document.querySelector('base');
|
|
52
|
+
if (!base) {
|
|
53
|
+
base = document.createElement('base');
|
|
54
|
+
document.head.prepend(base);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Decide whether the current path ends in “directory” vs “file”.
|
|
58
|
+
const p = location.pathname;
|
|
59
|
+
const looksFile = /\.[a-z0-9]+$/i.test(p.split('/').pop() || '');
|
|
60
|
+
|
|
61
|
+
// If it’s “directory without slash”, stick a slash on and update <base>.
|
|
62
|
+
base.href = !p.endsWith('/') && !looksFile ? p + '/' : './';
|
|
63
|
+
})();
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<link rel="icon" href={`${DEFAULT_STATIC_ASSETS_PATH}/images/favicon.ico`} sizes="32x32" />
|
|
67
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
68
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
69
|
+
<link
|
|
70
|
+
href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap"
|
|
71
|
+
rel="stylesheet"
|
|
72
|
+
/>
|
|
73
|
+
</head>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
import MainHead from '@/features/themes/base-theme/layouts/MainHead.astro';
|
|
3
|
+
|
|
4
|
+
import type { GalleryMetadata } from '@/features/themes/base-theme/types/gallery';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
metadata?: GalleryMetadata;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { title, metadata } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!doctype html>
|
|
15
|
+
<html lang={metadata?.language || 'en'}>
|
|
16
|
+
<MainHead title={title} metadata={metadata} />
|
|
17
|
+
<body>
|
|
18
|
+
<slot />
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
<style is:global>
|
|
26
|
+
* {
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: 0;
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
font-family:
|
|
34
|
+
'Montserrat',
|
|
35
|
+
-apple-system,
|
|
36
|
+
BlinkMacSystemFont,
|
|
37
|
+
'Segoe UI',
|
|
38
|
+
Roboto,
|
|
39
|
+
sans-serif;
|
|
40
|
+
line-height: 1.6;
|
|
41
|
+
color: #333;
|
|
42
|
+
font-weight: 400;
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
import GallerySection from '@/features/themes/base-theme/components/gallery-section/GallerySection.astro';
|
|
5
|
+
import Hero from '@/features/themes/base-theme/components/hero/Hero.astro';
|
|
6
|
+
import PhotoSwipe from '@/features/themes/base-theme/components/lightbox/PhotoSwipe.astro';
|
|
7
|
+
import SubGalleries from '@/features/themes/base-theme/components/sub-galleries/SubGalleries.astro';
|
|
8
|
+
import MainLayout from '@/features/themes/base-theme/layouts/MainLayout.astro';
|
|
9
|
+
|
|
10
|
+
import type { GalleryData } from '@/features/themes/base-theme/types/gallery';
|
|
11
|
+
|
|
12
|
+
// Dynamically import gallery.json from source path or fallback to local
|
|
13
|
+
const galleryJsonPath = process.env.GALLERY_JSON_PATH || './gallery.json';
|
|
14
|
+
const galleryData = JSON.parse(fs.readFileSync(galleryJsonPath, 'utf8'));
|
|
15
|
+
|
|
16
|
+
const gallery = galleryData as GalleryData;
|
|
17
|
+
|
|
18
|
+
const { title, description, headerImage, metadata, sections, subGalleries } = gallery;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<MainLayout title={title} metadata={metadata}>
|
|
22
|
+
<Hero title={title} description={description} backgroundImage={headerImage} />
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
subGalleries && subGalleries.galleries.length > 0 && (
|
|
26
|
+
<SubGalleries
|
|
27
|
+
title={subGalleries.title}
|
|
28
|
+
description={subGalleries.description}
|
|
29
|
+
subGalleries={subGalleries.galleries}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
{sections.map((section, sectionIndex) => <GallerySection section={section} sectionIndex={sectionIndex} />)}
|
|
34
|
+
|
|
35
|
+
<PhotoSwipe />
|
|
36
|
+
</MainLayout>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface GalleryImage {
|
|
2
|
+
path: string;
|
|
3
|
+
alt?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
type: 'image' | 'video';
|
|
8
|
+
thumbnail: {
|
|
9
|
+
path: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GallerySection {
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
images: GalleryImage[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SubGallery {
|
|
22
|
+
title: string;
|
|
23
|
+
headerImage: string;
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GalleryMetadata {
|
|
28
|
+
description?: string;
|
|
29
|
+
ogUrl?: string;
|
|
30
|
+
ogImage?: string;
|
|
31
|
+
ogImageWidth?: number;
|
|
32
|
+
ogImageHeight?: number;
|
|
33
|
+
ogType?: string;
|
|
34
|
+
ogSiteName?: string;
|
|
35
|
+
twitterCard?: string;
|
|
36
|
+
twitterSite?: string;
|
|
37
|
+
twitterCreator?: string;
|
|
38
|
+
author?: string;
|
|
39
|
+
keywords?: string;
|
|
40
|
+
canonicalUrl?: string;
|
|
41
|
+
language?: string;
|
|
42
|
+
robots?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GalleryData {
|
|
46
|
+
title: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
outputDir?: string;
|
|
49
|
+
headerImage: string;
|
|
50
|
+
metadata?: GalleryMetadata;
|
|
51
|
+
sections: GallerySection[];
|
|
52
|
+
subGalleries?: {
|
|
53
|
+
title?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
galleries: SubGallery[];
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes resource paths to be relative to the gallery root directory instead of the gallery.json file.
|
|
5
|
+
*
|
|
6
|
+
* @param resourcePath - The resource path (file or directory), typically relative to the gallery.json file
|
|
7
|
+
* @returns The normalized path relative to the gallery root directory
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const getGalleryPath = (resourcePath: string) => {
|
|
11
|
+
const galleryConfigPath = path.resolve(process.env.GALLERY_JSON_PATH || '');
|
|
12
|
+
const galleryConfigDir = path.dirname(galleryConfigPath);
|
|
13
|
+
|
|
14
|
+
const absoluteResourcePath = path.resolve(path.join(galleryConfigDir, resourcePath));
|
|
15
|
+
const baseDir = path.dirname(galleryConfigDir);
|
|
16
|
+
|
|
17
|
+
return path.relative(baseDir, absoluteResourcePath);
|
|
18
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type PhotoSwipe from 'photoswipe';
|
|
2
|
+
import type PhotoSwipeLightbox from 'photoswipe/lightbox';
|
|
3
|
+
|
|
4
|
+
interface Slide {
|
|
5
|
+
content: Content;
|
|
6
|
+
height: number;
|
|
7
|
+
currZoomLevel: number;
|
|
8
|
+
bounds: { center: { y: number } };
|
|
9
|
+
placeholder?: { element: HTMLElement };
|
|
10
|
+
isActive: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Content {
|
|
14
|
+
data: SlideData;
|
|
15
|
+
element?: HTMLVideoElement | HTMLImageElement | HTMLDivElement;
|
|
16
|
+
state?: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
isAttached?: boolean;
|
|
19
|
+
onLoaded?: () => void;
|
|
20
|
+
appendImage?: () => void;
|
|
21
|
+
slide?: Slide;
|
|
22
|
+
_videoPosterImg?: HTMLImageElement;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SlideData {
|
|
26
|
+
type?: string;
|
|
27
|
+
msrc?: string;
|
|
28
|
+
videoSrc?: string;
|
|
29
|
+
videoSources?: Array<{ src: string; type: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface VideoPluginOptions {
|
|
33
|
+
videoAttributes?: Record<string, string>;
|
|
34
|
+
autoplay?: boolean;
|
|
35
|
+
preventDragOffset?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface EventData {
|
|
39
|
+
content?: Content;
|
|
40
|
+
slide?: Slide;
|
|
41
|
+
width?: number;
|
|
42
|
+
height?: number;
|
|
43
|
+
originalEvent?: PointerEvent;
|
|
44
|
+
preventDefault?: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const defaultOptions: VideoPluginOptions = {
|
|
48
|
+
videoAttributes: { controls: '', playsinline: '', preload: 'auto' },
|
|
49
|
+
autoplay: true,
|
|
50
|
+
preventDragOffset: 40,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if slide has video content
|
|
55
|
+
*/
|
|
56
|
+
function isVideoContent(content: Content | Slide): boolean {
|
|
57
|
+
return content && 'data' in content && content.data && content.data.type === 'video';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class VideoContentSetup {
|
|
61
|
+
private options: VideoPluginOptions;
|
|
62
|
+
|
|
63
|
+
constructor(lightbox: PhotoSwipeLightbox, options: VideoPluginOptions) {
|
|
64
|
+
this.options = options;
|
|
65
|
+
|
|
66
|
+
this.initLightboxEvents(lightbox);
|
|
67
|
+
lightbox.on('init', () => {
|
|
68
|
+
if (lightbox.pswp) {
|
|
69
|
+
this.initPswpEvents(lightbox.pswp);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private initLightboxEvents(lightbox: PhotoSwipeLightbox): void {
|
|
75
|
+
lightbox.on('contentLoad', (data: unknown) => this.onContentLoad(data as EventData));
|
|
76
|
+
lightbox.on('contentDestroy', (data: unknown) => this.onContentDestroy(data as { content: Content }));
|
|
77
|
+
lightbox.on('contentActivate', (data: unknown) => this.onContentActivate(data as { content: Content }));
|
|
78
|
+
lightbox.on('contentDeactivate', (data: unknown) => this.onContentDeactivate(data as { content: Content }));
|
|
79
|
+
lightbox.on('contentAppend', (data: unknown) => this.onContentAppend(data as EventData));
|
|
80
|
+
lightbox.on('contentResize', (data: unknown) => this.onContentResize(data as EventData));
|
|
81
|
+
|
|
82
|
+
lightbox.addFilter('isKeepingPlaceholder', (value: unknown, ...args: unknown[]) =>
|
|
83
|
+
this.isKeepingPlaceholder(value as boolean, args[0] as Content),
|
|
84
|
+
);
|
|
85
|
+
lightbox.addFilter('isContentZoomable', (value: unknown, ...args: unknown[]) =>
|
|
86
|
+
this.isContentZoomable(value as boolean, args[0] as Content),
|
|
87
|
+
);
|
|
88
|
+
lightbox.addFilter('useContentPlaceholder', (value: unknown, ...args: unknown[]) =>
|
|
89
|
+
this.useContentPlaceholder(value as boolean, args[0] as Content),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
lightbox.addFilter('domItemData', (value: unknown, ...args: unknown[]) => {
|
|
93
|
+
const itemData = value as Record<string, unknown>;
|
|
94
|
+
const linkEl = args[1] as HTMLAnchorElement;
|
|
95
|
+
|
|
96
|
+
if (itemData.type === 'video' && linkEl) {
|
|
97
|
+
if (linkEl.dataset.pswpVideoSources) {
|
|
98
|
+
itemData.videoSources = JSON.parse(linkEl.dataset.pswpVideoSources);
|
|
99
|
+
} else if (linkEl.dataset.pswpVideoSrc) {
|
|
100
|
+
itemData.videoSrc = linkEl.dataset.pswpVideoSrc;
|
|
101
|
+
} else {
|
|
102
|
+
itemData.videoSrc = linkEl.href;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return itemData;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private initPswpEvents(pswp: PhotoSwipe): void {
|
|
110
|
+
// Prevent dragging when pointer is in bottom part of the video
|
|
111
|
+
pswp.on('pointerDown', (data: unknown) => {
|
|
112
|
+
const e = data as EventData;
|
|
113
|
+
const slide = pswp.currSlide as Slide | undefined;
|
|
114
|
+
if (slide && isVideoContent(slide) && this.options.preventDragOffset) {
|
|
115
|
+
const origEvent = e.originalEvent;
|
|
116
|
+
if (origEvent && origEvent.type === 'pointerdown') {
|
|
117
|
+
const videoHeight = Math.ceil(slide.height * slide.currZoomLevel);
|
|
118
|
+
const verticalEnding = videoHeight + slide.bounds.center.y;
|
|
119
|
+
const pointerYPos = origEvent.pageY - pswp.offset.y;
|
|
120
|
+
if (pointerYPos > verticalEnding - this.options.preventDragOffset! && pointerYPos < verticalEnding) {
|
|
121
|
+
e.preventDefault?.();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// do not append video on nearby slides
|
|
128
|
+
pswp.on('appendHeavy', (data: unknown) => {
|
|
129
|
+
const e = data as EventData;
|
|
130
|
+
if (e.slide && isVideoContent(e.slide) && !e.slide.isActive) {
|
|
131
|
+
e.preventDefault?.();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pswp.on('close', () => {
|
|
136
|
+
const slide = pswp.currSlide as Slide | undefined;
|
|
137
|
+
if (slide && isVideoContent(slide.content)) {
|
|
138
|
+
// Switch from zoom to fade closing transition,
|
|
139
|
+
// as zoom transition is choppy for videos
|
|
140
|
+
if (!pswp.options.showHideAnimationType || pswp.options.showHideAnimationType === 'zoom') {
|
|
141
|
+
pswp.options.showHideAnimationType = 'fade';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// pause video when closing
|
|
145
|
+
this.pauseVideo(slide.content);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private onContentDestroy({ content }: { content: Content }): void {
|
|
151
|
+
if (isVideoContent(content) && content._videoPosterImg) {
|
|
152
|
+
const handleLoad = () => {
|
|
153
|
+
if (content._videoPosterImg) {
|
|
154
|
+
content._videoPosterImg.removeEventListener('error', handleError);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const handleError = () => {
|
|
158
|
+
// Error handler
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
content._videoPosterImg.addEventListener('load', handleLoad);
|
|
162
|
+
content._videoPosterImg.addEventListener('error', handleError);
|
|
163
|
+
content._videoPosterImg = undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private onContentResize(e: EventData): void {
|
|
168
|
+
if (e.content && isVideoContent(e.content)) {
|
|
169
|
+
e.preventDefault?.();
|
|
170
|
+
|
|
171
|
+
const width = e.width!;
|
|
172
|
+
const height = e.height!;
|
|
173
|
+
const content = e.content;
|
|
174
|
+
|
|
175
|
+
if (content.element) {
|
|
176
|
+
content.element.style.width = width + 'px';
|
|
177
|
+
content.element.style.height = height + 'px';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (content.slide && content.slide.placeholder) {
|
|
181
|
+
// override placeholder size, so it more accurately matches the video
|
|
182
|
+
const placeholderElStyle = content.slide.placeholder.element.style;
|
|
183
|
+
placeholderElStyle.transform = 'none';
|
|
184
|
+
placeholderElStyle.width = width + 'px';
|
|
185
|
+
placeholderElStyle.height = height + 'px';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private isKeepingPlaceholder(isZoomable: boolean, content: Content): boolean {
|
|
191
|
+
if (isVideoContent(content)) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return isZoomable;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private isContentZoomable(isZoomable: boolean, content: Content): boolean {
|
|
198
|
+
if (isVideoContent(content)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return isZoomable;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private onContentActivate({ content }: { content: Content }): void {
|
|
205
|
+
if (isVideoContent(content) && this.options.autoplay) {
|
|
206
|
+
this.playVideo(content);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private onContentDeactivate({ content }: { content: Content }): void {
|
|
211
|
+
if (isVideoContent(content)) {
|
|
212
|
+
this.pauseVideo(content);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private onContentAppend(e: EventData): void {
|
|
217
|
+
if (e.content && isVideoContent(e.content)) {
|
|
218
|
+
e.preventDefault?.();
|
|
219
|
+
e.content.isAttached = true;
|
|
220
|
+
e.content.appendImage?.();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private onContentLoad(e: EventData): void {
|
|
225
|
+
const content = e.content!;
|
|
226
|
+
|
|
227
|
+
if (!isVideoContent(content)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// stop default content load
|
|
232
|
+
e.preventDefault?.();
|
|
233
|
+
|
|
234
|
+
if (content.element) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
content.state = 'loading';
|
|
239
|
+
content.type = 'video';
|
|
240
|
+
|
|
241
|
+
content.element = document.createElement('video');
|
|
242
|
+
|
|
243
|
+
if (this.options.videoAttributes) {
|
|
244
|
+
for (const key in this.options.videoAttributes) {
|
|
245
|
+
content.element.setAttribute(key, this.options.videoAttributes[key] || '');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
content.element.setAttribute('poster', content.data.msrc || '');
|
|
250
|
+
|
|
251
|
+
this.preloadVideoPoster(content, content.data.msrc);
|
|
252
|
+
|
|
253
|
+
content.element.style.position = 'absolute';
|
|
254
|
+
content.element.style.left = '0';
|
|
255
|
+
content.element.style.top = '0';
|
|
256
|
+
|
|
257
|
+
if (content.data.videoSources) {
|
|
258
|
+
for (const source of content.data.videoSources) {
|
|
259
|
+
const sourceEl = document.createElement('source');
|
|
260
|
+
sourceEl.src = source.src;
|
|
261
|
+
sourceEl.type = source.type;
|
|
262
|
+
content.element.append(sourceEl);
|
|
263
|
+
}
|
|
264
|
+
} else if (content.data.videoSrc) {
|
|
265
|
+
// Force video preload
|
|
266
|
+
// https://muffinman.io/blog/hack-for-ios-safari-to-display-html-video-thumbnail/
|
|
267
|
+
// this.element.src = this.data.videoSrc + '#t=0.001';
|
|
268
|
+
content.element.src = content.data.videoSrc;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private preloadVideoPoster(content: Content, src?: string): void {
|
|
273
|
+
if (!content._videoPosterImg && src) {
|
|
274
|
+
content._videoPosterImg = new Image();
|
|
275
|
+
content._videoPosterImg.src = src;
|
|
276
|
+
if (content._videoPosterImg.complete) {
|
|
277
|
+
content.onLoaded?.();
|
|
278
|
+
} else {
|
|
279
|
+
content._videoPosterImg.addEventListener('load', () => {
|
|
280
|
+
content.onLoaded?.();
|
|
281
|
+
});
|
|
282
|
+
content._videoPosterImg.addEventListener('error', () => {
|
|
283
|
+
content.onLoaded?.();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private playVideo(content: Content): void {
|
|
290
|
+
if (content.element) {
|
|
291
|
+
(content.element as HTMLVideoElement).play();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private pauseVideo(content: Content): void {
|
|
296
|
+
if (content.element) {
|
|
297
|
+
(content.element as HTMLVideoElement).pause();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private useContentPlaceholder(usePlaceholder: boolean, content: Content): boolean {
|
|
302
|
+
if (isVideoContent(content)) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return usePlaceholder;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
class PhotoSwipeVideoPlugin {
|
|
310
|
+
constructor(lightbox: PhotoSwipeLightbox, options: VideoPluginOptions = {}) {
|
|
311
|
+
new VideoContentSetup(lightbox, {
|
|
312
|
+
...defaultOptions,
|
|
313
|
+
...options,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default PhotoSwipeVideoPlugin;
|
|
319
|
+
export type { VideoPluginOptions };
|