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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apphelper/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# @churchapps/apphelper

## 0.15.0

### Minor Changes

- 39e47d3: Image craft + responsive image performance for the website builder.

apphelper:

- New pluggable image-optimizer seam (`setImageOptimizer` / `responsiveImgProps` / `optimizedBackgroundImage`). Default is identity, so non-Next hosts (the Vite editor) render plain `<img>` unchanged; B1App registers a Next.js `getImageProps`-backed optimizer to emit `srcset`/`sizes` and WebP/AVIF backgrounds.
- Content images (`image`, `card`, `textWithPhoto`, `logo`) now route their `src` through the seam; `logo` also gains the missing `loading="lazy" decoding="async"`.
- `BoxElement` image backgrounds get optimized loading, a `focalPoint` (`background-position`), and an opt-in tint overlay (`.boxBG:before`, driven by `--overlay-color` / `--overlay-opacity`, default off).

helpers:

- Documented the new `box` answer keys (`focalPoint`, `overlayColor`, `backgroundOpacity`) in `ElementTypes`.

## 0.14.0

### Minor Changes
Expand Down
4 changes: 2 additions & 2 deletions apphelper/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@churchapps/apphelper",
"version": "0.14.0",
"version": "0.15.0",
"description": "Library of helper functions, components, and feature modules (donations/forms/login/markdown/website) for React and NextJS ChurchApps",
"type": "module",
"main": "dist/index.js",
Expand Down Expand Up @@ -138,7 +138,7 @@
"slug": "^11.0.0"
},
"devDependencies": {
"@churchapps/helpers": "^1.7.1",
"@churchapps/helpers": "^1.8.1",
"@eslint/js": "^10.0.1",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^7.9.0",
Expand Down
18 changes: 12 additions & 6 deletions apphelper/src/website/components/elementTypes/BoxElement.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { CSSProperties } from "react";
import { ElementInterface, SectionInterface } from "../../helpers";
import { ElementInterface, SectionInterface, optimizedBackgroundImage } from "../../helpers";
import { DroppableArea } from "../admin/DroppableArea";
import { Element } from "../Element";
import { ApiHelper } from "../../..";
Expand Down Expand Up @@ -37,12 +37,17 @@ export function BoxElement(props: Props) {
};


const isImageBg = props.element.answers?.background?.indexOf("/") > -1;

const getStyle = () => {
let result: CSSProperties = { };
if (props.element.answers?.background?.indexOf("/") > -1) {
result = { backgroundImage: "url('" + props.element.answers?.background + "')" };
} else {
result = { background: props.element.answers?.background };
const result: CSSProperties = isImageBg
? { backgroundImage: optimizedBackgroundImage(props.element.answers?.background) }
: { background: props.element.answers?.background };
if (isImageBg) {
if (props.element.answers?.focalPoint) result.backgroundPosition = props.element.answers.focalPoint;
// overlay defaults off (opacity 0) so existing image boxes don't darken
(result as any)["--overlay-color"] = props.element.answers?.overlayColor || "#000";
(result as any)["--overlay-opacity"] = props.element.answers?.backgroundOpacity || "0";
}
if (props.element.answers?.textColor?.startsWith("var(")) result.color = props.element.answers?.textColor;
result.padding = 15;
Expand All @@ -54,6 +59,7 @@ export function BoxElement(props: Props) {

const getClass = () => {
let result = "elBox";
if (isImageBg) result += " boxBG";
let hc = props.element.answers?.headingColor;
if (hc) {
hc = hc.replace("var(--", "").replace(")", "");
Expand Down
6 changes: 4 additions & 2 deletions apphelper/src/website/components/elementTypes/CardElement.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from "react";
import { ElementInterface, SectionInterface } from "../../helpers";
import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";
import { HtmlPreview } from "./HtmlPreview";
import { Card, CardContent } from "@mui/material";

const IMG_SIZES = "(max-width:768px) 100vw, 33vw";


interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; }

Expand All @@ -20,7 +22,7 @@ export const CardElement: React.FC<Props> = (props) => {

let photoContent = <></>;
if (props.element.answers?.photo) {
const photo = <img src={props.element.answers?.photo || "about:blank"} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 3 }} loading="lazy" decoding="async" />;
const photo = <img {...responsiveImgProps(props.element.answers?.photo, IMG_SIZES)} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 3 }} loading="lazy" decoding="async" />;
if (props.element.answers?.url) photoContent = (<a href={props.element.answers?.url}>{photo}</a>);
else photoContent = (photo);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { CSSProperties, useCallback, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import { ElementInterface, SectionInterface } from "../../helpers";
import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";

const IMG_SIZES = "(max-width:768px) 100vw, 66vw";

interface Props {
element: ElementInterface;
Expand Down Expand Up @@ -63,7 +65,7 @@ export const ImageElement = ({ element }: Props) => {
if (imageUrl) {
const imgTag = (
<img
src={imageUrl}
{...responsiveImgProps(imageUrl, IMG_SIZES)}
alt={element.answers?.photoAlt || ""}
className={imageClassName}
id={"el-" + element.id}
Expand Down
6 changes: 4 additions & 2 deletions apphelper/src/website/components/elementTypes/LogoElement.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { ElementInterface } from "../../helpers";
import { ElementInterface, responsiveImgProps } from "../../helpers";
import { AppearanceHelper } from "../../..";

interface Props { element: ElementInterface; churchSettings: any; textColor: string; }
Expand All @@ -14,11 +14,13 @@ export const LogoElement: React.FC<Props> = (props) => {

const photo = (
<img
src={logoUrl}
{...responsiveImgProps(logoUrl)}
alt={props.element.answers?.photoAlt || ""}
className="img-fluid"
id={"el-" + props.element.id}
style={{ maxWidth: "100%", height: "auto", display: "block" }}
loading="lazy"
decoding="async"
/>
);
const photoContent = (props.element.answers?.url) ? <a href={props.element.answers?.url}>{photo}</a> : photo;
Expand Down
12 changes: 7 additions & 5 deletions apphelper/src/website/components/elementTypes/TextWithPhoto.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ElementInterface, SectionInterface } from "../../helpers";
import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";
import { Grid } from "@mui/material";
import { HtmlPreview } from "./HtmlPreview";

const IMG_SIZES = "(max-width:768px) 100vw, 33vw";

interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; }

export const TextWithPhoto: React.FC<Props> = props => {
Expand All @@ -21,7 +23,7 @@ export const TextWithPhoto: React.FC<Props> = props => {
result = (
<Grid container columnSpacing={3}>
<Grid size={{ md: 4, xs: 12 }}>
<img src={props.element.answers?.photo || "about:blank"} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
<img {...responsiveImgProps(props.element.answers?.photo, IMG_SIZES)} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
</Grid>
<Grid size={{ md: 8, xs: 12 }}>
{textComponent}
Expand All @@ -36,7 +38,7 @@ export const TextWithPhoto: React.FC<Props> = props => {
{textComponent}
</Grid>
<Grid size={{ md: 4, xs: 12 }}>
<img src={props.element.answers?.photo || "about:blank"} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
<img {...responsiveImgProps(props.element.answers?.photo, IMG_SIZES)} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
</Grid>
</Grid>
);
Expand All @@ -45,14 +47,14 @@ export const TextWithPhoto: React.FC<Props> = props => {
result = (
<>
{textComponent}
<img src={props.element.answers?.photo || "about:blank"} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
<img {...responsiveImgProps(props.element.answers?.photo, IMG_SIZES)} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
</>
);
break;
case "top":
result = (
<>
<img src={props.element.answers?.photo || "about:blank"} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
<img {...responsiveImgProps(props.element.answers?.photo, IMG_SIZES)} alt={props.element.answers?.photoAlt || ""} style={{ borderRadius: 10, marginTop: 40 }} loading="lazy" decoding="async" />
{textComponent}
</>
);
Expand Down
33 changes: 33 additions & 0 deletions apphelper/src/website/helpers/imageOptimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Pluggable image-optimization seam. Host apps (B1App / Next.js) register an optimizer that
// produces responsive <img> props and optimized CSS backgrounds via the Next image pipeline.
// The default is identity, so the Vite editor and any non-Next host render plain images unchanged.

export interface ResponsiveImgProps {
src: string;
srcSet?: string;
sizes?: string;
}

export interface ImageOptimizer {
// Responsive props to spread onto an <img>.
img: (url: string, sizes?: string) => ResponsiveImgProps;
// A CSS background-image value: url('…') or image-set(…).
background: (url: string) => string;
}

const identity: ImageOptimizer = {
img: (url) => ({ src: url }),
background: (url) => `url('${url}')`
};

let current: ImageOptimizer = identity;

export const setImageOptimizer = (optimizer: ImageOptimizer | null) => {
current = optimizer || identity;
};

export const responsiveImgProps = (url: string, sizes?: string): ResponsiveImgProps =>
url ? current.img(url, sizes) : { src: url };

export const optimizedBackgroundImage = (url: string): string =>
url ? current.background(url) : `url('${url}')`;
1 change: 1 addition & 0 deletions apphelper/src/website/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface SectionInterface {
}

export * from "./StyleHelper";
export * from "./imageOptimizer";
export * from "./EnvironmentHelper";
export * from "./interfaces";
export * from "./StreamingServiceHelper";
Expand Down
22 changes: 22 additions & 0 deletions apphelper/src/website/styles/pages.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,28 @@ body {
box-sizing: inherit;
}

/* Box background overlay/tint — opt-in via inline --overlay-opacity (defaults off) */
.page .boxBG {
background-size: cover;
background-position: 50% 50%;
position: relative;
}

.page .boxBG:before {
content: '';
position: absolute;
inset: 0;
background: var(--overlay-color, #000);
opacity: var(--overlay-opacity, 0);
pointer-events: none;
box-sizing: inherit;
}

.page .boxBG > * {
position: relative;
z-index: 1;
}

/*
.section {
color: #333;
Expand Down
29 changes: 29 additions & 0 deletions content-providers/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# @churchapps/content-providers

## 0.4.0

### Minor Changes

- ddd001f: Slim down the content-providers surface to what the apps actually use.

Breaking:

- Removed `FormatResolver` and the `FormatConverters` namespace (plus the `ResolvedFormatMeta` and `FormatResolverOptions` types). No shipping consumer used them — apps call `provider.getPlaylist()` / `provider.getInstructions()` directly. The cross-format derivation that `FormatResolver` provided lives on only in the dev playground.
- Removed the unused `presentations` field from `ProviderCapabilities` and retired the never-implemented "presentations" format (dead `getPresentations` stubs, `convertFilesToPresentations`). Capabilities are now `browse` / `playlist` / `instructions` / `mediaLicensing`.

Added:

- Export `LifeChurchProvider` from the package root (it was registered but not exported).

Internal (no API change):

- `instructionsToPlaylist` moved into `utils` (it's a general converter, used by B1Church and the playground).
- B1Church device-flow now delegates to the shared `DeviceFlowHelper` instead of a duplicate copy.
- Provider registration is a single list instead of constructing and registering each provider twice.

- 39e47d3: Trim the content-providers utility surface to what consumers actually use.

Breaking:

- Removed the duration-estimation module (`estimateDuration`, `estimateImageDuration`, `estimateTextDuration`, `countWords`, `DEFAULT_DURATION_CONFIG`, `DurationEstimationConfig`). The never-used text-estimation code is gone; image duration is now a single internal `IMAGE_DURATION_SECONDS = 15` constant.
- Removed the path helpers `buildPath`, `appendToPath`, and `generatePath` — no consumer or internal caller used them.
- Stopped exporting internal-only utilities from the package root: `detectMediaType`, `isMediaFile`, `createFolder`, `createFile`, `parsePath`, `getSegment`, and the `OAuthHelper` / `DeviceFlowHelper` / `ApiHelper` classes. They're still used internally — just no longer part of the public API. `TokenHelper` and `navigateToPath` remain exported.

## 0.3.1

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion content-providers/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createRequire } from 'module';
const require = createRequire(import.meta.url);

export default tseslint.config([
{ ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js"] },
{ ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js", "playground/", "cli/"] },
{
files: ["**/*.{ts,tsx,js,jsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
Expand Down
3 changes: 2 additions & 1 deletion content-providers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@churchapps/content-providers",
"version": "0.3.1",
"version": "0.4.0",
"type": "module",
"description": "Helper classes for interacting with third party providers",
"main": "./dist/index.js",
Expand All @@ -19,6 +19,7 @@
"build": "tsup",
"dev": "vite",
"cli": "yarn exec tsx cli/playground.ts",
"test": "tsx --test tests/*.test.ts",
"prepublishOnly": "yarn build",
"lint": "eslint --fix src/",
"lint:check": "eslint src/"
Expand Down
10 changes: 4 additions & 6 deletions content-providers/playground/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { state } from './state';
import { showLoading, showStatus } from './ui';
import { FormatResolver, ContentItem, ContentFile, Plan, Instructions, ContentFolder } from '../src';
import type { ResolvedFormatMeta } from '../src';
import { ContentItem, ContentFile, Instructions, ContentFolder } from '../src';
import { getPlaylistWithMeta, getInstructionsWithMeta, type ResolvedFormatMeta } from './formats';

/**
* Result type for viewAsPlaylist function
Expand Down Expand Up @@ -59,8 +59,7 @@ export async function viewAsPlaylist(folder: ContentFolder): Promise<PlaylistRes
showLoading(true);

try {
const resolver = new FormatResolver(state.currentProvider);
const { data: playlist, meta } = await resolver.getPlaylistWithMeta(folder.path, state.currentAuth);
const { data: playlist, meta } = await getPlaylistWithMeta(state.currentProvider, folder.path, state.currentAuth);

if (!playlist || playlist.length === 0) {
// Update state even for empty playlist (caller may want to fallback to browse)
Expand Down Expand Up @@ -136,8 +135,7 @@ export async function viewAsInstructions(folder: ContentFolder): Promise<Instruc
showLoading(true);

try {
const resolver = new FormatResolver(state.currentProvider);
const { data: instructions, meta } = await resolver.getInstructionsWithMeta(folder.path, state.currentAuth);
const { data: instructions, meta } = await getInstructionsWithMeta(state.currentProvider, folder.path, state.currentAuth);

if (!instructions) {
showStatus('This provider does not support expanded instructions view', 'error');
Expand Down
33 changes: 33 additions & 0 deletions content-providers/playground/formats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { IProvider, ContentFile, Instructions, ContentProviderAuthData } from '../src';
import { instructionsToPlaylist } from '../src/utils';

// Local playground shim. The package's FormatResolver was removed because no shipping app used it
// (they call provider.getPlaylist/getInstructions directly). The playground still wants the old
// "try native, else derive from instructions" behaviour, so it lives here instead of in the package.
export interface ResolvedFormatMeta {
isNative: boolean;
sourceFormat?: 'playlist' | 'instructions';
isLossy: boolean;
}

export async function getPlaylistWithMeta(provider: IProvider, path: string, auth?: ContentProviderAuthData | null): Promise<{ data: ContentFile[] | null; meta: ResolvedFormatMeta }> {
if (provider.capabilities.playlist && provider.getPlaylist) {
const result = await provider.getPlaylist(path, auth);
if (result && result.length > 0) return { data: result, meta: { isNative: true, isLossy: false } };
}
// Fall back to deriving a playlist from instructions (e.g. providers that only expose playlists
// at leaf depth, like Jesus Film and High Voltage Kids at the collection level).
if (provider.capabilities.instructions && provider.getInstructions) {
const instructions = await provider.getInstructions(path, auth);
if (instructions) return { data: instructionsToPlaylist(instructions), meta: { isNative: false, sourceFormat: 'instructions', isLossy: false } };
}
return { data: null, meta: { isNative: false, isLossy: false } };
}

export async function getInstructionsWithMeta(provider: IProvider, path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Instructions | null; meta: ResolvedFormatMeta }> {
if (provider.capabilities.instructions && provider.getInstructions) {
const result = await provider.getInstructions(path, auth);
if (result) return { data: result, meta: { isNative: true, isLossy: false } };
}
return { data: null, meta: { isNative: false, isLossy: false } };
}
Loading
Loading