├── .gitignore
├── frontend
├── Procfile
├── tsconfig.json
├── src
│ ├── env.d.ts
│ ├── templates
│ │ ├── DefaultPage.astro
│ │ ├── index.js
│ │ ├── ArticleShowPage.astro
│ │ ├── HomePage.astro
│ │ └── ArticleIndexPage.astro
│ ├── widgets
│ │ ├── RichTextWidget.astro
│ │ ├── index.js
│ │ ├── ImageWidget.astro
│ │ ├── VideoWidget.astro
│ │ ├── LinkWidget.astro
│ │ ├── RowsWidget.astro
│ │ ├── AccordionWidget.astro
│ │ └── GridLayoutWidget.astro
│ ├── lib
│ │ ├── homepage-defaults.js
│ │ ├── use-site-config.js
│ │ └── attachments.js
│ ├── components
│ │ ├── Pagination.astro
│ │ ├── ArticlesFilter.astro
│ │ ├── Header.astro
│ │ └── Footer.astro
│ ├── layouts
│ │ └── article-layouts
│ │ │ ├── ShowMagazine.astro
│ │ │ ├── ShowMinimal.astro
│ │ │ ├── Standard.astro
│ │ │ ├── HeroGrid.astro
│ │ │ ├── ListAside.astro
│ │ │ └── ShowFullWidth.astro
│ ├── pages
│ │ └── [...slug].astro
│ └── styles
│ │ └── main.scss
├── .vscode
│ ├── extensions.json
│ └── launch.json
├── public
│ ├── fonts
│ │ └── fontawesome
│ │ │ ├── fa-solid-900.woff2
│ │ │ ├── fa-brands-400.woff2
│ │ │ ├── fa-regular-400.woff2
│ │ │ └── fa-v4compatibility.woff2
│ ├── images
│ │ ├── image-widget-placeholder.jpg
│ │ └── missing-icon.svg
│ ├── scripts
│ │ ├── dynamic-navbar-padding.js
│ │ └── VideoWidget.js
│ └── favicon.svg
├── .gitignore
├── postcss.config.js
├── README.md
├── package.json
└── astro.config.mjs
├── backend
├── modules
│ ├── @apostrophecms
│ │ ├── home-page
│ │ │ ├── views
│ │ │ │ └── page.html
│ │ │ └── index.js
│ │ ├── page
│ │ │ ├── views
│ │ │ │ └── notFound.html
│ │ │ └── index.js
│ │ ├── image-widget
│ │ │ ├── index.js
│ │ │ └── public
│ │ │ │ └── preview.svg
│ │ ├── express
│ │ │ └── index.js
│ │ ├── video-widget
│ │ │ ├── index.js
│ │ │ └── public
│ │ │ │ └── preview.svg
│ │ ├── asset
│ │ │ └── index.js
│ │ ├── attachment
│ │ │ └── index.js
│ │ ├── user
│ │ │ └── index.js
│ │ ├── admin-bar
│ │ │ └── index.js
│ │ ├── rich-text-widget
│ │ │ ├── index.js
│ │ │ └── public
│ │ │ │ └── preview.svg
│ │ ├── i18n
│ │ │ └── index.js
│ │ └── settings
│ │ │ └── index.js
│ ├── link-widget
│ │ ├── index.js
│ │ └── public
│ │ │ └── preview.svg
│ ├── @apostrophecms-pro
│ │ └── palette
│ │ │ └── index.js
│ ├── card-widget
│ │ ├── index.js
│ │ └── public
│ │ │ └── preview.svg
│ ├── hero-widget
│ │ ├── index.js
│ │ └── public
│ │ │ └── preview.svg
│ ├── slideshow-widget
│ │ ├── index.js
│ │ └── public
│ │ │ └── preview.svg
│ ├── default-page
│ │ └── index.js
│ ├── rows-widget
│ │ ├── public
│ │ │ └── preview.svg
│ │ └── index.js
│ ├── accordion-widget
│ │ ├── public
│ │ │ └── preview.svg
│ │ └── index.js
│ ├── grid-layout-widget
│ │ └── public
│ │ │ └── preview.svg
│ ├── author
│ │ └── index.js
│ ├── article
│ │ └── index.js
│ └── article-page
│ │ └── index.js
├── .eslintignore
├── public
│ └── images
│ │ └── logo.png
├── .eslintrc
├── .gitignore
├── scripts
│ ├── load-starter-content
│ └── update-starter-content
├── README.md
├── LICENSE
├── lib
│ ├── helpers
│ │ ├── color-options.js
│ │ ├── typography-options.js
│ │ └── area-widgets.js
│ └── schema-mixins
│ │ ├── slideshow-fields.js
│ │ ├── link-fields.js
│ │ ├── card-fields.js
│ │ └── hero-fields.js
├── app.js
├── package.json
└── views
│ └── layout.html
├── LICENSE
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | .DS_Store
--------------------------------------------------------------------------------
/frontend/Procfile:
--------------------------------------------------------------------------------
1 | web: node dist/server/entry.mjs
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/base"
3 | }
4 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/home-page/views/page.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/page/views/notFound.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
--------------------------------------------------------------------------------
/backend/.eslintignore:
--------------------------------------------------------------------------------
1 | /public/apos-frontend
2 | /data/temp
3 | /apos-build
4 | /modules/asset/ui/public
5 |
--------------------------------------------------------------------------------
/frontend/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/backend/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/backend/public/images/logo.png
--------------------------------------------------------------------------------
/frontend/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/public/fonts/fontawesome/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/frontend/public/fonts/fontawesome/fa-solid-900.woff2
--------------------------------------------------------------------------------
/frontend/public/images/image-widget-placeholder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/frontend/public/images/image-widget-placeholder.jpg
--------------------------------------------------------------------------------
/frontend/public/fonts/fontawesome/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/frontend/public/fonts/fontawesome/fa-brands-400.woff2
--------------------------------------------------------------------------------
/frontend/public/fonts/fontawesome/fa-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/frontend/public/fonts/fontawesome/fa-regular-400.woff2
--------------------------------------------------------------------------------
/frontend/public/fonts/fontawesome/fa-v4compatibility.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apostrophecms/astro-testbed/main/frontend/public/fonts/fontawesome/fa-v4compatibility.woff2
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/image-widget/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | label: 'Image',
4 | description: 'Display images on your page',
5 | previewImage: 'svg'
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/backend/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "apostrophe"
4 | ],
5 | "globals": {
6 | "apos": true
7 | },
8 | "rules": {
9 | "no-var": "error",
10 | "no-console": 0
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/express/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | session: {
4 | // If this still says `undefined`, set a real secret!
5 | secret: undefined
6 | }
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/video-widget/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | label: 'Video',
4 | description: 'Add a video player from services like YouTube',
5 | previewImage: 'svg'
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/asset/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | // When not in production, refresh the page on restart
3 | options: {
4 | refreshOnRestart: true,
5 | breakpointPreviewMode: {
6 | enabled: false
7 | },
8 | hmr: false
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/templates/DefaultPage.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | const { page, user, query } = Astro.props.aposData;
4 | const { main } = page;
5 | ---
6 |
9 |
--------------------------------------------------------------------------------
/frontend/public/scripts/dynamic-navbar-padding.js:
--------------------------------------------------------------------------------
1 | const adjustBodyPadding = () => {
2 | const navbar = document.querySelector('.navbar.is-fixed-top');
3 | if (navbar) {
4 | document.body.style.paddingTop = `${navbar.offsetHeight}px`;
5 | }
6 | };
7 |
8 | document.addEventListener('DOMContentLoaded', adjustBodyPadding);
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | # generated types
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/attachment/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | addFileGroups: [
4 | {
5 | name: 'videos',
6 | label: 'Videos',
7 | extensions: [
8 | 'mp4',
9 | 'webm',
10 | 'gif'
11 | ],
12 | extensionMaps: {}
13 | }
14 | ]
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | /public/apos-minified
3 | /apos-build
4 | /public/apos-frontend
5 | /modules/asset/ui/public/site.js
6 |
7 | # dependencies
8 | node_modules/
9 |
10 | # logs
11 | npm-debug.log
12 |
13 | # content
14 | /public/uploads
15 |
16 | # local env data
17 | /data
18 | /data/temp/uploadfs
19 |
20 | # macOS-specific files
21 | .DS_Store
--------------------------------------------------------------------------------
/backend/modules/link-widget/index.js:
--------------------------------------------------------------------------------
1 | import linkFields from '../../lib/schema-mixins/link-fields.js';
2 | export default {
3 | extend: '@apostrophecms/widget-type',
4 | options: {
5 | label: 'Link',
6 | icon: 'link-icon',
7 | previewImage: 'svg',
8 | description: 'Add a button that links to a page or URL'
9 | },
10 | fields: {
11 | add: linkFields
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms-pro/palette/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | fields: {
3 | add: {
4 | backgroundColor: {
5 | type: 'color',
6 | label: 'Page Background',
7 | selector: 'body',
8 | property: 'color'
9 | }
10 | },
11 | group: {
12 | colors: {
13 | label: 'Colors',
14 | fields: ['backgroundColor']
15 | }
16 | }
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/backend/modules/card-widget/index.js:
--------------------------------------------------------------------------------
1 | import { cardFields, cardGroups } from '../../lib/schema-mixins/card-fields.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/widget-type',
5 | options: {
6 | label: 'Card',
7 | icon: 'sign-text-icon',
8 | previewImage: 'svg',
9 | description: 'Cards from simple to complex.'
10 | },
11 | fields: {
12 | add: cardFields,
13 | group: cardGroups
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/backend/modules/hero-widget/index.js:
--------------------------------------------------------------------------------
1 | import heroFields from '../../lib/schema-mixins/hero-fields.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/widget-type',
5 | options: {
6 | label: 'Hero Section',
7 | icon: 'sign-text-icon',
8 | previewImage: 'svg',
9 | description: 'A full-width or split hero section with background image or video.'
10 | },
11 | fields: {
12 | add: heroFields
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | import postcssViewportToContainerToggle from 'postcss-viewport-to-container-toggle';
2 |
3 | export default {
4 | // Is the site still editable in production like a normal Apos Site,
5 | // if yes we need the plugin for all builds
6 | // plugins: [
7 | // postcssViewportToContainerToggle({
8 | // modifierAttr: 'data-breakpoint-preview-mode',
9 | // debug: true,
10 | // transform: null
11 | // })
12 | // ]
13 | }
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/user/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | fields: {
3 | add: {
4 | firstName: {
5 | type: 'string',
6 | label: 'First Name'
7 | },
8 | lastName: {
9 | type: 'string',
10 | label: 'Last Name'
11 | }
12 | },
13 | group: {
14 | account: {
15 | label: 'Account',
16 | fields: [
17 | 'firstName',
18 | 'lastName'
19 | ]
20 | }
21 | }
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/frontend/src/templates/index.js:
--------------------------------------------------------------------------------
1 | import HomePage from './HomePage.astro';
2 | import DefaultPage from './DefaultPage.astro';
3 | import ArticleIndexPage from './ArticleIndexPage.astro';
4 | import ArticleShowPage from './ArticleShowPage.astro';
5 |
6 | const templateComponents = {
7 | '@apostrophecms/home-page': HomePage,
8 | 'default-page': DefaultPage,
9 | 'article-page:index': ArticleIndexPage,
10 | 'article-page:show': ArticleShowPage
11 | };
12 |
13 | export default templateComponents;
14 |
--------------------------------------------------------------------------------
/backend/modules/slideshow-widget/index.js:
--------------------------------------------------------------------------------
1 | import slideshowFields from '../../lib/schema-mixins/slideshow-fields.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/widget-type',
5 | options: {
6 | label: 'Slideshow',
7 | icon: 'view-carousel-outline',
8 | previewImage: 'svg',
9 | description: 'A slideshow of images with optional titles and content.'
10 | },
11 | icons: {
12 | 'view-carousel-outline': 'ViewCarouselOutline'
13 | },
14 | fields: {
15 | add: slideshowFields
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/admin-bar/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | groups: [
4 | {
5 | name: 'media',
6 | label: 'Media',
7 | items: [
8 | '@apostrophecms/image',
9 | '@apostrophecms/file',
10 | '@apostrophecms/image-tag',
11 | '@apostrophecms/file-tag'
12 | ]
13 | },
14 | {
15 | name: 'blog',
16 | label: 'Blog',
17 | items: [
18 | 'article',
19 | 'author'
20 | ]
21 | }
22 | ]
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/frontend/src/widgets/RichTextWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * Rich Text Widget Component for ApostropheCMS
4 | * Renders editable rich text content within an Apostrophe area
5 | *
6 | * @component
7 | * @param {Object} props
8 | * @param {Object} props.widget - ApostropheCMS widget object
9 | * @param {string} props.widget.content - HTML content from Apostrophe's rich text editor
10 | */
11 |
12 | const { widget } = Astro.props;
13 | const { content } = widget;
14 | ---
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/backend/modules/default-page/index.js:
--------------------------------------------------------------------------------
1 | import { getWidgetGroups } from '../../lib/helpers/area-widgets.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/page-type',
5 | options: {
6 | label: 'Default Page'
7 | },
8 | fields: {
9 | add: {
10 | main: {
11 | type: 'area',
12 | options: getWidgetGroups({
13 | includeLayouts: true
14 | })
15 | }
16 | },
17 | group: {
18 | basics: {
19 | label: 'Basics',
20 | fields: [
21 | 'main'
22 | ]
23 | }
24 | }
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # apollo-frontend
2 |
3 | This is an Astro-based website, with editable content powered by ApostropheCMS.
4 |
5 | The project consist of the code in this sub-directory that provides an Astro-based frontend with customization to use ApostropheCMS as a backend, and the sub-directory with the ApostropheCMS code. For more information, consult the README at the root of this repository.
6 |
7 | To run the project locally, set the `APOS_EXTERNAL_FRONT_KEY` environment variable to the same string in both projects. Then issue the command `npm run dev` and point your browser at `localhost:4321`.
8 |
--------------------------------------------------------------------------------
/frontend/src/templates/ArticleShowPage.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import FullWidth from '../layouts/article-layouts/ShowFullWidth.astro';
3 | import Magazine from '../layouts/article-layouts/ShowMagazine.astro';
4 | import Minimal from '../layouts/article-layouts/ShowMinimal.astro';
5 |
6 | const {
7 | page,
8 | piece
9 | } = Astro.props.aposData;
10 |
11 | const layouts = {
12 | fullWidth: FullWidth,
13 | magazine: Magazine,
14 | minimal: Minimal
15 | };
16 |
17 | const SelectedLayout = layouts[page.showLayout] || FullWidth;
18 | ---
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/rich-text-widget/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | label: 'Rich Text',
4 | description: 'Add styled text to your page',
5 | previewImage: 'svg',
6 | className: 'o-widget',
7 | imageStyles: [
8 | {
9 | value: 'image-full',
10 | label: 'Full Width'
11 | },
12 | {
13 | value: 'image-center',
14 | label: 'Center'
15 | },
16 | {
17 | value: 'image-float-left',
18 | label: 'Float Left'
19 | },
20 | {
21 | value: 'image-float-right',
22 | label: 'Float Right'
23 | }
24 | ]
25 | }
26 | };
--------------------------------------------------------------------------------
/backend/scripts/load-starter-content:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if [ -z "$APOS_MONGODB_URI" ]; then
4 | APOS_MONGODB_URI="mongodb://localhost:27017/apollo"
5 | fi
6 |
7 | curl -o /tmp/starter-database.snapshot https://static.apostrophecms.com/apollo/starter-database.snapshot &&
8 | mongodb-snapshot-read --erase --from=/tmp/starter-database.snapshot --to=$APOS_MONGODB_URI &&
9 | curl -o /tmp/starter-uploads.tar https://static.apostrophecms.com/apollo/starter-uploads.tar &&
10 | mkdir -p public/uploads &&
11 | (cd public/uploads && tar -xf /tmp/starter-uploads.tar) &&
12 | echo "Starter content loaded. Now set an admin password." &&
13 | node app @apostrophecms/user:add admin admin
14 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # apollo-backend
2 |
3 | This is an Astro-based website, with editable content powered by ApostropheCMS.
4 |
5 | The project consist of the code in this sub-directory that provides an ApostropheCMS-based backend with customization to use Astro as a frontend, and the sub-directory with the Astro code. For more information, consult the README at the root of this repository.
6 |
7 | To run the project locally, set the `APOS_EXTERNAL_FRONT_KEY` environment variable to the same string in both projects. Then issue the command `npm run dev` and point your browser at `localhost:4321`. Your ApostropheCMS instance will be served at `localhost:3000`, but only provides information about the project status.
8 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/page/index.js:
--------------------------------------------------------------------------------
1 | // This configures the @apostrophecms/page module to add a "home" page type to the
2 | // pages menu
3 |
4 | export default {
5 | options: {
6 | builders: {
7 | children: true,
8 | ancestors: {
9 | children: {
10 | depth: 2,
11 | relationships: false
12 | }
13 | }
14 | },
15 | types: [
16 | {
17 | name: 'default-page',
18 | label: 'Default'
19 | },
20 | {
21 | name: 'article-page',
22 | label: 'Article Page'
23 | },
24 | {
25 | name: '@apostrophecms/home-page',
26 | label: 'Home'
27 | }
28 | ]
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
--------------------------------------------------------------------------------
/backend/scripts/update-starter-content:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # Our team uses this script to update the starter content. You may
4 | # safely remove this script from your own project
5 |
6 | if [ -z "$APOS_MONGODB_URI" ]; then
7 | APOS_MONGODB_URI="mongodb://localhost:27017/apollo"
8 | fi
9 |
10 | mongodb-snapshot-write --to=/tmp/starter-database.snapshot --from=$APOS_MONGODB_URI \
11 | --exclude=aposUsersSafe,aposLocks,aposBearerTokens,aposCache,aposNotifications,sessions \
12 | --filter-aposDocs='{"type":{"$ne":"@apostrophecms/user"}}' &&
13 | scp /tmp/starter-database.snapshot static@static.apostrophecms.com:/opt/static/static/apollo/starter-database.snapshot &&
14 | tar -cf /tmp/starter-uploads.tar -C ./public/uploads . &&
15 | scp /tmp/starter-uploads.tar static@static.apostrophecms.com:/opt/static/static/apollo/starter-uploads.tar &&
16 | echo "Starter content updated."
17 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/video-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Video Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/i18n/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | locales: {
4 | en: {
5 | label: 'English'
6 | },
7 | fr: {
8 | label: 'French',
9 | prefix: '/fr'
10 | },
11 | es: {
12 | label: 'Spanish',
13 | prefix: '/es'
14 | },
15 | de: {
16 | label: 'German',
17 | prefix: '/de'
18 | }
19 | },
20 | adminLocales: [
21 | // you can add an object for as many or few of the locales as desired
22 | // the user will only be able to select from these locales in the personal preferences menu
23 | {
24 | label: 'English',
25 | value: 'en'
26 | },
27 | {
28 | label: 'Spanish',
29 | value: 'es'
30 | },
31 | {
32 | label: 'French',
33 | value: 'fr'
34 | },
35 | {
36 | label: 'German',
37 | value: 'de'
38 | }
39 | ]
40 | }
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/widgets/index.js:
--------------------------------------------------------------------------------
1 | import RichTextWidget from './RichTextWidget.astro';
2 | import ImageWidget from './ImageWidget.astro';
3 | import VideoWidget from './VideoWidget.astro';
4 | import GridLayoutWidget from './GridLayoutWidget.astro';
5 | import AccordionWidget from './AccordionWidget.astro';
6 | import CardWidget from './CardWidget.astro';
7 | import HeroWidget from './HeroWidget.astro';
8 | import LinkWidget from './LinkWidget.astro';
9 | import SlideshowWidget from './SlideshowWidget.astro';
10 | import RowsWidget from './RowsWidget.astro';
11 |
12 | const widgetComponents = {
13 | '@apostrophecms/rich-text': RichTextWidget,
14 | '@apostrophecms/image': ImageWidget,
15 | '@apostrophecms/video': VideoWidget,
16 | 'grid-layout': GridLayoutWidget,
17 | 'accordion': AccordionWidget,
18 | 'card': CardWidget,
19 | 'hero': HeroWidget,
20 | 'link': LinkWidget,
21 | 'slideshow': SlideshowWidget,
22 | 'rows': RowsWidget
23 | };
24 |
25 | export default widgetComponents;
26 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/image-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Image Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/backend/modules/rows-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Row Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/backend/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024 Apostrophe Technologies
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2025 Apostrophe Technologies
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19 | OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/backend/lib/helpers/color-options.js:
--------------------------------------------------------------------------------
1 | const colorOptionsHelper = {
2 | getColorOptions() {
3 | return [
4 | // Main colors
5 | { label: 'White', value: 'white' },
6 | { label: 'Black', value: 'black' },
7 | { label: 'Light', value: 'light' },
8 | { label: 'Dark', value: 'dark' },
9 | { label: 'Primary', value: 'primary' },
10 | { label: 'Link', value: 'link' },
11 | { label: 'Info', value: 'info' },
12 | { label: 'Success', value: 'success' },
13 | { label: 'Warning', value: 'warning' },
14 | { label: 'Danger', value: 'danger' },
15 | { label: 'Black Bis', value: 'black-bis' },
16 | { label: 'Black Ter', value: 'black-ter' },
17 | { label: 'Grey Darker', value: 'grey-darker' },
18 | { label: 'Grey Dark', value: 'grey-dark' },
19 | { label: 'Grey', value: 'grey' },
20 | { label: 'Grey Light', value: 'grey-light' },
21 | { label: 'Grey Lighter', value: 'grey-lighter' },
22 | { label: 'White Ter', value: 'white-ter' },
23 | { label: 'White Bis', value: 'white-bis' },
24 | { label: 'Transparent', value: 'transparent' }
25 | ];
26 | }
27 | };
28 |
29 | export default colorOptionsHelper;
30 |
--------------------------------------------------------------------------------
/frontend/public/images/missing-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
10 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "apollo-frontend",
3 | "type": "module",
4 | "description": "Astro frontend for the ApostropheCMS + Astro Apollo template",
5 | "version": "1.0.0",
6 | "scripts": {
7 | "dev": "astro dev",
8 | "start": "node ./dist/server/entry.mjs",
9 | "build": "astro build",
10 | "serve": "HOST=0.0.0.0 node ./dist/server/entry.mjs",
11 | "preview": "DEBUG=* astro preview",
12 | "astro": "astro"
13 | },
14 | "engines": {
15 | "node": "^18.17.1 || ^20.3.0 || >=22.0.0",
16 | "npm": ">=9.0.0"
17 | },
18 | "dependencies": {
19 | "@apostrophecms/apostrophe-astro": "github:apostrophecms/apostrophe-astro",
20 | "@astrojs/node": "^9.1.1",
21 | "@fortawesome/fontawesome-svg-core": "^6.6.0",
22 | "@fortawesome/free-brands-svg-icons": "^6.6.0",
23 | "@fortawesome/free-regular-svg-icons": "^6.6.0",
24 | "@fortawesome/free-solid-svg-icons": "^6.6.0",
25 | "accordion-js": "^3.3.4",
26 | "astro": "^5.3.1",
27 | "bulma": "^1.0.2",
28 | "dayjs": "^1.11.10",
29 | "postcss-viewport-to-container-toggle": "^1.0.0",
30 | "vite": "^5.0.7"
31 | },
32 | "devDependencies": {
33 | "sass-embedded": "^1.81.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/rich-text-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Rich Text Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/backend/modules/hero-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hero Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/frontend/src/templates/HomePage.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Hero from '../widgets/HeroWidget.astro';
3 | import Slideshow from '../widgets/SlideshowWidget.astro';
4 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
5 |
6 | import { heroDefaults, slideshowDefaults } from '../lib/homepage-defaults.js';
7 |
8 | const { page, user, query, global } = Astro.props.aposData;
9 | const { layout = 'foundation', heroSection = {}, showcaseSlideshow = {}, main = {} } = page;
10 |
11 | // Use default data if no data is provided
12 | const heroData = heroSection?.mainContent?.title ? heroSection : heroDefaults;
13 | const slideshowData = showcaseSlideshow?.slides?.length ? showcaseSlideshow : slideshowDefaults;
14 |
15 | ---
16 |
17 | {/* Foundation Layout */}
18 | {layout === 'foundation' && (
19 | <>
20 | {heroData &&
}
21 | >
22 | )}
23 |
24 | {/* Showcase Layout */}
25 | {layout === 'showcase' && (
26 | <>
27 | {slideshowData && (
28 |
29 |
30 |
31 | )}
32 | >
33 | )}
34 |
35 | {/* Main Content Area */}
36 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/backend/modules/slideshow-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Slideshow Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/backend/modules/card-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Card Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/lib/homepage-defaults.js:
--------------------------------------------------------------------------------
1 | export const heroDefaults = {
2 | layout: 'split',
3 | splitSide: 'right',
4 | background: 'image',
5 | height: 'large',
6 | contentAlignment: 'left',
7 | mainContent: {
8 | title: 'Welcome to Your Site',
9 | subtitle: 'Start customizing your homepage',
10 | titleColor: 'primary',
11 | subtitleColor: 'primary',
12 | }
13 | };
14 |
15 | export const slideshowDefaults = {
16 | slideDuration: 5000,
17 | transitionSpeed: 300,
18 | autoplay: true,
19 | showControls: true,
20 | slides: [
21 | {
22 | slideTitle: 'Welcome to Our Site',
23 | titleColor: 'warning',
24 | cardContent: 'Edit this slideshow to add your own content and images.',
25 | contentColor: 'success',
26 | textBlockBackground: 'dark',
27 | textBlockOpacity: '65',
28 | },
29 | {
30 | slideTitle: 'Customizable Design',
31 | titleColor: 'primary',
32 | cardContent: 'Add your own slides with content.',
33 | contentColor: 'warning',
34 | textBlockBackground: 'dark',
35 | textBlockOpacity: '65',
36 | },
37 | {
38 | slideTitle: 'Getting Started',
39 | titleColor: 'info',
40 | cardContent: 'Click edit to begin customizing your slideshow.',
41 | contentColor: 'white',
42 | textBlockBackground: 'dark',
43 | textBlockOpacity: '65',
44 | }
45 | ]
46 | };
47 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/settings/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | options: {
3 | subforms: {
4 | changePassword: {
5 | // This will have `protection: true` automatically.
6 | fields: [ 'password' ]
7 | },
8 | displayName: {
9 | // The default `title` field is labeled 'Display Name' in the `@apostrophecms/user` module.
10 | // Changing this field will **not** change the Username or Slug of the user.
11 | fields: [ 'title' ],
12 | reload: true
13 | },
14 | fullName: {
15 | // Passing in a label so that it doesn't use the label for `lastName`
16 | // These fields need to be added to the user schema
17 | label: 'Full Name',
18 | // Schema fields added at project level
19 | fields: [ 'lastName', 'firstName' ],
20 | preview: '{{ firstName }} {{lastName}}'
21 | },
22 | // The `adminLocales` option **must** be configured in the `@apostrophecms/i18n` module for this to be allowed
23 | adminLocale: {
24 | fields: [ 'adminLocale' ]
25 | }
26 | },
27 | groups: {
28 | account: {
29 | label: 'Account',
30 | subforms: [ 'displayName', 'fullName', 'changePassword' ]
31 | },
32 | preferences: {
33 | label: 'Preferences',
34 | // The `adminLocales` option **must** be configured in the `@apostrophecms/i18n` module for this to be allowed
35 | subforms: [ 'adminLocale' ]
36 | }
37 | }
38 | }
39 | };
40 |
--------------------------------------------------------------------------------
/frontend/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config';
2 | import node from '@astrojs/node';
3 | import apostrophe from '@apostrophecms/apostrophe-astro';
4 | import path from 'path';
5 |
6 | // https://astro.build/config
7 | export default defineConfig({
8 | output: "server",
9 | server: {
10 | port: process.env.PORT ? parseInt(process.env.PORT) : 4321,
11 | // Required for some hosting, like Heroku
12 | // host: true
13 | },
14 | adapter: node({
15 | mode: 'standalone'
16 | }),
17 | integrations: [apostrophe({
18 | aposHost: 'http://localhost:3000',
19 | widgetsMapping: './src/widgets',
20 | templatesMapping: './src/templates',
21 | includeResponseHeaders: [
22 | 'content-security-policy',
23 | 'strict-transport-security',
24 | 'x-frame-options',
25 | 'referrer-policy',
26 | 'cache-control'
27 | ],
28 | excludeRequestHeaders: [
29 | // For hosting on multiple servers, block the host header
30 | // 'host'
31 | ]
32 | })],
33 | vite: {
34 | css: {
35 | preprocessorOptions: {
36 | scss: {
37 | quietDeps: true
38 | }
39 | }
40 | },
41 | ssr: {
42 | // Do not externalize the @apostrophecms/apostrophe-astro plugin, we need
43 | // to be able to use virtual: URLs there
44 | noExternal: ['@apostrophecms/apostrophe-astro']
45 | }
46 | },
47 | css: {
48 | preprocessorOptions: {
49 | scss: {
50 | api: 'modern-compiler',
51 | }
52 | }
53 | }
54 | });
--------------------------------------------------------------------------------
/frontend/src/components/Pagination.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js';
3 |
4 | const { class: className = '', currentPage, totalPages, url } = Astro.props;
5 |
6 | const pages = Array.from({ length: totalPages }, (_, i) => ({
7 | number: i + 1,
8 | current: i + 1 === currentPage,
9 | url: setParameter(url, 'page', i + 1)
10 | }));
11 |
12 | const showPrevNext = totalPages > 1;
13 | const prevUrl = currentPage > 1 ? setParameter(url, 'page', currentPage - 1) : null;
14 | const nextUrl = currentPage < totalPages ? setParameter(url, 'page', currentPage + 1) : null;
15 | ---
16 |
17 |
18 | {showPrevNext && (
19 |
27 |
35 | )}
36 |
37 |
54 |
--------------------------------------------------------------------------------
/backend/modules/accordion-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Accordion Widget Preview
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/backend/app.js:
--------------------------------------------------------------------------------
1 | import apostrophe from 'apostrophe';
2 |
3 | export default apostrophe({
4 | root: import.meta,
5 | shortName: 'astro-testbed',
6 | modules: {
7 | // Apostrophe module configuration
8 | // *******************************
9 | //
10 | // Most configuration occurs in the respective modules' directories.
11 | // See modules/@apostrophecms/page/index.js for an example.
12 | //
13 | // Any modules that are not present by default in Apostrophe must at least
14 | // have a minimal configuration here to turn them on: `moduleName: {}`
15 |
16 | // *********************************
17 | '@apostrophecms/vite': {},
18 |
19 | // `className` options set custom CSS classes for Apostrophe core widgets.
20 | '@apostrophecms/rich-text-widget': {},
21 | '@apostrophecms/image-widget': {},
22 | '@apostrophecms/video-widget': {},
23 | '@apostrophecms/asset': {},
24 |
25 | // Custom extensions
26 | // Make sure to set the `APOS_BASE_URL` environment variable to the base URL of your Apostrophe site
27 | '@apostrophecms/seo': {},
28 | '@apostrophecms-pro/palette': {},
29 | '@apostrophecms-pro/document-versions': {},
30 | '@apostrophecms-pro/advanced-permission-group': {},
31 | '@apostrophecms-pro/advanced-permission': {},
32 | '@apostrophecms-pro/doc-template-library': {},
33 |
34 | // pieces
35 | article: {},
36 | author: {},
37 |
38 | // pages
39 | 'default-page': {},
40 | 'article-page': {},
41 |
42 | // widgets
43 | 'grid-layout-widget': {},
44 | 'accordion-widget': {},
45 | 'card-widget': {},
46 | 'hero-widget': {},
47 | 'link-widget': {},
48 | 'slideshow-widget': {},
49 | 'rows-widget': {}
50 | }
51 | });
52 |
--------------------------------------------------------------------------------
/backend/modules/grid-layout-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Bitmap
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/components/ArticlesFilter.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js';
3 |
4 | const { currentUrl, currentCategory, articles } = Astro.props;
5 |
6 | // Extract unique categories from articles
7 | const uniqueCategories = [...new Set(articles.map(article => article.category))].filter(Boolean);
8 |
9 | // Create category objects array with counts
10 | const categories = [
11 | {
12 | label: 'All Articles',
13 | value: '',
14 | count: articles.length
15 | },
16 | ...uniqueCategories.map(cat => ({
17 | label: cat.charAt(0).toUpperCase() + cat.slice(1),
18 | value: cat,
19 | count: articles.filter(article => article.category === cat).length
20 | }))
21 | ];
22 |
23 | function buildUrl(currentUrl, category) {
24 | let url = currentUrl;
25 | if (category) {
26 | url = setParameter(url, 'category', category);
27 | } else {
28 | url = setParameter(url, 'category', null); // Removes the parameter
29 | }
30 | if (url.searchParams.get('page')) {
31 | url = setParameter(url, 'page', null); // Reset to first page
32 | }
33 | return url;
34 | }
35 | ---
36 |
37 |
38 |
Filter by Category
39 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "combined-astro-starter-kit",
3 | "version": "1.0.0",
4 | "description": "Combined ApostropheCMS plus Astro starter kit",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "postinstall": "npm run install-frontend && npm run install-backend && echo '\\n💡 combined npm install complete'",
9 | "install-frontend": "cd frontend && echo '\\n💡 running npm install in frontend/' && npm install",
10 | "install-backend": "cd backend && echo '\\n💡 running npm install in backend/' && npm install",
11 | "update": "npm run update-frontend && npm run update-backend && echo '\\n💡 combined npm update complete'",
12 | "update-frontend": "cd frontend && echo '\\n💡 running npm update in frontend/' && npm update",
13 | "update-backend": "cd backend && echo '\\n💡 running npm update in backend/' && npm update",
14 | "build": "npm run build-frontend && npm run build-backend && echo '\\n💡 combined npm build complete'",
15 | "build-frontend": "cd frontend && echo '\\n💡 running npm run build in frontend/' && npm run build",
16 | "build-backend": " cd backend && echo '\\n💡 running npm run build in backend/' && npm run build",
17 | "migrate": "cd backend && npm run migrate",
18 | "serve-frontend": "cd frontend && npm run serve",
19 | "serve-backend": "cd backend && npm run serve",
20 | "load-starter-content": "cd backend && npm run load-starter-content"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/apostrophecms/combined-astro-starter-kit.git"
25 | },
26 | "keywords": [
27 | "astro",
28 | "apostrophecms",
29 | "apostrophe"
30 | ],
31 | "author": "Apostrophe Technologies Inc.",
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/apostrophecms/combined-astro-starter-kit/issues"
35 | },
36 | "homepage": "https://github.com/apostrophecms/combined-astro-starter-kit#readme"
37 | }
38 |
--------------------------------------------------------------------------------
/backend/modules/author/index.js:
--------------------------------------------------------------------------------
1 | export default {
2 | extend: '@apostrophecms/piece-type',
3 | options: {
4 | label: 'Author',
5 | pluralLabel: 'Authors'
6 | },
7 | fields: {
8 | add: {
9 | title: {
10 | type: 'string',
11 | label: 'Name'
12 | },
13 | profileImage: {
14 | type: 'area',
15 | options: {
16 | max: 1,
17 | widgets: {
18 | '@apostrophecms/image': {}
19 | }
20 | }
21 | },
22 | biography: {
23 | type: 'area',
24 | options: {
25 | widgets: {
26 | '@apostrophecms/rich-text': {}
27 | }
28 | }
29 | },
30 | email: {
31 | type: 'email',
32 | label: 'Email Address'
33 | },
34 | socialLinks: {
35 | type: 'array',
36 | label: 'Social Media Links',
37 | titleField: 'platform',
38 | inline: true,
39 | fields: {
40 | add: {
41 | platform: {
42 | type: 'select',
43 | choices: [
44 | { label: 'Twitter/X', value: 'twitter' },
45 | { label: 'LinkedIn', value: 'linkedin' },
46 | { label: 'GitHub', value: 'github' },
47 | { label: 'Personal Website', value: 'website' }
48 | ]
49 | },
50 | url: {
51 | type: 'url',
52 | label: 'Profile URL'
53 | }
54 | }
55 | }
56 | },
57 | _articles: {
58 | type: 'relationshipReverse',
59 | withType: 'article',
60 | reverseOf: '_author'
61 | }
62 | },
63 | group: {
64 | basics: {
65 | label: 'Basic Info',
66 | fields: ['title', 'email', 'profileImage', '_articles']
67 | },
68 | content: {
69 | label: 'Content',
70 | fields: ['biography']
71 | },
72 | social: {
73 | label: 'Social Media',
74 | fields: ['socialLinks']
75 | }
76 | }
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/frontend/src/widgets/ImageWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * Image widget component for ApostropheCMS
4 | * Renders a responsive image with proper sizing and focal point support.
5 | * Content editors only need to:
6 | * - Upload an image
7 | * - Set alt text
8 | * - Optionally set a focal point
9 | *
10 | * @param {Object} props
11 | * @param {Object} props.widget - The widget data from ApostropheCMS
12 | * @returns {astro.AstroComponent} - Rendered image component
13 | */
14 |
15 | import {
16 | getAttachmentUrl,
17 | getAttachmentSrcset,
18 | getFocalPoint,
19 | getWidth,
20 | getHeight
21 | } from '../lib/attachments.js';
22 |
23 | const { widget, imageOptions } = Astro.props;
24 | const placeholder = widget?.aposPlaceholder;
25 | const image = widget?._image?.[0];
26 |
27 | // Handle missing image gracefully
28 | if (!placeholder && !image) {
29 | console.warn('Image widget: No image or placeholder provided');
30 | }
31 |
32 | const src = placeholder
33 | ? '/images/image-widget-placeholder.jpg'
34 | : getAttachmentUrl(image);
35 | const srcset = placeholder ? '' : getAttachmentSrcset(image);
36 | const objectPosition = placeholder ? 'center center' : getFocalPoint(image);
37 | const alt = image?.alt || '';
38 |
39 | // Only add width/height if they exist to prevent layout shift
40 | const width = image ? getWidth(image) : undefined;
41 | const height = image ? getHeight(image) : undefined;
42 | const aspectRatio = width && height ? `${width}/${height}` : undefined;
43 | ---
44 |
45 |
54 |
55 |
--------------------------------------------------------------------------------
/frontend/src/widgets/VideoWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * Video Widget Astro Component
4 | *
5 | * This component integrates with ApostropheCMS's video widget and renders videos
6 | * using a custom web component. It supports multiple video providers through
7 | * ApostropheCMS's oEmbed implementation - editors can simply paste any standard
8 | * video sharing URL from supported platforms like YouTube or Vimeo.
9 | *
10 | * @typedef {Object} VideoWidget
11 | * @property {boolean} [aposPlaceholder] - Indicates if this is a placeholder instance in the editor
12 | * @property {Object} [video] - Video data object
13 | * @property {string} [video.url] - The URL of the video to embed
14 | * @property {string} [video.title] - The title of the video
15 | *
16 | * @param {Object} props - Component properties
17 | * @param {VideoWidget} [props.widget] - The widget data from ApostropheCMS
18 | */
19 |
20 | const { widget } = Astro.props;
21 |
22 | // Use placeholder video for the widget editor UI when no video is selected
23 | const placeholder = widget?.aposPlaceholder ? 'true' : '';
24 | const url = widget?.video?.url;
25 | const videoTitle = widget?.video?.title;
26 |
27 | // Default placeholder video used in the ApostropheCMS editor
28 | const PLACEHOLDER_VIDEO_URL = 'https://youtu.be/Q5UX9yexEyM';
29 | ---
30 |
31 |
42 |
43 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/backend/modules/accordion-widget/index.js:
--------------------------------------------------------------------------------
1 | import colorOptionsHelper from '../../lib/helpers/color-options.js';
2 | import { getWidgetGroups } from '../../lib/helpers/area-widgets.js';
3 |
4 | export default {
5 | extend: '@apostrophecms/widget-type',
6 | options: {
7 | label: 'Accordion',
8 | icon: 'arrow-down-drop-circle',
9 | previewImage: 'svg',
10 | description: 'An accordion of items with headers and content.'
11 | },
12 | icons: {
13 | 'arrow-down-drop-circle': 'ArrowDownDropCircle'
14 | },
15 | fields: {
16 | add: {
17 | itemBackgroundColor: {
18 | type: 'select',
19 | label: 'Item Background Color',
20 | choices: colorOptionsHelper.getColorOptions(),
21 | def: 'white'
22 | },
23 | allowMultipleOpen: {
24 | type: 'boolean',
25 | label: 'Allow Multiple Items Open',
26 | def: false
27 | },
28 | openIndex: {
29 | type: 'integer',
30 | label: 'Default Open Item (-1 for none, 1 for first item, etc...)',
31 | def: -1
32 | },
33 | items: {
34 | type: 'array',
35 | label: 'Items',
36 | titleField: 'header',
37 | inline: true,
38 | fields: {
39 | add: {
40 | header: {
41 | type: 'string',
42 | label: 'Header'
43 | },
44 | headerColor: {
45 | type: 'select',
46 | label: 'Header Color',
47 | choices: colorOptionsHelper.getColorOptions(),
48 | def: 'black'
49 | },
50 | headerAlignment: {
51 | type: 'select',
52 | label: 'Header Alignment',
53 | choices: [
54 | { label: 'Left', value: 'left' },
55 | { label: 'Center', value: 'center' },
56 | { label: 'Right', value: 'right' }
57 | ],
58 | def: 'left'
59 | },
60 | content: {
61 | type: 'area',
62 | label: 'Content',
63 | options: getWidgetGroups({
64 | exclude: ['accordion']
65 | })
66 | }
67 | }
68 | }
69 | }
70 | }
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "apollo-backend",
3 | "version": "1.0.0",
4 | "description": "Apostrophe backend for the ApostropheCMS + Astro Apollo template",
5 | "main": "app.js",
6 | "type": "module",
7 | "scripts": {
8 | "start": "node app",
9 | "dev": "nodemon",
10 | "build": "NODE_ENV=production node app @apostrophecms/asset:build",
11 | "serve": "NODE_ENV=production node app",
12 | "release": "npm install && npm run build && npm run migrate",
13 | "migrate": "NODE_ENV=production node app @apostrophecms/migration:migrate",
14 | "load-starter-content": "./scripts/load-starter-content",
15 | "update-starter-content": "./scripts/update-starter-content"
16 | },
17 | "engines": {
18 | "node": "^18.17.1 || ^20.3.0 || >=22.0.0",
19 | "npm": ">=9.0.0"
20 | },
21 | "nodemonConfig": {
22 | "delay": 1000,
23 | "verbose": true,
24 | "watch": [
25 | "./app.js",
26 | "./modules/**/*",
27 | "./lib/**/*.js",
28 | "./views/**/*.html"
29 | ],
30 | "ignoreRoot": [
31 | ".git"
32 | ],
33 | "ignore": [
34 | "**/ui/apos/",
35 | "**/ui/src/",
36 | "**ui/public/",
37 | "locales/*.json",
38 | "public/uploads/",
39 | "public/apos-frontend/*.js",
40 | "data/"
41 | ],
42 | "ext": "json, js, html, scss, vue"
43 | },
44 | "repository": {
45 | "type": "git",
46 | "url": "https://github.com/apostrophecms/apollo"
47 | },
48 | "author": "Apostrophe Technologies, Inc.",
49 | "license": "MIT",
50 | "dependencies": {
51 | "@apostrophecms-pro/advanced-permission": "apostrophecms/advanced-permission",
52 | "@apostrophecms-pro/doc-template-library": "apostrophecms/doc-template-library",
53 | "@apostrophecms-pro/document-versions": "apostrophecms/document-versions",
54 | "@apostrophecms-pro/palette": "apostrophecms/palette",
55 | "@apostrophecms/mongodb-snapshot": "apostrophecms/mongodb-snapshot",
56 | "@apostrophecms/seo": "apostrophecms/seo",
57 | "@apostrophecms/vite": "apostrophecms/vite",
58 | "apostrophe": "apostrophecms/apostrophe",
59 | "normalize.css": "^8.0.1"
60 | },
61 | "devDependencies": {
62 | "eslint": "^8.0.0",
63 | "eslint-config-apostrophe": "^4.0.0",
64 | "nodemon": "^3.0.1"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/backend/lib/helpers/typography-options.js:
--------------------------------------------------------------------------------
1 | const textOptionsHelper = {
2 | getTextSizes() {
3 | return [
4 | { label: 'Default', value: '' },
5 | { label: 'Size 1 (3rem)', value: 'is-size-1' },
6 | { label: 'Size 2 (2.5rem)', value: 'is-size-2' },
7 | { label: 'Size 3 (2rem)', value: 'is-size-3' },
8 | { label: 'Size 4 (1.5rem)', value: 'is-size-4' },
9 | { label: 'Size 5 (1.25rem)', value: 'is-size-5' },
10 | { label: 'Size 6 (1rem)', value: 'is-size-6' },
11 | { label: 'Size 7 (0.75rem)', value: 'is-size-7' }
12 | ];
13 | },
14 |
15 | getResponsiveSizes() {
16 | const sizes = ['1', '2', '3', '4', '5', '6', '7'];
17 | const breakpoints = ['mobile', 'tablet', 'desktop', 'widescreen'];
18 |
19 | const choices = [{ label: 'Default', value: '' }];
20 |
21 | breakpoints.forEach(breakpoint => {
22 | sizes.forEach(size => {
23 | choices.push({
24 | label: `Size ${size} (${breakpoint})`,
25 | value: `is-size-${size}-${breakpoint}`
26 | });
27 | });
28 | });
29 |
30 | return choices;
31 | },
32 |
33 | getTextWeights() {
34 | return [
35 | { label: 'Default', value: '' },
36 | { label: 'Light (300)', value: 'has-text-weight-light' },
37 | { label: 'Normal (400)', value: 'has-text-weight-normal' },
38 | { label: 'Medium (500)', value: 'has-text-weight-medium' },
39 | { label: 'Semi-Bold (600)', value: 'has-text-weight-semibold' },
40 | { label: 'Bold (700)', value: 'has-text-weight-bold' }
41 | ];
42 | },
43 |
44 | getTextTransforms() {
45 | return [
46 | { label: 'Default', value: '' },
47 | { label: 'Capitalized', value: 'is-capitalized' },
48 | { label: 'Lowercase', value: 'is-lowercase' },
49 | { label: 'Uppercase', value: 'is-uppercase' },
50 | { label: 'Italic', value: 'is-italic' }
51 | ];
52 | },
53 |
54 | getTextAlignments() {
55 | return [
56 | { label: 'Default', value: '' },
57 | { label: 'Left', value: 'has-text-left' },
58 | { label: 'Centered', value: 'has-text-centered' },
59 | { label: 'Right', value: 'has-text-right' },
60 | { label: 'Justified', value: 'has-text-justified' }
61 | ];
62 | }
63 | };
64 |
65 | export default textOptionsHelper;
66 |
--------------------------------------------------------------------------------
/backend/modules/article/index.js:
--------------------------------------------------------------------------------
1 | import { getWidgetGroups } from '../../lib/helpers/area-widgets.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/piece-type',
5 | options: {
6 | label: 'Article',
7 | pluralLabel: 'Articles',
8 | shortcut: 'Shift+Alt+A'
9 | },
10 | fields: {
11 | add: {
12 | category: {
13 | type: 'select',
14 | label: 'Category',
15 | help: 'Choose a category for this article',
16 | choices: [
17 | {
18 | label: 'News',
19 | value: 'news'
20 | },
21 | {
22 | label: 'Opinion',
23 | value: 'opinion'
24 | },
25 | {
26 | label: 'Feature',
27 | value: 'feature'
28 | },
29 | {
30 | label: 'Review',
31 | value: 'review'
32 | }
33 | ]
34 | },
35 | _heroImage: {
36 | type: 'relationship',
37 | label: 'Hero Image',
38 | withType: '@apostrophecms/image',
39 | max: 1
40 | },
41 | excerpt: {
42 | type: 'string',
43 | textarea: true,
44 | label: 'Article Excerpt',
45 | help: 'Brief summary for listings and previews'
46 | },
47 | mainContent: {
48 | type: 'area',
49 | options: getWidgetGroups({
50 | includeLayouts: true
51 | })
52 |
53 | },
54 | _author: {
55 | type: 'relationship',
56 | label: 'Author',
57 | withType: 'author',
58 | withRelationships: [ '_articles' ]
59 | },
60 | publishDate: {
61 | lable: 'Publication Date',
62 | type: 'date',
63 | required: true
64 | },
65 | _related: {
66 | type: 'relationship',
67 | label: 'Related Articles',
68 | withType: 'article',
69 | max: 4,
70 | builders: {
71 | project: {
72 | title: 1,
73 | _url: 1
74 | }
75 | },
76 | withRelationships: [ '_heroImage' ]
77 | }
78 | },
79 | group: {
80 | basics: {
81 | label: 'Basic Info',
82 | fields: ['_author', 'category', 'publishDate', '_related']
83 | },
84 | content: {
85 | label: 'Content',
86 | fields: ['_heroImage', 'excerpt', 'mainContent']
87 | }
88 | }
89 | }
90 | };
91 |
--------------------------------------------------------------------------------
/backend/modules/@apostrophecms/home-page/index.js:
--------------------------------------------------------------------------------
1 | import { getWidgetGroups } from '../../../lib/helpers/area-widgets.js';
2 | import heroFields from '../../../lib/schema-mixins/hero-fields.js';
3 | import slideshowFields from '../../../lib/schema-mixins/slideshow-fields.js';
4 |
5 | export default {
6 | options: {
7 | label: 'Home Page'
8 | },
9 | fields: {
10 | add: {
11 | layout: {
12 | type: 'select',
13 | label: 'Layout',
14 | choices: [
15 | {
16 | label: 'Foundation - with Hero',
17 | value: 'foundation',
18 | help: 'Traditional layout with hero section at the top'
19 | },
20 | {
21 | label: 'Showcase - with Slideshow',
22 | value: 'showcase',
23 | help: 'Features a prominent slideshow at the top'
24 | },
25 | {
26 | label: 'Minimal',
27 | value: 'minimal',
28 | help: 'Clean slate for custom designs'
29 | }
30 | ],
31 | def: 'foundation'
32 | },
33 | // Hero Section - For Foundation Layout
34 | heroSection: {
35 | type: 'object',
36 | label: 'Hero Section',
37 | fields: {
38 | add: heroFields
39 | },
40 | if: {
41 | layout: 'foundation'
42 | }
43 | },
44 | // Showcase Layout Sections
45 | showcaseSlideshow: {
46 | type: 'object',
47 | label: 'Showcase Slideshow',
48 | fields: {
49 | add: slideshowFields
50 | },
51 | if: {
52 | layout: 'showcase'
53 | }
54 | },
55 | // Main Content Area - Available for all layouts
56 | main: {
57 | type: 'area',
58 | label: 'Main Content',
59 | options: getWidgetGroups({
60 | includeLayouts: true
61 | })
62 | }
63 | },
64 | group: {
65 | utility: {
66 | label: 'Layout Settings',
67 | fields: [
68 | 'layout'
69 | ]
70 | },
71 | hero: {
72 | label: 'Hero Section',
73 | fields: [
74 | 'heroSection'
75 | ]
76 | },
77 | showcase: {
78 | label: 'Showcase Content',
79 | fields: [
80 | 'showcaseSlideshow'
81 | ]
82 | },
83 | content: {
84 | label: 'Content',
85 | fields: [
86 | 'main'
87 | ]
88 | }
89 | }
90 | }
91 | };
92 |
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/ShowMagazine.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 |
11 | const { article } = Astro.props;
12 | const heroImage = article?._heroImage?.[0];
13 | ---
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | By {article._author[0].title} · {new Date(article.publishDate).toLocaleDateString()}
23 |
24 |
{article.title}
25 |
{article.excerpt}
26 |
27 |
28 | {heroImage && (
29 |
30 |
41 |
42 | )}
43 |
44 |
47 |
48 | {article._tags?.length > 0 && (
49 |
50 | {article._tags.map(tag => (
51 | {tag.title}
52 | ))}
53 |
54 | )}
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/backend/modules/article-page/index.js:
--------------------------------------------------------------------------------
1 | import { getWidgetGroups } from '../../lib/helpers/area-widgets.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/piece-page-type',
5 | options: {
6 | label: 'Article Page',
7 | perPage: 7,
8 | piecesFilters: [
9 | {
10 | name: 'category'
11 | }
12 | ]
13 | },
14 | fields: {
15 | add: {
16 | masthead: {
17 | type: 'area',
18 | options: getWidgetGroups({
19 | includeLayouts: true
20 | })
21 | },
22 | beforeContent: {
23 | type: 'area',
24 | label: 'Before Articles Section',
25 | options: getWidgetGroups({
26 | includeLayouts: true
27 | })
28 | },
29 | sidebarContent: {
30 | type: 'area',
31 | label: 'Sidebar Content',
32 | if: {
33 | indexLayout: 'listAside'
34 | },
35 | options: getWidgetGroups({
36 | includeLayouts: true
37 | })
38 | },
39 | afterContent: {
40 | type: 'area',
41 | label: 'After Articles Section',
42 | options: getWidgetGroups({
43 | includeLayouts: true
44 | })
45 | },
46 | indexLayout: {
47 | type: 'select',
48 | label: 'Index Page Layout',
49 | def: 'heroGrid',
50 | choices: [
51 | {
52 | label: 'Hero Grid',
53 | value: 'heroGrid',
54 | help: 'Featured article with grid layout below'
55 | },
56 | {
57 | label: 'List with Aside',
58 | value: 'listAside',
59 | help: 'Articles in a list with side navigation'
60 | },
61 | {
62 | label: 'Standard',
63 | value: 'standard',
64 | help: 'Traditional blog-style listing'
65 | }
66 | ]
67 | },
68 | showLayout: {
69 | type: 'select',
70 | label: 'Article Display Layout',
71 | def: 'fullWidth',
72 | choices: [
73 | {
74 | label: 'Full Width',
75 | value: 'fullWidth',
76 | help: 'Hero image and content spanning full width'
77 | },
78 | {
79 | label: 'Magazine Style',
80 | value: 'magazine',
81 | help: 'Enhanced typography with pull quotes and featured sections'
82 | },
83 | {
84 | label: 'Minimal',
85 | value: 'minimal',
86 | help: 'Simple, clean layout with focus on content'
87 | }
88 | ]
89 | }
90 | },
91 | group: {
92 | basics: {
93 | label: 'Basics',
94 | fields: ['masthead', 'beforeContent', 'sidebarContent', 'afterContent']
95 | },
96 | utility: {
97 | label: 'Display Options',
98 | fields: ['indexLayout', 'showLayout']
99 | }
100 | }
101 | }
102 | };
103 |
--------------------------------------------------------------------------------
/backend/lib/schema-mixins/slideshow-fields.js:
--------------------------------------------------------------------------------
1 | import colorOptionsHelper from '../../lib/helpers/color-options.js';
2 |
3 | export default {
4 | slideDuration: {
5 | type: 'integer',
6 | label: 'Slide Duration (ms)',
7 | def: 5000,
8 | min: 1000,
9 | max: 20000
10 | },
11 | transitionSpeed: {
12 | type: 'integer',
13 | label: 'Transition Speed (ms)',
14 | def: 1000,
15 | min: 100,
16 | max: 2000
17 | },
18 | autoplay: {
19 | type: 'boolean',
20 | label: 'Enable Autoplay',
21 | def: true
22 | },
23 | showControls: {
24 | type: 'boolean',
25 | label: 'Show Navigation Controls',
26 | def: true
27 | },
28 | slides: {
29 | type: 'array',
30 | label: 'Slides',
31 | titleField: 'slideTitle',
32 | inline: true,
33 | fields: {
34 | add: {
35 | slideTitle: {
36 | type: 'string',
37 | label: 'Slide Title',
38 | required: true
39 | },
40 | titleColor: {
41 | type: 'select',
42 | label: 'Title Color',
43 | choices: colorOptionsHelper.getColorOptions().filter(color =>
44 | color.value !== 'transparent'
45 | )
46 | },
47 | titleSize: {
48 | type: 'select',
49 | label: 'Title Size',
50 | choices: [
51 | {
52 | label: 'Small',
53 | value: 'is-5'
54 | },
55 | {
56 | label: 'Medium',
57 | value: 'is-4'
58 | },
59 | {
60 | label: 'Large',
61 | value: 'is-3'
62 | }
63 | ],
64 | def: 'is-4'
65 | },
66 | cardContent: {
67 | type: 'string',
68 | label: 'Slide Content',
69 | textarea: true
70 | },
71 | contentColor: {
72 | type: 'select',
73 | label: 'Content Text Color',
74 | choices: colorOptionsHelper.getColorOptions().filter(color =>
75 | color.value !== 'transparent'
76 | )
77 | },
78 | contentSize: {
79 | type: 'select',
80 | label: 'Content Text Size',
81 | choices: [
82 | {
83 | label: 'Small',
84 | value: 'is-size-6'
85 | },
86 | {
87 | label: 'Medium',
88 | value: 'is-size-5'
89 | },
90 | {
91 | label: 'Large',
92 | value: 'is-size-4'
93 | }
94 | ],
95 | def: 'is-size-6'
96 | },
97 | textBlockBackground: {
98 | type: 'select',
99 | label: 'Text Block Background Color',
100 | choices: colorOptionsHelper.getColorOptions().filter(color =>
101 | color.value !== 'transparent'
102 | ),
103 | def: 'white'
104 | },
105 | textBlockOpacity: {
106 | type: 'range',
107 | label: 'Text Block Opacity',
108 | min: 0,
109 | max: 100,
110 | step: 5,
111 | def: 70
112 | },
113 | _image: {
114 | type: 'relationship',
115 | label: 'Slide Image',
116 | withType: '@apostrophecms/image',
117 | max: 1
118 | }
119 | }
120 | }
121 | }
122 | };
123 |
--------------------------------------------------------------------------------
/frontend/src/templates/ArticleIndexPage.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js';
3 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
4 |
5 | import Pagination from '../components/Pagination.astro';
6 |
7 | import HeroGrid from '../layouts/article-layouts/HeroGrid.astro';
8 | import ListAside from '../layouts/article-layouts/ListAside.astro';
9 | import Standard from '../layouts/article-layouts/Standard.astro';
10 |
11 | const {
12 | page,
13 | user,
14 | query,
15 | piecesFilters = [],
16 | pieces,
17 | currentPage,
18 | totalPages
19 | } = Astro.props.aposData;
20 |
21 | const pages = [];
22 | for (let i = 1; i <= totalPages; i++) {
23 | pages.push({
24 | number: i,
25 | current: i === currentPage,
26 | url: setParameter(Astro.url, 'page', i)
27 | });
28 | }
29 | ---
30 |
31 |
32 |
{page.title}
33 |
34 | {/* Global Masthead - Shows for all layouts */}
35 | {
36 | page.masthead && (
37 |
42 | )
43 | }
44 |
45 | {
46 | Array.isArray(piecesFilters) && piecesFilters.length > 0 && (
47 |
57 | )
58 | }
59 |
60 | {/* Before Content Area */}
61 | {page.beforeContent && (
62 |
65 | )}
66 |
67 | {page.indexLayout === 'heroGrid' && (
68 |
73 | )}
74 |
75 | {page.indexLayout === 'listAside' && (
76 |
83 | )}
84 |
85 | {page.indexLayout === 'standard' && (
86 |
93 | )}
94 |
95 | {/* After Content Area */}
96 | {page.afterContent && (
97 |
100 | )}
101 |
102 | {totalPages > 1 && (
103 |
109 | )}
110 |
111 |
112 |
--------------------------------------------------------------------------------
/frontend/src/pages/[...slug].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js';
3 | import AposLayout from '@apostrophecms/apostrophe-astro/components/layouts/AposLayout.astro';
4 | import AposTemplate from '@apostrophecms/apostrophe-astro/components/AposTemplate.astro';
5 |
6 | const aposData = await aposPageFetch(Astro.request);
7 |
8 | if (aposData.redirect) {
9 | return Astro.redirect(aposData.url, aposData.status);
10 | }
11 | if (aposData.notFound) {
12 | Astro.response.status = 404;
13 | }
14 | // Extract header information
15 | const headerPosition = aposData.global?.headerGroup?.headerPosition || 'static';
16 |
17 |
18 | // Initialize an array to hold body classes
19 | const bodyClasses = [];
20 |
21 | // Conditionally add 'has-navbar-fixed-top' based on headerPosition
22 | if (headerPosition === 'fixed' || headerPosition === 'fixed-fade') {
23 | bodyClasses.push('has-navbar-fixed-top');
24 | }
25 |
26 | // Join the classes into a single string
27 | const bodyClass = bodyClasses.join(' ');
28 |
29 | import '../styles/main.scss';
30 |
31 | import Header from '../components/Header.astro';
32 | import Footer from '../components/Footer.astro';
33 | ---
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/frontend/src/widgets/LinkWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * @typedef {Object} LinkWidget
4 | * @property {'page'|'file'|'url'} linkType - Type of link
5 | * @property {Object} _linkPage - Page link object
6 | * @property {string} _linkPage._url - URL for page link
7 | * @property {Object} _linkFile - File link object
8 | * @property {string} _linkFile._url - URL for file link
9 | * @property {string} linkUrl - Direct URL
10 | * @property {string} linkText - Text to display
11 | * @property {'button'|'link'} linkStyle - Style of the link
12 | * @property {string} [buttonColor] - Button color variant
13 | * @property {string} [buttonStyle] - Button style variant
14 | * @property {string} [buttonSize] - Button size variant
15 | * @property {boolean} [buttonDisabled] - Disabled state
16 | * @property {string} [linkTarget] - Target attribute value
17 | * @property {string} [icon] - Icon identifier
18 | * @property {'left'|'right'} [iconPosition] - Icon position
19 | * @property {'left'|'center'|'right'} [buttonAlignment='left'] - Button alignment
20 | */
21 |
22 | // Constants
23 | const ALIGNMENT_MAP = {
24 | 'left': 'is-justify-content-flex-start',
25 | 'center': 'is-justify-content-center',
26 | 'right': 'is-justify-content-flex-end'
27 | };
28 |
29 | const { widget } = Astro.props;
30 |
31 | // Input validation
32 | if (!widget?.linkType || !widget?.linkText) {
33 | throw new Error('Link widget requires linkType and linkText properties');
34 | }
35 |
36 | // Determine the link path based on type
37 | const path = (() => {
38 | switch (widget.linkType) {
39 | case 'page':
40 | return widget._linkPage?.[0]?._url;
41 | case 'file':
42 | return widget._linkFile?.[0]?._url;
43 | default:
44 | return widget.linkUrl;
45 | }
46 | })();
47 |
48 | const getButtonStyle = (style) => style ? `is-${style}` : '';
49 |
50 | // Build button classes
51 | const isButton = widget.linkStyle === 'button';
52 | const buttonClasses = isButton
53 | ? [
54 | 'button',
55 | widget.buttonColor && `is-${widget.buttonColor}`,
56 | getButtonStyle(widget.buttonStyle),
57 | widget.buttonSize && `is-${widget.buttonSize}`
58 | ].filter(Boolean).join(' ')
59 | : '';
60 |
61 | const linkTarget = widget.linkTarget?.includes('_blank') ? '_blank' : '';
62 |
63 | // Build link attributes
64 | const attributes = {
65 | target: linkTarget,
66 | class: isButton ? buttonClasses : 'link',
67 | href: path,
68 | ...(linkTarget === '_blank' && { rel: 'noopener noreferrer' }),
69 | ...(isButton && { role: 'button' }),
70 | ...(widget.buttonDisabled ? {
71 | 'disabled': true,
72 | 'aria-disabled': 'true',
73 | tabindex: '-1'
74 | } : {})
75 | };
76 |
77 | const hasIcon = Boolean(widget.icon);
78 | const alignmentClass = ALIGNMENT_MAP[widget.buttonAlignment || 'left'];
79 |
80 | // Prepare aria-label if link opens in new tab
81 | const ariaLabel = linkTarget === '_blank'
82 | ? `${widget.linkText} (opens in new tab)`
83 | : undefined;
84 | ---
85 |
86 |
--------------------------------------------------------------------------------
/backend/lib/helpers/area-widgets.js:
--------------------------------------------------------------------------------
1 | // Define our available widgets grouped by type
2 | export const widgetGroups = {
3 | // Layout widgets are structural elements that help organize content
4 | layout: {
5 | label: 'Layout',
6 | columns: 2,
7 | widgets: {
8 | 'grid-layout': {},
9 | rows: {}
10 | }
11 | },
12 | // Content widgets are the actual content elements users can add
13 | content: {
14 | label: 'Content',
15 | columns: 3,
16 | widgets: {
17 | '@apostrophecms/image': {},
18 | '@apostrophecms/video': {},
19 | '@apostrophecms/rich-text': {
20 | toolbar: [
21 | 'styles',
22 | '|',
23 | 'bold',
24 | 'italic',
25 | 'strike',
26 | 'link',
27 | 'anchor',
28 | '|',
29 | 'bulletList',
30 | 'orderedList',
31 | '|',
32 | 'alignLeft',
33 | 'alignCenter',
34 | 'alignRight',
35 | '|',
36 | 'blockquote',
37 | 'codeBlock',
38 | '|',
39 | 'horizontalRule',
40 | '|',
41 | 'table',
42 | 'image',
43 | '|',
44 | 'undo',
45 | 'redo'
46 | ],
47 | styles: [
48 | {
49 | tag: 'p',
50 | label: 'Paragraph (P)'
51 | },
52 | {
53 | tag: 'h3',
54 | label: 'Heading 3 (H3)'
55 | },
56 | {
57 | tag: 'h4',
58 | label: 'Heading 4 (H4)'
59 | }
60 | ],
61 | insert: [
62 | 'table',
63 | 'image'
64 | ]
65 | },
66 | slideshow: {},
67 | hero: {},
68 | accordion: {},
69 | card: {},
70 | link: {}
71 | }
72 | }
73 | };
74 |
75 | /**
76 | * Creates the groups configuration for ApostropheCMS widget areas
77 | * @param {Object} options - Configuration options
78 | * @param {boolean} options.includeLayouts - If true, includes layout widgets in the groups
79 | * @param {Array} options.exclude - Array of widget names to exclude
80 | * @returns {Object} Returns the groups configuration object
81 | *
82 | * @example
83 | * // In your page type or piece type:
84 | * fields: {
85 | * add: {
86 | * main: {
87 | * type: 'area',
88 | * options: {
89 | * // Get grouped widgets configuration
90 | * ...getWidgetGroups({
91 | * includeLayouts: true,
92 | * exclude: ['hero']
93 | * }),
94 | * // Add any additional area options
95 | * max: 10,
96 | * min: 1
97 | * }
98 | * }
99 | * }
100 | * }
101 | */
102 | export const getWidgetGroups = ({
103 | includeLayouts = false,
104 | exclude = []
105 | } = {}) => {
106 | // Initialize our groups object
107 | const groups = {};
108 |
109 | // Add layout widgets if requested
110 | if (includeLayouts) {
111 | groups.layout = {
112 | ...widgetGroups.layout,
113 | // Filter out any excluded widgets
114 | widgets: Object.fromEntries(
115 | Object.entries(widgetGroups.layout.widgets)
116 | .filter(([key]) => !exclude.includes(key))
117 | )
118 | };
119 | }
120 |
121 | // Always add content widgets
122 | groups.content = {
123 | ...widgetGroups.content,
124 | // Filter out any excluded widgets
125 | widgets: Object.fromEntries(
126 | Object.entries(widgetGroups.content.widgets)
127 | .filter(([key]) => !exclude.includes(key))
128 | )
129 | };
130 |
131 | // Return just the expanded and groups properties
132 | // This allows other area options to be spread alongside it
133 | return {
134 | expanded: true,
135 | groups
136 | };
137 | };
138 |
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/ShowMinimal.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 |
11 | const { article } = Astro.props;
12 | const heroImage = article?._heroImage?.[0];
13 | ---
14 |
15 | {
16 | heroImage && (
17 |
18 |
19 |
30 |
31 |
32 | )
33 | }
34 |
35 |
36 |
37 |
{article.title}
38 |
39 | {new Date(article.publishDate).toLocaleDateString()} · By {
40 | article._author[0].title
41 | }
42 |
43 |
44 |
47 |
48 | {
49 | article._related?.length > 0 && (
50 |
51 |
More Articles
52 |
53 | {article._related?.map((related) => {
54 | const relatedHero = related?._heroImage?.[0];
55 | return (
56 |
84 | );
85 | })}
86 |
87 |
88 | )
89 | }
90 |
91 |
92 |
93 |
110 |
--------------------------------------------------------------------------------
/frontend/src/lib/use-site-config.js:
--------------------------------------------------------------------------------
1 | export function useSiteConfig(globalData) {
2 | const brandingGroup = globalData?.brandingGroup || {};
3 | const headerGroup = globalData?.headerGroup || {};
4 |
5 | const getHeaderClasses = () => {
6 | const classes = ['navbar'];
7 |
8 | // Add position class based on headerMode or headerPosition
9 | const headerPosition = headerGroup.headerPosition;
10 |
11 | if (headerPosition === 'fixed') {
12 | classes.push('is-fixed-top'); // Bulma's fixed top class
13 | } else if (headerPosition === 'fixed-fade') {
14 | classes.push('is-fixed-top', 'is-fixed-fade');
15 | }
16 |
17 | // Add spacing class
18 | if (headerGroup.spacing) {
19 | classes.push(headerGroup.spacing);
20 | }
21 |
22 | // Add background color
23 | if (headerGroup.headerBackgroundColor) {
24 | classes.push(`has-background-${headerGroup.headerBackgroundColor}`);
25 | }
26 |
27 | // Add text color
28 | if (headerGroup.headerTextColor) {
29 | classes.push(`has-text-${headerGroup.headerTextColor}`);
30 | }
31 |
32 | return classes.join(' ');
33 | };
34 |
35 |
36 | const getHeaderTransparency = () => {
37 | if (headerGroup.transparency) {
38 | return headerGroup.transparency;
39 | };
40 | return 100;
41 | }
42 |
43 | const getNavItemClasses = (isActive = false) => {
44 | const classes = ['navbar-item'];
45 |
46 | if (headerGroup.dropdownTextColor) {
47 | classes.push(`has-text-${headerGroup.dropdownTextColor}`);
48 | }
49 |
50 | if (headerGroup.headerBackgroundColor) {
51 | classes.push(`has-background-${headerGroup.headerBackgroundColor}`);
52 | }
53 |
54 | if (isActive) {
55 | classes.push('is-active');
56 | if (headerGroup.headerActiveColor) {
57 | classes.push(`has-background-${headerGroup.headerActiveColor}`);
58 | }
59 | }
60 |
61 | // Add hover classes via data attribute for CSS handling
62 | if (headerGroup.headerHoverColor) {
63 | classes.push(`hover-color-${headerGroup.headerHoverColor}`);
64 | }
65 |
66 | return classes.join(' ');
67 | };
68 |
69 | const getDropdownClasses = () => {
70 | const classes = ['navbar-dropdown'];
71 | if (headerGroup.dropdownTextColor) {
72 | classes.push(`has-text-${headerGroup.dropdownTextColor}`);
73 | }
74 |
75 | if (headerGroup.headerBackgroundColor) {
76 | classes.push(`has-background-${headerGroup.headerBackgroundColor}`);
77 | }
78 |
79 | return classes.join(' ');
80 | };
81 |
82 | const renderBranding = (isMobile = false) => {
83 | const displayType = isMobile && brandingGroup.mobileDisplayPreference !== 'same'
84 | ? brandingGroup.mobileDisplayPreference
85 | : brandingGroup.brandingType;
86 |
87 | const elements = [];
88 |
89 | // Add logo if needed
90 | if (displayType === 'logo' || displayType === 'both') {
91 | if (brandingGroup.siteLogo?._urls?.max) {
92 | elements.push(
93 | ` `
99 | );
100 | }
101 | }
102 |
103 | // Add text if needed
104 | if (displayType === 'text' || displayType === 'both') {
105 | elements.push(
106 | `
107 | ${brandingGroup.siteTitle}
108 | `
109 | );
110 | }
111 |
112 | return elements.join('');
113 | };
114 |
115 | return {
116 | getHeaderClasses,
117 | getHeaderTransparency,
118 | getNavItemClasses,
119 | getDropdownClasses,
120 | renderBranding
121 | };
122 | }
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/Standard.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 | import ArticlesFilter from '../../components/ArticlesFilter.astro';
11 |
12 | const {
13 | pieces,
14 | showImage = true,
15 | imageWidth = 4,
16 | showAuthorAvatar = true,
17 | excerptLength
18 | } = Astro.props;
19 | const textWidth = showImage ? 12 - imageWidth : 12;
20 |
21 | const currentCategory = Astro.url.searchParams.get('category') || '';
22 | ---
23 |
24 |
25 |
30 |
31 | {
32 | pieces.map((article) => {
33 | const heroImage = article?._heroImage?.[0];
34 | return (
35 |
36 |
37 |
74 | {showImage && heroImage && (
75 |
76 |
77 |
89 |
90 |
91 | )}
92 |
93 |
94 | );
95 | })
96 | }
97 |
98 |
99 |
113 |
--------------------------------------------------------------------------------
/frontend/src/styles/main.scss:
--------------------------------------------------------------------------------
1 | @charset "utf-8";
2 |
3 | /// BULMA CUSTOMIZATION
4 | // There are three ways to use/customize Bulma:
5 |
6 | // METHOD 1: Use standard Bulma, but add back dark mode without customization
7 | //@use 'bulma/sass';
8 |
9 | // METHOD 2: Customize using bulma-no-dark-mode
10 | // To customize, replace the line "@use 'bulma/versions/bulma-no-dark-mode';" located below
11 | // with the following, uncommenting and modifying variables as needed:
12 |
13 | //@use 'bulma/versions/bulma-no-dark-mode' with (
14 | // Colors
15 | // $turquoise: hsl(171, 100%, 41%), // Primary color
16 | // $cyan: hsl(204, 86%, 53%), // Info color
17 | // $green: hsl(141, 71%, 48%), // Success color
18 | // $yellow: hsl(48, 100%, 67%), // Warning color
19 | // $red: hsl(348, 100%, 61%), // Danger color
20 | // $blue: hsl(217, 71%, 53%), // Link color
21 |
22 | // Typography
23 | // $family-sans-serif: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif,
24 | // $family-monospace: monospace,
25 | // $size-1: 3rem,
26 | // $size-2: 2.5rem,
27 | // $size-3: 2rem,
28 | // $size-4: 1.5rem,
29 | // $size-5: 1.25rem,
30 | // $size-6: 1rem
31 | //);
32 |
33 | // METHOD 3: Customize standard Bulma with dark mode
34 | // To enable dark mode with customization, use:
35 | //@use 'bulma/sass' with ( ... ); // Use the same variable format as above
36 |
37 | // For all available variables, see:
38 | // https://bulma.io/documentation/customize/variables/
39 |
40 | @use 'bulma/versions/bulma-no-dark-mode';
41 |
42 | // HEADING STYLES
43 | // These extend Bulma's title classes to regular h1-h6 tags
44 | // You can customize individual heading styles here if needed
45 | h1 {
46 | @extend .title;
47 | @extend .is-1;
48 | }
49 | h2 {
50 | @extend .title;
51 | @extend .is-2;
52 | }
53 | h3 {
54 | @extend .title;
55 | @extend .is-3;
56 | }
57 | h4 {
58 | @extend .title;
59 | @extend .is-4;
60 | }
61 | h5 {
62 | @extend .title;
63 | @extend .is-5;
64 | }
65 | h6 {
66 | @extend .title;
67 | @extend .is-6;
68 | }
69 |
70 | :root {
71 | // Your custom CSS variables
72 | --bulma-body-font-size: 1.2em;
73 | }
74 |
75 | *, *::before, *::after {
76 | box-sizing: border-box;
77 | }
78 |
79 | body {
80 | margin: 0;
81 | font-family: var(
82 | --family-primary,
83 | -apple-system,
84 | BlinkMacSystemFont,
85 | 'Segoe UI',
86 | 'Roboto',
87 | 'Oxygen',
88 | 'Ubuntu',
89 | 'Cantarell',
90 | 'Fira Sans',
91 | 'Droid Sans',
92 | 'Helvetica Neue',
93 | 'Helvetica',
94 | 'Arial',
95 | sans-serif
96 | );
97 | }
98 |
99 | h1,
100 | h2,
101 | h3,
102 | h4,
103 | h5,
104 | h6 {
105 | font-family: var(--family-primary, inherit);
106 | }
107 |
108 | // Overrides for the Apostrophe Admin UI
109 | body {
110 | & [data-apos-refreshable] .navbar.is-fixed-top {
111 | top: 112px;
112 | }
113 |
114 | &[data-apos-generated].has-navbar-fixed-top .main-content {
115 | padding-top: 118px;
116 | }
117 |
118 |
119 | &.has-navbar-fixed-top .apos-admin-bar-spacer {
120 | height: 0 !important;
121 | }
122 |
123 | &:not(.has-navbar-fixed-top) .navbar {
124 | margin-bottom: 0;
125 | }
126 | }
127 |
128 | .apos-area-widget-controls.apos-area-widget-controls--modify,
129 | .apos-area-widget-controls.apos-area-widget-controls {
130 | z-index: 100 !important;
131 | }
132 |
133 | .apos-rich-text-editor__editor {
134 | margin-left: 20px;
135 | }
136 |
137 | // helper for has-backround-transparent
138 | .has-background-transparent {
139 | background-color: transparent;
140 | }
141 |
142 | body [data-apos-area] [data-apos-area] {
143 | margin: 0 0 1.5rem 0;
144 | }
145 |
146 | .rich-text-widget {
147 | *:not(:last-child) {
148 | margin-bottom: 0.75rem
149 | }
150 | }
151 |
152 | .rich-text-widget,
153 | .ProseMirror {
154 | ul {
155 | list-style-type: disc;
156 | }
157 | ol {
158 | list-style-type: decimal;
159 | }
160 | ul, ol {
161 | margin-bottom: 1em;
162 | }
163 | ul ul, ol ul {
164 | list-style-type: circle;
165 | margin-left: 1em;
166 | }
167 | ol ol, ul ol {
168 | list-style-type: lower-latin;
169 | margin-left: 1em;
170 | }
171 | }
172 |
173 | ol.apos-area-widget__breadcrumbs {
174 | list-style-type: none !important;
175 | padding-left: 0;
176 | margin: 0;
177 | }
178 |
--------------------------------------------------------------------------------
/frontend/public/scripts/VideoWidget.js:
--------------------------------------------------------------------------------
1 | /**
2 | * VideoWidget - A custom web component for embedding videos with responsive sizing
3 | *
4 | * This component handles video embedding through ApostropheCMS's oEmbed endpoint,
5 | * which supports multiple video providers (YouTube, Vimeo, etc). While typically
6 | * used with URLs from ApostropheCMS's video widget schema, it can accept any
7 | * standard video sharing URL. The component handles all provider-specific details
8 | * through the oEmbed standard and maintains responsive sizing.
9 | *
10 | * Typical usage (with ApostropheCMS):
11 | * The URL typically comes from the video widget schema data:
12 | *
13 | *
14 | * Direct usage (if needed):
15 | *
16 | *
17 | */
18 | class VideoWidget extends HTMLElement {
19 | constructor() {
20 | super();
21 | this.init();
22 | }
23 |
24 | /**
25 | * Initializes the video widget by fetching oEmbed data and rendering the video
26 | */
27 | async init() {
28 | const videoUrl = this.getAttribute('url');
29 |
30 | if (!videoUrl) {
31 | console.warn('VideoWidget: No URL provided');
32 | return;
33 | }
34 |
35 | try {
36 | this.result = await this.oembed(videoUrl);
37 | this.renderVideo();
38 | } catch (error) {
39 | console.error('VideoWidget initialization failed:', error);
40 | this.innerHTML = `Failed to load video: ${error.message}
`;
41 | }
42 | }
43 |
44 | /**
45 | * Fetches oEmbed data for the given URL using ApostropheCMS's oEmbed endpoint
46 | * @param {string} url - The video URL to fetch oEmbed data for
47 | * @returns {Promise} The oEmbed response data
48 | */
49 | async oembed(url) {
50 | const response = await fetch('/api/v1/@apostrophecms/oembed/query?' + new URLSearchParams({
51 | url
52 | }));
53 | if (response.status >= 400) {
54 | throw new Error(`oEmbed request failed with status: ${response.status}`);
55 | }
56 | return response.json();
57 | }
58 |
59 | /**
60 | * Renders the video iframe with proper responsive sizing
61 | * Uses oEmbed HTML and maintains aspect ratio if dimensions are provided
62 | */
63 | renderVideo() {
64 | // Create temporary container to parse oEmbed HTML
65 | const shaker = document.createElement('div');
66 | shaker.innerHTML = this.result.html;
67 | const inner = shaker.firstChild;
68 |
69 | if (!inner || !(inner instanceof HTMLElement)) {
70 | throw new Error('oEmbed response must contain a valid HTML element');
71 | }
72 |
73 | this.canvasEl = inner;
74 | this.innerHTML = '';
75 |
76 | // Add title attribute to iframe
77 | if (inner instanceof HTMLIFrameElement) {
78 | const title = this.getAttribute('title') || 'Video content';
79 | inner.setAttribute('title', title);
80 | }
81 |
82 | // Remove fixed dimensions to allow responsive sizing
83 | inner.removeAttribute('width');
84 | inner.removeAttribute('height');
85 | this.append(inner);
86 |
87 | // Wait for CSS width to be applied before calculating dimensions
88 | setTimeout(() => {
89 | if (this.result.width && this.result.height) {
90 | inner.style.width = '100%';
91 | this.resizeVideo();
92 | // Maintain aspect ratio on window resize
93 | window.addEventListener('resize', this.resizeHandler.bind(this));
94 | }
95 | // If no dimensions provided, assume oEmbed HTML is already responsive
96 | }, 0);
97 | }
98 |
99 | /**
100 | * Updates video height to maintain aspect ratio based on current width
101 | */
102 | resizeVideo() {
103 | const aspectRatio = this.result.height / this.result.width;
104 | this.canvasEl.style.height = (aspectRatio * this.canvasEl.offsetWidth) + 'px';
105 | }
106 |
107 | /**
108 | * Handles window resize events and cleans up when component is removed
109 | */
110 | resizeHandler() {
111 | if (document.contains(this)) {
112 | this.resizeVideo();
113 | } else {
114 | // Clean up resize listener when component is removed from DOM
115 | window.removeEventListener('resize', this.resizeHandler);
116 | }
117 | }
118 | }
119 |
120 | // Register the web component if it hasn't been registered already
121 | if (!customElements.get('video-widget')) {
122 | console.log('Registering VideoWidget web component');
123 | customElements.define('video-widget', VideoWidget);
124 | } else {
125 | console.log('VideoWidget was already registered');
126 | }
--------------------------------------------------------------------------------
/backend/lib/schema-mixins/link-fields.js:
--------------------------------------------------------------------------------
1 | import colorOptionsHelper from '../helpers/color-options.js';
2 |
3 | export default {
4 | linkText: {
5 | label: 'Link/Button Text',
6 | type: 'string',
7 | def: 'Click Here',
8 | required: true
9 | },
10 | linkType: {
11 | label: 'Link Type',
12 | type: 'select',
13 | required: true,
14 | choices: [
15 | {
16 | label: 'Page',
17 | value: 'page'
18 | },
19 | {
20 | label: 'File',
21 | value: 'file'
22 | },
23 | {
24 | label: 'Custom URL',
25 | value: 'custom'
26 | }
27 | ]
28 | },
29 | _linkPage: {
30 | label: 'Page to Link',
31 | type: 'relationship',
32 | withType: '@apostrophecms/page',
33 | max: 1,
34 | builders: {
35 | project: {
36 | title: 1,
37 | _url: 1
38 | }
39 | },
40 | if: {
41 | linkType: 'page'
42 | },
43 | required: true
44 | },
45 | _linkFile: {
46 | label: 'File to Link',
47 | type: 'relationship',
48 | withType: '@apostrophecms/file',
49 | max: 1,
50 | if: {
51 | linkType: 'file'
52 | },
53 | required: true
54 | },
55 | linkUrl: {
56 | label: 'URL for Custom Link',
57 | type: 'url',
58 | if: {
59 | linkType: 'custom'
60 | },
61 | required: true
62 | },
63 | linkTarget: {
64 | label: 'Open link in new tab?',
65 | type: 'checkboxes',
66 | choices: [
67 | {
68 | label: 'Open in new tab',
69 | value: '_blank'
70 | }
71 | ]
72 | },
73 | linkStyle: {
74 | label: 'Link Style',
75 | type: 'select',
76 | def: 'button',
77 | choices: [
78 | {
79 | label: 'Button',
80 | value: 'button'
81 | },
82 | {
83 | label: 'Text Link',
84 | value: 'text-link'
85 | }
86 | ]
87 | },
88 | buttonStyle: {
89 | type: 'select',
90 | label: 'Button Style',
91 | def: '',
92 | choices: [
93 | { label: 'Solid', value: '' },
94 | { label: 'Outlined', value: 'outlined' },
95 | { label: 'Inverted', value: 'inverted' },
96 | { label: 'Rounded', value: 'rounded' }
97 | ],
98 | if: {
99 | linkStyle: 'button'
100 | }
101 | },
102 | buttonSize: {
103 | label: 'Button Size',
104 | type: 'select',
105 | def: 'large',
106 | choices: [
107 | {
108 | label: 'Small',
109 | value: 'small'
110 | },
111 | {
112 | label: 'Default',
113 | value: ''
114 | },
115 | {
116 | label: 'Normal',
117 | value: 'normal'
118 | },
119 | {
120 | label: 'Medium',
121 | value: 'medium'
122 | },
123 | {
124 | label: 'Large',
125 | value: 'large'
126 | }
127 | ],
128 | if: {
129 | linkStyle: 'button'
130 | }
131 | },
132 | buttonDisabled: {
133 | label: 'Disabled Button?',
134 | type: 'boolean',
135 | if: {
136 | linkStyle: 'button'
137 | },
138 | def: false
139 | },
140 | addIcon: {
141 | label: 'Add Icon?',
142 | type: 'boolean',
143 | if: {
144 | linkStyle: 'button'
145 | },
146 | def: false
147 | },
148 | icon: {
149 | label: 'Icon Name',
150 | type: 'string',
151 | htmlHelp: 'Enter the name of the icon you want to use. For example, "arrow-right" .',
152 | if: {
153 | linkStyle: 'button',
154 | addIcon: true
155 | }
156 | },
157 | iconPosition: {
158 | label: 'Icon Position',
159 | type: 'select',
160 | def: 'left',
161 | choices: [
162 | {
163 | label: 'Left',
164 | value: 'left'
165 | },
166 | {
167 | label: 'Right',
168 | value: 'right'
169 | }
170 | ],
171 | if: {
172 | linkStyle: 'button',
173 | addIcon: true
174 | }
175 | },
176 | buttonAlignment: {
177 | type: 'select',
178 | label: 'Button Alignment',
179 | choices: [
180 | {
181 | label: 'Left',
182 | value: 'left'
183 | },
184 | {
185 | label: 'Center',
186 | value: 'center'
187 | },
188 | {
189 | label: 'Right',
190 | value: 'right'
191 | }
192 | ],
193 | def: 'left',
194 | if: {
195 | linkStyle: 'button'
196 | }
197 | },
198 | buttonColor: {
199 | label: 'Button Color',
200 | type: 'select',
201 | def: 'primary',
202 | choices: colorOptionsHelper.getColorOptions(),
203 | if: {
204 | linkStyle: 'button'
205 | }
206 | }
207 | };
208 |
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/HeroGrid.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 |
11 | import ArticlesFilter from '../../components/ArticlesFilter.astro';
12 |
13 | const {
14 | pieces,
15 | gridColumns = 3,
16 | heroImageClass = 'is-3by2'
17 | } = Astro.props;
18 |
19 | const heroImage = pieces[0]?._heroImage[0];
20 | const heroSrcset = heroImage ? getAttachmentSrcset(heroImage) : '';
21 |
22 | // Get the current category from URL params
23 | const currentCategory = Astro.url.searchParams.get('category') || '';
24 | ---
25 | {pieces[0] && (
26 |
78 |
85 |
86 | {pieces.slice(1).map((article) => {
87 | const cardImage = article?._heroImage[0];
88 | return (
89 |
125 | )}
126 | )}
127 |
128 | )}
129 |
130 |
--------------------------------------------------------------------------------
/frontend/src/widgets/RowsWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * A flexible row-based layout widget for ApostropheCMS content areas.
4 | * Supports multiple column configurations, spacing options, and alignment settings.
5 | *
6 | * @component
7 | * @param {Object} widget - The widget configuration object from ApostropheCMS
8 | * @param {string} [widget.columnLayout='two-equal'] - Column layout configuration:
9 | * - 'single' - Full width single column
10 | * - 'two-equal' - Two equal width columns
11 | * - 'three-equal' - Three equal width columns
12 | * - 'four-equal' - Four equal width columns
13 | * - 'one-third-two-thirds' - 33% | 66% split
14 | * - 'two-thirds-one-third' - 66% | 33% split
15 | * - 'quarter-half-quarter' - 25% | 50% | 25% split
16 | * @param {string} [widget.spacing='normal'] - Space between columns ('none', 'tight', 'normal', 'wide')
17 | * @param {string} [widget.verticalAlignment='top'] - Vertical alignment ('top', 'center', 'bottom')
18 | * @param {string} [widget.horizontalAlignment='left'] - Horizontal alignment ('left', 'center', 'right', 'space-between')
19 | * @param {string} [widget.maxWidth] - Optional maximum width constraint ('768', '960', '1152', '1344')
20 | * @param {Object} widget.columnOneContent - Content for first column
21 | * @param {Object} [widget.columnTwoContent] - Content for second column (layout dependent)
22 | * @param {Object} [widget.columnThreeContent] - Content for third column (layout dependent)
23 | * @param {Object} [widget.columnFourContent] - Content for fourth column (layout dependent)
24 | */
25 |
26 | const { widget } = Astro.props;
27 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
28 |
29 | const layouts = {
30 | 'single': {
31 | classes: ['is-12'],
32 | areas: ['columnOneContent']
33 | },
34 | 'two-equal': {
35 | classes: ['is-12-mobile is-6-tablet', 'is-12-mobile is-6-tablet'],
36 | areas: ['columnOneContent', 'columnTwoContent']
37 | },
38 | 'three-equal': {
39 | classes: ['is-12-mobile is-6-tablet is-4-desktop',
40 | 'is-12-mobile is-6-tablet is-4-desktop',
41 | 'is-12-mobile is-6-tablet is-4-desktop'],
42 | areas: ['columnOneContent', 'columnTwoContent', 'columnThreeContent']
43 | },
44 | 'four-equal': {
45 | classes: ['is-12-mobile is-6-tablet is-3-desktop',
46 | 'is-12-mobile is-6-tablet is-3-desktop',
47 | 'is-12-mobile is-6-tablet is-3-desktop',
48 | 'is-12-mobile is-6-tablet is-3-desktop'],
49 | areas: ['columnOneContent', 'columnTwoContent', 'columnThreeContent', 'columnFourContent']
50 | },
51 | 'one-third-two-thirds': {
52 | classes: ['is-12-mobile is-4-tablet', 'is-12-mobile is-8-tablet'],
53 | areas: ['columnOneContent', 'columnTwoContent']
54 | },
55 | 'two-thirds-one-third': {
56 | classes: ['is-12-mobile is-8-tablet', 'is-12-mobile is-4-tablet'],
57 | areas: ['columnOneContent', 'columnTwoContent']
58 | },
59 | 'quarter-half-quarter': {
60 | classes: ['is-12-mobile is-3-tablet',
61 | 'is-12-mobile is-6-tablet',
62 | 'is-12-mobile is-3-tablet'],
63 | areas: ['columnOneContent', 'columnTwoContent', 'columnThreeContent']
64 | }
65 | };
66 |
67 | // Spacing configurations
68 | const spacingClasses = {
69 | 'none': 'is-gapless',
70 | 'tight': 'is-1',
71 | 'normal': 'is-3',
72 | 'wide': 'is-5'
73 | };
74 |
75 | // Vertical alignment configurations
76 | const verticalAlignmentClasses = {
77 | 'top': 'is-align-items-start',
78 | 'center': 'is-align-items-center',
79 | 'bottom': 'is-align-items-end'
80 | };
81 |
82 | // Horizontal alignment configurations
83 | const horizontalAlignmentClasses = {
84 | 'left': 'is-justify-content-flex-start',
85 | 'center': 'is-justify-content-center',
86 | 'right': 'is-justify-content-flex-end',
87 | 'space-between': 'is-justify-content-space-between'
88 | };
89 |
90 | const currentLayout = layouts[widget.columnLayout || 'two-equal'];
91 | const spacingClass = spacingClasses[widget.spacing || 'normal'];
92 | const verticalAlignClass = verticalAlignmentClasses[widget.verticalAlignment || 'top'];
93 | const horizontalAlignClass = horizontalAlignmentClasses[widget.horizontalAlignment || 'left'];
94 | const maxWidth = widget.maxWidth ? widget.maxWidth : '';
95 |
96 | // Build columns classes
97 | const columnsClasses = [
98 | 'columns',
99 | spacingClass,
100 | verticalAlignClass,
101 | horizontalAlignClass,
102 | maxWidth,
103 | 'mx-auto'
104 | ].filter(Boolean).join(' ');
105 | ---
106 |
107 |
112 |
116 | {currentLayout.areas.map((areaName, index) => (
117 | widget[areaName] && (
118 |
128 | )
129 | ))}
130 |
131 |
132 |
133 |
134 |
140 |
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/ListAside.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 |
11 | import ArticlesFilter from '../../components/ArticlesFilter.astro';
12 |
13 | const {
14 | pieces,
15 | showRelated = true,
16 | sidebarWidth = 4,
17 | showAuthorAvatar = true,
18 | sidebarArea
19 | } = Astro.props;
20 |
21 | const mainWidth = 12 - sidebarWidth;
22 |
23 | // Get the current category from URL params
24 | const currentCategory = Astro.url.searchParams.get('category') || '';
25 | ---
26 |
27 |
28 |
29 | {
30 | pieces.map((article) => (
31 |
32 |
33 |
34 |
37 |
60 |
61 |
{article.excerpt}
62 |
63 |
64 | Read More
65 |
66 | {showRelated && article._related?.length > 0 && (
67 |
68 |
69 | Related Articles:
70 |
71 |
78 |
79 | )}
80 |
81 |
82 | {article?._heroImage?.[0] &&
83 | (() => {
84 | const heroImage = article._heroImage[0];
85 | return (
86 |
87 |
99 |
100 | );
101 | })()}
102 |
103 |
104 |
105 | ))
106 | }
107 |
108 |
109 |
117 | {/* Optional sidebar area content */}
118 | {
119 | sidebarArea && (
120 |
123 | )
124 | }
125 |
126 |
Quick Navigation
127 |
143 |
144 |
145 |
146 |
161 |
--------------------------------------------------------------------------------
/backend/modules/rows-widget/index.js:
--------------------------------------------------------------------------------
1 | import { getWidgetGroups } from '../../lib/helpers/area-widgets.js';
2 |
3 | export default {
4 | extend: '@apostrophecms/widget-type',
5 | options: {
6 | label: 'Rows Layout',
7 | icon: 'view-column-icon',
8 | description: 'Create row and column-based layouts for your content.',
9 | previewImage: 'svg'
10 | },
11 | fields: {
12 | add: {
13 | columnLayout: {
14 | type: 'select',
15 | label: 'Column Layout',
16 | help: 'Choose how to divide your content across columns',
17 | def: 'single',
18 | choices: [
19 | {
20 | label: 'Single Column',
21 | help: 'Adds container around content',
22 | value: 'single'
23 | },
24 | {
25 | label: 'Two Equal Columns (50/50)',
26 | value: 'two-equal'
27 | },
28 | {
29 | label: 'Three Equal Columns (33/33/33)',
30 | value: 'three-equal'
31 | },
32 | {
33 | label: 'Four Equal Columns (25/25/25/25)',
34 | value: 'four-equal'
35 | },
36 | {
37 | label: 'Narrow + Wide (33/66)',
38 | value: 'one-third-two-thirds'
39 | },
40 | {
41 | label: 'Wide + Narrow (66/33)',
42 | value: 'two-thirds-one-third'
43 | },
44 | {
45 | label: 'Narrow + Wide + Narrow (25/50/25)',
46 | value: 'quarter-half-quarter'
47 | }
48 | ]
49 | },
50 | maxWidth: {
51 | type: 'select',
52 | label: 'Maximum Content Width',
53 | choices: [
54 | {
55 | label: 'Full Width',
56 | value: ''
57 | },
58 | {
59 | label: 'Extra Narrow (768px)',
60 | value: 'max-width-768'
61 | },
62 | {
63 | label: 'Narrow (960px)',
64 | value: 'max-width-960'
65 | },
66 | {
67 | label: 'Medium (1152px)',
68 | value: 'max-width-1152'
69 | },
70 | {
71 | label: 'Wide (1344px)',
72 | value: 'max-width-1344'
73 | }
74 | ],
75 | def: ''
76 | },
77 | spacing: {
78 | type: 'select',
79 | label: 'Space Between Columns',
80 | def: 'normal',
81 | choices: [
82 | {
83 | label: 'None',
84 | value: 'none'
85 | },
86 | {
87 | label: 'Tight',
88 | value: 'tight'
89 | },
90 | {
91 | label: 'Normal',
92 | value: 'normal'
93 | },
94 | {
95 | label: 'Wide',
96 | value: 'wide'
97 | }
98 | ]
99 | },
100 | verticalAlignment: {
101 | type: 'select',
102 | label: 'Vertical Alignment',
103 | def: 'top',
104 | choices: [
105 | {
106 | label: 'Top',
107 | value: 'top'
108 | },
109 | {
110 | label: 'Center',
111 | value: 'center'
112 | },
113 | {
114 | label: 'Bottom',
115 | value: 'bottom'
116 | }
117 | ]
118 | },
119 | horizontalAlignment: {
120 | type: 'select',
121 | label: 'Horizontal Alignment',
122 | choices: [
123 | {
124 | label: 'Left',
125 | value: 'left'
126 | },
127 | {
128 | label: 'Center',
129 | value: 'center'
130 | },
131 | {
132 | label: 'Right',
133 | value: 'right'
134 | },
135 | {
136 | label: 'Space Between',
137 | value: 'space-between'
138 | }
139 | ],
140 | def: 'left'
141 | },
142 | columnOneContent: {
143 | type: 'area',
144 | label: 'First Column',
145 | options: getWidgetGroups({
146 | includeLayouts: true,
147 | exclude: [ 'grid-layout' ]
148 | })
149 | },
150 | columnTwoContent: {
151 | type: 'area',
152 | label: 'Second Column',
153 | options: getWidgetGroups({
154 | includeLayouts: true,
155 | exclude: ['grid-layout']
156 | }),
157 | if: {
158 | $or: [
159 | { columnLayout: 'two-equal' },
160 | { columnLayout: 'three-equal' },
161 | { columnLayout: 'four-equal' },
162 | { columnLayout: 'one-third-two-thirds' },
163 | { columnLayout: 'two-thirds-one-third' },
164 | { columnLayout: 'quarter-half-quarter' }
165 | ]
166 | }
167 | },
168 | columnThreeContent: {
169 | type: 'area',
170 | label: 'Third Column',
171 | options: getWidgetGroups({
172 | includeLayouts: true,
173 | exclude: ['grid-layout']
174 | }),
175 | if: {
176 | $or: [
177 | { columnLayout: 'three-equal' },
178 | { columnLayout: 'four-equal' },
179 | { columnLayout: 'quarter-half-quarter' }
180 | ]
181 | }
182 | },
183 | columnFourContent: {
184 | type: 'area',
185 | label: 'Fourth Column',
186 | options: getWidgetGroups({
187 | includeLayouts: true,
188 | exclude: ['grid-layout']
189 | }),
190 | if: {
191 | columnLayout: 'four-equal'
192 | }
193 | }
194 | }
195 | }
196 | };
197 |
--------------------------------------------------------------------------------
/backend/views/layout.html:
--------------------------------------------------------------------------------
1 |
60 |
61 |
62 |
ApostropheCMS Backend for Astro
63 |
64 |
65 |
You are currently viewing the ApostropheCMS backend, used to manage the content for your project. It supplies the Admin UI and in-context editing functionality. The frontend rendering of the website is handled by Astro and is delivered from a separate repository.
66 |
67 |
68 |
69 |
Important Note About Frontend Server
70 |
This status checker only works when the Astro frontend is running in development mode (npm run dev). It will not detect servers running in preview mode (npm run preview) or production mode (npm run serve).
71 |
For local development and content editing, please ensure you're using npm run dev to start the Astro frontend.
72 |
73 |
74 |
75 |
Astro Frontend Status
76 |
Checking Astro frontend status...
77 |
78 |
79 |
80 |
Setting up the Astro Frontend Project
81 |
To view the complete project, you need to set up and run the Astro frontend. Follow these steps:
82 |
83 | Either fork or clone the frontend repository: git clone https://github.com/apostrophecms/astro-frontend.git
84 | Navigate to the frontend directory: cd astro-frontend
85 | Install dependencies: npm install
86 | Export the same APOS_EXTERNAL_FRONT_KEY environment variable you used to start the backend
87 | Start the Astro development server: npm run dev
88 |
89 |
90 |
91 |
94 |
95 |
96 |
152 |
--------------------------------------------------------------------------------
/frontend/src/layouts/article-layouts/ShowFullWidth.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
3 | import {
4 | getAttachmentUrl,
5 | getAttachmentSrcset,
6 | getFocalPoint,
7 | getWidth,
8 | getHeight
9 | } from '../../lib/attachments.js';
10 |
11 | const { article, showAuthorAvatar = true } = Astro.props;
12 |
13 | const iconMap = {
14 | github: 'fab fa-github',
15 | twitter: 'fab fa-x-twitter',
16 | linkedin: 'fab fa-linkedin',
17 | website: 'fas fa-globe'
18 | };
19 |
20 | const authors = article._author;
21 | const authorCount = Array.isArray(authors) ? authors.length : 1;
22 | const primaryAuthor = Array.isArray(authors) ? authors[0] : authors;
23 |
24 | const heroImage = article?._heroImage?.[0];
25 | ---
26 |
27 | {heroImage && (
28 |
29 |
30 |
41 |
42 |
43 | )}
44 |
45 |
46 |
{article.title}
47 |
48 |
53 |
54 |
About the Author{authorCount > 1 && 's'}
55 |
56 |
76 | {primaryAuthor.socialLinks.length > 0 && (
77 |
92 | )}
93 |
96 |
97 | {authorCount > 1 && (
98 |
99 |
Additional Contributors
100 |
101 | {authors.slice(1).map((author) => (
102 |
103 |
104 |
{author.title}
105 |
106 | ))}
107 |
108 |
109 | )}
110 | {article._tags?.length > 0 && (
111 |
112 | {article._tags.map(tag => (
113 | {tag.title}
114 | ))}
115 |
116 | )}
117 | {primaryAuthor._articles?.length > 0 && (
118 |
119 |
Other Articles by {primaryAuthor.title}
120 |
131 |
132 | )}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
--------------------------------------------------------------------------------
/frontend/src/components/Header.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { useSiteConfig } from '../lib/use-site-config.js';
3 |
4 | const { aposData } = Astro.props;
5 | const mainNav = aposData.home?._children || [];
6 | const {
7 | getNavItemClasses,
8 | getDropdownClasses,
9 | renderBranding,
10 | getHeaderClasses,
11 | getHeaderTransparency
12 | } = useSiteConfig(aposData.global);
13 |
14 | function formatNavItem(page) {
15 | if (!page) return null;
16 | return {
17 | title: page.title || '',
18 | url: page._url || '#',
19 | active: page._url === aposData.page?._url,
20 | children: page._children?.map(formatNavItem).filter(Boolean) || []
21 | };
22 | }
23 |
24 | const headerGroup = aposData.global?.headerGroup || {};
25 |
26 | const navMenuClasses = () => {
27 | const classes = [];
28 | const alignment = headerGroup.navAlignment || 'end';
29 | classes.push(`navbar-${alignment}`);
30 | if (headerGroup.headerBackgroundColor) {
31 | classes.push(`has-background-${headerGroup.headerBackgroundColor}`);
32 | }
33 | return classes.join(' ');
34 | };
35 |
36 | const addFixedHeaderClass = () => {
37 | const fixedHeader = headerGroup.headerPosition === 'static' ? false : true;
38 | return fixedHeader.toString();
39 | };
40 |
41 | const navItems = mainNav.map(formatNavItem).filter(Boolean);
42 |
43 | const shouldShowItems = navItems.length > 0;
44 | ---
45 |
46 |
113 |
114 |
141 |
142 |
--------------------------------------------------------------------------------
/frontend/src/widgets/AccordionWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * An accessible accordion widget component for ApostropheCMS content
4 | * @component
5 | * @param {Object} widget - The widget configuration object from ApostropheCMS
6 | * @param {string} [widget.itemBackgroundColor='white'] - Background color of accordion items
7 | * @param {string} [widget.headerAlignment='left'] - Alignment of headers ('left'|'center'|'right')
8 | * @param {boolean} [widget.allowMultipleOpen=false] - Whether multiple accordion items can be open simultaneously
9 | * @param {number} [widget.openIndex=-1] - Index of initially open accordion item (1-based, -1 means all closed)
10 | * @param {Array<{header: string, headerColor: string, content: Object}>} [widget.items=[]] - Accordion items
11 | */
12 |
13 | const { widget } = Astro.props;
14 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
15 |
16 | const {
17 | itemBackgroundColor = 'white',
18 | headerAlignment = 'left',
19 | allowMultipleOpen = false,
20 | openIndex = -1,
21 | items = []
22 | } = widget;
23 |
24 | const convertedIndex = openIndex === -1 ? -1 : openIndex - 1;
25 | ---
26 |
71 |
72 |
128 |
129 |
--------------------------------------------------------------------------------
/frontend/src/widgets/GridLayoutWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | /**
3 | * A customizable grid layout widget component for Astro that integrates with ApostropheCMS
4 | * @component
5 | * @param {Object} props - Component props
6 | * @param {Object} props.widget - The widget configuration object from ApostropheCMS
7 | * @param {string} props.widget._id - Unique identifier for the widget
8 | * @param {('asideMainThree'|'mainAsideThree'|'asideTwoMain'|'twoMainAside'|'headerTwoColFooter'|'featuredThreeGrid'|'magazineLayout'|'contentHub'|'galleryMasonry'|'dashboardLayout'|'productShowcase'|'custom')} props.widget.layoutType - The type of layout to render
9 | * @param {Object} [props.widget.customGrid] - Custom grid configuration when layoutType is 'custom'
10 | * @param {string} [props.widget.maxWidth] - Maximum width constraint for the layout
11 | * @param {string} [props.widget.overrideClass] - Additional CSS classes to apply
12 | * @param {Object} [props.widget.areaStyles] - Custom styling for areas
13 | */
14 |
15 | const { widget } = Astro.props;
16 | const widgetId = widget._id;
17 |
18 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
19 | import { getLayoutAreas } from '../lib/get-layout-areas.js';
20 |
21 | // Constants for grid defaults
22 | const GRID_DEFAULTS = {
23 | COLUMNS: 12,
24 | GAP: '1rem',
25 | PADDING: '1rem',
26 | MARGIN: 'auto',
27 | MOBILE_GAP: '0.5rem',
28 | MOBILE_PADDING: '0.5rem'
29 | };
30 |
31 | // Basic preset configurations for different layout types
32 | const presetConfigs = {
33 | asideMainThree: { rows: 3 },
34 | mainAsideThree: { rows: 3 },
35 | asideTwoMain: { rows: 4 },
36 | twoMainAside: { rows: 4 },
37 | headerTwoColFooter: { rows: 3 },
38 | featuredThreeGrid: { rows: 2 },
39 | magazineLayout: { rows: 3, gap: '1.5rem' },
40 | contentHub: { rows: 5, gap: '2rem' },
41 | galleryMasonry: { rows: 3 },
42 | dashboardLayout: { rows: 2, gap: '1.5rem' },
43 | productShowcase: { rows: 3, gap: '2rem' }
44 | };
45 |
46 | // Helper function to get layout configuration
47 | const getLayoutConfig = (widget) => {
48 | // Our base configuration that applies to all layouts
49 | const defaultConfig = {
50 | columns: GRID_DEFAULTS.COLUMNS,
51 | gap: GRID_DEFAULTS.GAP,
52 | padding: GRID_DEFAULTS.PADDING,
53 | margin: GRID_DEFAULTS.MARGIN,
54 | mobileGap: GRID_DEFAULTS.MOBILE_GAP,
55 | mobilePadding: GRID_DEFAULTS.MOBILE_PADDING
56 | };
57 |
58 | // If we don't have a layout type, return default config
59 | if (!widget?.layoutType) {
60 | console.error('Layout type is required for custom layout widget');
61 | return defaultConfig;
62 | }
63 |
64 | // For custom layouts, merge with custom settings
65 | if (widget.layoutType === 'custom') {
66 | return {
67 | ...defaultConfig,
68 | rows: widget.customGrid.rows,
69 | columns: widget.customGrid.columns,
70 | gap: widget.customGrid.gap,
71 | padding: widget.customGrid.padding,
72 | margin: widget.customGrid.margin
73 | };
74 | }
75 |
76 | // For preset layouts, merge with preset config
77 | return {
78 | ...defaultConfig,
79 | ...(presetConfigs[widget.layoutType] || { rows: 1 })
80 | };
81 | };
82 |
83 | // Get the areas and configuration
84 | const areas = getLayoutAreas(widget);
85 | const config = getLayoutConfig(widget);
86 |
87 | // Build classes for the layout
88 | const layoutClasses = [
89 | 'custom-layout-widget',
90 | `custom-layout-widget-${widgetId}`,
91 | `layout-type-${widget.layoutType}`,
92 | widget.overrideClass,
93 | widget.maxWidth
94 | ].filter(Boolean).join(' ');
95 | ---
96 |
97 |
126 |
127 |
180 |
181 |
--------------------------------------------------------------------------------
/backend/lib/schema-mixins/card-fields.js:
--------------------------------------------------------------------------------
1 | import colorOptionsHelper from '../helpers/color-options.js';
2 | import textOptionsHelper from '../helpers/typography-options.js';
3 |
4 | export const cardFields = {
5 | cardType: {
6 | type: 'select',
7 | label: 'Card Type',
8 | choices: [
9 | {
10 | label: 'Basic Card',
11 | value: 'basic'
12 | },
13 | {
14 | label: 'Image Card',
15 | value: 'image'
16 | },
17 | {
18 | label: 'Image Overlay',
19 | value: 'image-overlay'
20 | },
21 | {
22 | label: 'Media Card',
23 | value: 'media',
24 | help: 'Features a main image with author/profile section below'
25 | }
26 | ],
27 | required: true,
28 | def: 'basic'
29 | },
30 | minHeight: {
31 | type: 'select',
32 | label: 'Card Height',
33 | choices: [
34 | { label: 'Auto', value: '' },
35 | { label: 'Small', value: 'is-small' },
36 | { label: 'Medium', value: 'is-medium' },
37 | { label: 'Large', value: 'is-large' }
38 | ],
39 | def: ''
40 | },
41 | _mainImage: {
42 | type: 'relationship',
43 | label: 'Main Image',
44 | withType: '@apostrophecms/image',
45 | max: 1,
46 | if: {
47 | $or: [
48 | { cardType: 'image' },
49 | { cardType: 'image-overlay' },
50 | { cardType: 'media' }
51 | ]
52 | }
53 | },
54 | useImageRatio: {
55 | type: 'boolean',
56 | label: 'Force Image Ratio?',
57 | def: false,
58 | if: {
59 | $or: [
60 | { cardType: 'image' },
61 | { cardType: 'image-overlay' },
62 | { cardType: 'media' }
63 | ]
64 | }
65 | },
66 | imageRatio: {
67 | type: 'select',
68 | label: 'Image Ratio',
69 | choices: [
70 | { label: 'Square (1:1)', value: 'is-1by1' },
71 | { label: '4:3', value: 'is-4by3' },
72 | { label: '3:2', value: 'is-3by2' },
73 | { label: '16:9', value: 'is-16by9' }
74 | ],
75 | if: {
76 | useImageRatio: true,
77 | $or: [
78 | { cardType: 'image' },
79 | { cardType: 'image-overlay' },
80 | { cardType: 'media' }
81 | ]
82 | }
83 | },
84 | _avatar: {
85 | type: 'relationship',
86 | label: 'Profile Image',
87 | withType: '@apostrophecms/image',
88 | max: 1,
89 | if: {
90 | cardType: 'media'
91 | }
92 | },
93 | cardTitle: {
94 | type: 'string',
95 | label: 'Card Title',
96 | required: true
97 | },
98 | subtitle: {
99 | type: 'string',
100 | label: 'Subtitle'
101 | },
102 | titleSize: {
103 | type: 'select',
104 | label: 'Title Size',
105 | choices: [
106 | { label: 'Normal', value: 'is-4' },
107 | { label: 'Large', value: 'is-3' },
108 | { label: 'Small', value: 'is-5' }
109 | ],
110 | def: 'is-4'
111 | },
112 | titleColor: {
113 | type: 'select',
114 | label: 'Title Color',
115 | choices: colorOptionsHelper.getColorOptions().filter(color =>
116 | color.value !== 'transparent'
117 | )
118 | },
119 | content: {
120 | type: 'area',
121 | label: 'Card Content',
122 | options: {
123 | widgets: {
124 | '@apostrophecms/rich-text': {},
125 | link: {}
126 | }
127 | }
128 | },
129 | contentColor: {
130 | type: 'select',
131 | label: 'Content Text Color',
132 | choices: colorOptionsHelper.getColorOptions().filter(color =>
133 | color.value !== 'transparent'
134 | ),
135 | def: 'black'
136 | },
137 | headerAlignment: {
138 | type: 'select',
139 | label: 'Title & Subtitle Alignment',
140 | choices: [
141 | { label: 'Left', value: 'has-text-left' },
142 | { label: 'Center', value: 'has-text-centered' },
143 | { label: 'Right', value: 'has-text-right' }
144 | ],
145 | def: 'has-text-left'
146 | },
147 | contentAlignment: {
148 | type: 'select',
149 | label: 'Content Alignment',
150 | choices: [
151 | { label: 'Left', value: 'has-text-left' },
152 | { label: 'Center', value: 'has-text-centered' },
153 | { label: 'Right', value: 'has-text-right' }
154 | ],
155 | def: 'has-text-left'
156 | },
157 | backgroundColor: {
158 | type: 'select',
159 | label: 'Background Color',
160 | choices: colorOptionsHelper.getColorOptions()
161 | },
162 | showOverlay: {
163 | type: 'boolean',
164 | label: 'Show Overlay on Image',
165 | def: false,
166 | if: {
167 | cardType: 'image' // Only show this field when cardType is 'image'
168 | }
169 | },
170 | overlayColor: {
171 | type: 'select',
172 | label: 'Overlay Color',
173 | choices: colorOptionsHelper.getColorOptions(),
174 | if: {
175 | $or: [
176 | { cardType: 'image-overlay' },
177 | { showOverlay: true }
178 | ]
179 | }
180 | },
181 | overlayOpacity: {
182 | type: 'select',
183 | label: 'Overlay Opacity',
184 | choices: [
185 | { label: 'Very light', value: '20%' },
186 | { label: 'Light', value: '30%' },
187 | { label: 'Medium', value: '40%' },
188 | { label: 'Dark', value: '50%' },
189 | { label: 'Very dark', value: '60%' }
190 | ],
191 | if: {
192 | $or: [
193 | { cardType: 'image-overlay' },
194 | { showOverlay: true }
195 | ]
196 | }
197 | },
198 | hasFooter: {
199 | type: 'boolean',
200 | label: 'Add Footer?',
201 | def: false
202 | },
203 | footerContent: {
204 | type: 'area',
205 | label: 'Footer Content',
206 | if: {
207 | hasFooter: true
208 | },
209 | options: {
210 | widgets: {
211 | '@apostrophecms/rich-text': {},
212 | link: {}
213 | }
214 | }
215 | },
216 | addFooterBorder: {
217 | type: 'boolean',
218 | label: 'Footer Border',
219 | def: false,
220 | if: {
221 | hasFooter: true
222 | }
223 | },
224 | footerBorderWidth: {
225 | type: 'select',
226 | label: 'Border Width',
227 | choices: [
228 | { label: 'Thin', value: 'thin' },
229 | { label: 'Medium', value: 'medium' },
230 | { label: 'Thick', value: 'thick' }
231 | ],
232 | def: 'thin',
233 | if: {
234 | addFooterBorder: true // Only show if addBorder is enabled
235 | }
236 | },
237 | footerBorderColor: {
238 | type: 'select',
239 | label: 'Border Color',
240 | choices: colorOptionsHelper.getColorOptions(),
241 | if: {
242 | addFooterBorder: true // Only show if addBorder is enabled
243 | }
244 | }
245 | };
246 |
247 | export const cardGroups = {
248 | basics: {
249 | label: 'Basic Settings',
250 | fields: ['cardType']
251 | },
252 | images: {
253 | label: 'Images',
254 | fields: ['_mainImage', 'useImageRatio', 'imageRatio', '_avatar']
255 | },
256 | content: {
257 | label: 'Content',
258 | fields: ['cardTitle', 'subtitle', 'headerAlignment', 'titleSize', 'content', 'contentAlignment']
259 | },
260 | styling: {
261 | label: 'Styling',
262 | fields: [
263 | 'minHeight',
264 | 'titleColor',
265 | 'contentColor',
266 | 'backgroundColor',
267 | 'showOverlay',
268 | 'overlayColor',
269 | 'overlayOpacity'
270 | ]
271 | },
272 | footer: {
273 | label: 'Footer',
274 | fields: ['hasFooter', 'footerContent', 'addFooterBorder', 'footerBorderWidth', 'footerBorderColor']
275 | }
276 | };
277 |
--------------------------------------------------------------------------------
/backend/lib/schema-mixins/hero-fields.js:
--------------------------------------------------------------------------------
1 | import colorOptionsHelper from '../../lib/helpers/color-options.js';
2 |
3 | export default {
4 | layout: {
5 | type: 'select',
6 | label: 'Layout Style',
7 | def: 'full',
8 | choices: [
9 | {
10 | label: 'Full Width',
11 | value: 'full'
12 | },
13 | {
14 | label: 'Split Content',
15 | value: 'split'
16 | }
17 | ]
18 | },
19 | splitSide: {
20 | type: 'select',
21 | label: 'Content Position (Split Layout)',
22 | def: 'left',
23 | choices: [
24 | {
25 | label: 'Left Side',
26 | value: 'left'
27 | },
28 | {
29 | label: 'Right Side',
30 | value: 'right'
31 | }
32 | ],
33 | if: {
34 | layout: 'split'
35 | }
36 | },
37 | background: {
38 | type: 'select',
39 | label: 'Background Type',
40 | def: 'image',
41 | choices: [
42 | {
43 | label: 'Color',
44 | value: 'color'
45 | },
46 | {
47 | label: 'Image',
48 | value: 'image'
49 | },
50 | {
51 | label: 'Video',
52 | value: 'video'
53 | }
54 | ]
55 | },
56 | backgroundColorType: {
57 | type: 'select',
58 | label: 'Background Color Type',
59 | choices: [
60 | {
61 | label: 'Solid',
62 | value: 'solid'
63 | },
64 | {
65 | label: 'Gradient',
66 | value: 'gradient'
67 | }
68 | ],
69 | if: {
70 | background: 'color'
71 | }
72 | },
73 | mainColor: {
74 | type: 'select',
75 | label: 'Main Background Color',
76 | choices: colorOptionsHelper.getColorOptions().filter(color =>
77 | color.value !== 'transparent'
78 | ),
79 | if: {
80 | background: 'color'
81 | }
82 | },
83 | secondaryColor: {
84 | type: 'select',
85 | label: 'Secondary Background Color',
86 | choices: colorOptionsHelper.getColorOptions().filter(color =>
87 | color.value !== 'transparent'
88 | ),
89 | if: {
90 | backgroundColorType: 'gradient',
91 | background: 'color'
92 | }
93 | },
94 | gradientAngle: {
95 | type: 'select',
96 | label: 'Gradient Angle',
97 | choices: [
98 | { label: 'To Right', value: '90deg' },
99 | { label: 'To Bottom Right', value: '135deg' },
100 | { label: 'To Bottom', value: '180deg' },
101 | { label: 'To Bottom Left', value: '225deg' },
102 | { label: 'To Left', value: '270deg' }
103 | ],
104 | if: {
105 | backgroundColorType: 'gradient',
106 | background: 'color'
107 | }
108 | },
109 | _backgroundImage: {
110 | type: 'relationship',
111 | label: 'Background Image',
112 | withType: '@apostrophecms/image',
113 | max: 1,
114 | required: true,
115 | if: {
116 | background: 'image'
117 | }
118 | },
119 | imagePosition: {
120 | type: 'select',
121 | label: 'Image Position',
122 | choices: [
123 | {
124 | label: 'Top',
125 | value: 'top'
126 | },
127 | {
128 | label: 'Center',
129 | value: 'center'
130 | },
131 | {
132 | label: 'Bottom',
133 | value: 'bottom'
134 | }
135 | ],
136 | def: 'center',
137 | if: {
138 | background: 'image'
139 | }
140 | },
141 | videoBackground: {
142 | type: 'attachment',
143 | label: 'Background Video',
144 | help: 'Upload an MP4 (recommended) or WebM file. For best performance, keep file size under 10MB. Animated GIFs are also supported but not recommended due to file size. Recommended dimensions: 1920x1080.',
145 | fileGroup: 'videos',
146 | max: 1,
147 | if: {
148 | background: 'video'
149 | }
150 | },
151 | videoBackgroundMobile: {
152 | type: 'attachment',
153 | label: 'Mobile Background Video (Optional)',
154 | help: 'If provided, this image will be used on mobile devices instead of the main background video',
155 | fileGroup: 'images',
156 | max: 1,
157 | if: {
158 | background: 'video'
159 | }
160 | },
161 | enableOverlay: {
162 | type: 'boolean',
163 | label: 'Enable Overlay',
164 | help: 'Add a semi-transparent overlay to the background image or video',
165 | def: true,
166 | if: {
167 | $or: [
168 | { background: 'image' },
169 | { background: 'video' }
170 | ]
171 | }
172 | },
173 | overlayColor: {
174 | type: 'select',
175 | label: 'Overlay Color',
176 | choices: colorOptionsHelper.getColorOptions().filter(color =>
177 | color.value !== 'transparent'
178 | ),
179 | if: {
180 | enableOverlay: true
181 | }
182 | },
183 | overlayOpacity: {
184 | type: 'select',
185 | label: 'Overlay Opacity',
186 | choices: [
187 | { label: 'Very light', value: '20%' },
188 | { label: 'Light', value: '30%' },
189 | { label: 'Medium', value: '40%' },
190 | { label: 'Dark', value: '50%' },
191 | { label: 'Very dark', value: '60%' }
192 | ],
193 | if: {
194 | enableOverlay: true
195 | }
196 | },
197 | height: {
198 | type: 'select',
199 | label: 'Hero Height',
200 | def: 'medium',
201 | choices: [
202 | {
203 | label: 'Small (400px)',
204 | value: 'small'
205 | },
206 | {
207 | label: 'Medium (600px)',
208 | value: 'medium'
209 | },
210 | {
211 | label: 'Large (800px)',
212 | value: 'large'
213 | },
214 | {
215 | label: 'Full Viewport',
216 | value: 'fullheight'
217 | }
218 | ]
219 | },
220 | contentAlignment: {
221 | type: 'select',
222 | label: 'Content Alignment',
223 | def: 'center',
224 | choices: [
225 | {
226 | label: 'Left',
227 | value: 'left'
228 | },
229 | {
230 | label: 'Center',
231 | value: 'center'
232 | },
233 | {
234 | label: 'Right',
235 | value: 'right'
236 | }
237 | ]
238 | },
239 | mainContent: {
240 | type: 'object',
241 | label: 'Main Content',
242 | fields: {
243 | add: {
244 | pretitle: {
245 | type: 'string',
246 | label: 'Pre-title Text',
247 | help: 'Optional text above the main title'
248 | },
249 | pretitleColor: {
250 | type: 'select',
251 | label: 'Pretitle Color',
252 | choices: colorOptionsHelper.getColorOptions().filter(color =>
253 | color.value !== 'transparent'
254 | )
255 | },
256 | title: {
257 | type: 'string',
258 | label: 'Main Title',
259 | required: true
260 | },
261 | titleColor: {
262 | type: 'select',
263 | label: 'Title Color',
264 | choices: colorOptionsHelper.getColorOptions().filter(color =>
265 | color.value !== 'transparent'
266 | )
267 | },
268 | subtitle: {
269 | type: 'string',
270 | label: 'Subtitle',
271 | textarea: true
272 | },
273 | subtitleColor: {
274 | type: 'select',
275 | label: 'Subtitle Color',
276 | choices: colorOptionsHelper.getColorOptions().filter(color =>
277 | color.value !== 'transparent'
278 | )
279 | }
280 | }
281 | }
282 | },
283 | callToAction: {
284 | type: 'area',
285 | label: 'Call-to-Action Links',
286 | options: {
287 | widgets: {
288 | link: {}
289 | },
290 | max: 2
291 | }
292 | }
293 | };
294 |
--------------------------------------------------------------------------------
/frontend/src/lib/attachments.js:
--------------------------------------------------------------------------------
1 | const MISSING_ATTACHMENT_URL = '/images/missing-icon.svg';
2 |
3 | /**
4 | * Get the actual attachment object from either a full image object or direct attachment
5 | * @param {Object} attachmentObject - Either a full image object or direct attachment
6 | * @returns {Object|null} The attachment object
7 | */
8 | function getAttachment(attachmentObject) {
9 | if (!attachmentObject) return null;
10 |
11 | // If it's a full image object (has _fields), get its attachment
12 | if (attachmentObject._fields) {
13 | return attachmentObject.attachment;
14 | }
15 |
16 | // If it's already an attachment or has nested attachment
17 | return attachmentObject.attachment || attachmentObject;
18 | }
19 |
20 | /**
21 | * Check if attachment has multiple size variants
22 | * @param {Object} attachmentObject - Either a full image object or direct attachment
23 | * @returns {boolean} True if the attachment has multiple sizes
24 | */
25 | function isSized(attachmentObject) {
26 | const attachment = getAttachment(attachmentObject);
27 | if (!attachment) return false;
28 |
29 | if (attachment._urls && typeof attachment._urls === 'object') {
30 | return Object.keys(attachment._urls).length > 1;
31 | }
32 |
33 | return false;
34 | }
35 |
36 | /**
37 | * Get focal point coordinates from attachment or image, or return default value if invalid
38 | * @param {Object} attachmentObject - Either a full image object or direct attachment
39 | * @param {string} [defaultValue='center center'] - Default value to return if no valid focal point
40 | * @returns {string} String with focal point for styling (e.g., "50% 50%") or default value if invalid
41 | */
42 | function getFocalPoint(attachmentObject, defaultValue = 'center center') {
43 | if (!attachmentObject) return defaultValue;
44 |
45 | // Check _fields if it's from a relationship
46 | if (attachmentObject._fields &&
47 | typeof attachmentObject._fields.x === 'number' &&
48 | attachmentObject._fields.x !== null &&
49 | typeof attachmentObject._fields.y === 'number' &&
50 | attachmentObject._fields.y !== null) {
51 | return `${attachmentObject._fields.x}% ${attachmentObject._fields.y}%`;
52 | }
53 |
54 | // Check attachment object directly if it's a direct attachment
55 | const attachment = getAttachment(attachmentObject);
56 | if (attachment &&
57 | typeof attachment.x === 'number' &&
58 | attachment.x !== null &&
59 | typeof attachment.y === 'number' &&
60 | attachment.y !== null) {
61 | return `${attachment.x}% ${attachment.y}%`;
62 | }
63 |
64 | return defaultValue;
65 | }
66 |
67 | /**
68 | * Get the width from the image object, using crop dimensions if available,
69 | * otherwise falling back to original image dimensions
70 | * @param {object} imageObject - Image object from ApostropheCMS
71 | * @returns {number|undefined} The width of the image
72 | */
73 | function getWidth(imageObject) {
74 | // Use cropped width from _fields if available
75 | if (imageObject?._fields?.width !== undefined && imageObject._fields.width !== null) {
76 | return imageObject._fields.width;
77 | }
78 | // Fall back to original image width
79 | return imageObject?.attachment?.width;
80 | }
81 |
82 | /**
83 | * Get the height from the image object, using crop dimensions if available,
84 | * otherwise falling back to original image dimensions
85 | * @param {object} imageObject - Image object from ApostropheCMS
86 | * @returns {number|undefined} The height of the image
87 | */
88 | function getHeight(imageObject) {
89 | // Use cropped height from _fields if available
90 | if (imageObject?._fields?.height !== undefined && imageObject._fields.height !== null) {
91 | return imageObject._fields.height;
92 | }
93 | // Fall back to original image height
94 | return imageObject?.attachment?.height;
95 | }
96 |
97 | /**
98 | * Get the crop parameters from the image object's _fields
99 | * @param {Object} imageObject - The full image object from ApostropheCMS
100 | * @returns {Object|null} The crop parameters or null if no crop exists
101 | */
102 | function getCrop(imageObject) {
103 | // Check for crop parameters in _fields
104 | if (imageObject?._fields &&
105 | typeof imageObject._fields.left === 'number' &&
106 | typeof imageObject._fields.top === 'number' &&
107 | typeof imageObject._fields.width === 'number' &&
108 | typeof imageObject._fields.height === 'number') {
109 | return {
110 | left: imageObject._fields.left,
111 | top: imageObject._fields.top,
112 | width: imageObject._fields.width,
113 | height: imageObject._fields.height
114 | };
115 | }
116 |
117 | return null;
118 | }
119 |
120 | /**
121 | * Build the URL for an attachment with crop parameters and size
122 | * @param {string} baseUrl - The base URL for the attachment
123 | * @param {Object} crop - The crop parameters object
124 | * @param {string} [size] - The size variant name
125 | * @param {string} extension - The file extension
126 | * @returns {string} The complete URL with crop parameters
127 | */
128 | function buildAttachmentUrl(baseUrl, crop, size, extension) {
129 | let url = baseUrl;
130 |
131 | // Add crop parameters if they exist
132 | if (crop) {
133 | url += `.${crop.left}.${crop.top}.${crop.width}.${crop.height}`;
134 | }
135 |
136 | // Add size if specified
137 | if (size && size !== 'original') {
138 | url += `.${size}`;
139 | }
140 |
141 | // Add extension
142 | url += `.${extension}`;
143 |
144 | return url;
145 | }
146 |
147 | /**
148 | * Get URL for an attachment with optional size
149 | * @param {Object} imageObject - The full image object from ApostropheCMS
150 | * @param {Object} [options={}] - Options object
151 | * @param {string} [options.size] - Size variant ('one-sixth', 'one-third', 'one-half', 'two-thirds', 'full', 'max', 'original')
152 | * @returns {string} The URL for the attachment
153 | */
154 | export function getAttachmentUrl(imageObject, options = {}) {
155 | const attachment = getAttachment(imageObject);
156 |
157 | if (!attachment) {
158 | console.warn('Template warning: Missing attachment, using fallback icon');
159 | return MISSING_ATTACHMENT_URL;
160 | }
161 |
162 | // Get the requested size or default to 'full'
163 | const size = options.size || 'two-thirds';
164 |
165 | // Check if we're in the just-edited state (has uncropped URLs)
166 | if (attachment._urls?.uncropped) {
167 | // During the just-edited state, the main _urls already contain the crop parameters
168 | return attachment._urls[size] || attachment._urls.original;
169 | }
170 |
171 | // Get crop parameters from the image object's _fields
172 | const crop = getCrop(imageObject);
173 |
174 | // If we have _urls and no crop, use the pre-generated URL
175 | if (attachment._urls && !crop) {
176 | return attachment._urls[size] || attachment._urls.original;
177 | }
178 |
179 | // Derive the base URL path from _urls if available
180 | let baseUrl;
181 | if (attachment._urls?.original) {
182 | // Remove the extension from the original URL to get the base path
183 | baseUrl = attachment._urls.original.replace(`.${attachment.extension}`, '');
184 | }
185 |
186 | // Build the complete URL with crop parameters and size
187 | return buildAttachmentUrl(baseUrl, crop, size, attachment.extension);
188 | }
189 |
190 | /**
191 | * Generate a srcset for an image attachment
192 | * @param {Object} attachmentObject - Either a full image object or direct attachment
193 | * @param {Object} [options] - Options for generating the srcset
194 | * @param {Array} [options.sizes] - Array of custom size objects to override the default sizes
195 | * @param {string} options.sizes[].name - The name of the size (e.g., 'small', 'medium')
196 | * @param {number} options.sizes[].width - The width of the image for this size
197 | * @param {number} [options.sizes[].height] - The height of the image for this size (optional)
198 | * @returns {string} The srcset string
199 | */
200 | export function getAttachmentSrcset(attachmentObject, options = {}) {
201 | if (!attachmentObject || !isSized(attachmentObject)) {
202 | return '';
203 | }
204 |
205 | const defaultSizes = [
206 | { name: 'one-sixth', width: 190, height: 350 },
207 | { name: 'one-third', width: 380, height: 700 },
208 | { name: 'one-half', width: 570, height: 700 },
209 | { name: 'two-thirds', width: 760, height: 760 },
210 | { name: 'full', width: 1140, height: 1140 },
211 | { name: 'max', width: 1600, height: 1600}
212 | ];
213 |
214 | const sizes = options.sizes || defaultSizes;
215 |
216 | return sizes
217 | .map(size => `${getAttachmentUrl(attachmentObject, { ...options, size: size.name })} ${size.width}w`)
218 | .join(', ');
219 | }
220 |
221 | // Export the helper functions for use in components
222 | export {
223 | getFocalPoint,
224 | getWidth,
225 | getHeight
226 | };
--------------------------------------------------------------------------------
/frontend/src/components/Footer.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { useSiteConfig } from '../lib/use-site-config.js';
3 |
4 | const { aposData } = Astro.props;
5 | const { footerGroup: footer = {}, brandingGroup: branding = {} } =
6 | aposData.global || {};
7 | const currentYear = new Date().getFullYear();
8 | const footerNav = aposData.home?._children || [];
9 |
10 | // Social platform icons mapping
11 | const platformIcons = {
12 | facebook: 'facebook',
13 | twitter: 'x-twitter',
14 | instagram: 'instagram',
15 | linkedin: 'linkedin',
16 | youtube: 'youtube',
17 | github: 'github',
18 | tiktok: 'tiktok',
19 | pinterest: 'pinterest',
20 | discord: 'discord',
21 | mastodon: 'mastodon'
22 | } as const;
23 |
24 | // Get theme-based classes
25 | const footerClasses = [
26 | 'footer-modern',
27 | footer.footerBackgroundColor
28 | ? `has-background-${footer.footerBackgroundColor}`
29 | : '',
30 | footer.footerTextColor ? `has-text-${footer.footerTextColor}` : '',
31 | footer.footerLayout ? `layout-${footer.footerLayout}` : 'layout-grid'
32 | ]
33 | .filter(Boolean)
34 | .join(' ');
35 |
36 | const linkClasses = [
37 | `has-text-${footer.footerLinkColor || 'grey-light'}`,
38 | 'hover-fade'
39 | ].join(' ');
40 |
41 | // Extract footer sections
42 | const footerSections = footer.footerSections || [];
43 |
44 | // Get link attributes for external links
45 | const getLinkAttributes = (url: string, openInNewTab?: boolean) => ({
46 | ...(openInNewTab || url.startsWith('http')
47 | ? {
48 | target: '_blank',
49 | rel: 'noopener noreferrer'
50 | }
51 | : {})
52 | });
53 |
54 | const socialPosition = footer.socialPosition || 'top';
55 | const { renderBranding } = useSiteConfig(aposData.global);
56 | ---
57 |
58 |
188 |
189 |
280 |
--------------------------------------------------------------------------------
/backend/modules/link-widget/public/preview.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Bitmap
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------