diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 8d21c053..710278df 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -44,6 +44,7 @@ import { $Typed, UnicodeString } from "@atproto/api"; import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; import { Lock } from "src/utils/lock"; +import type { PubLeafletPublication } from "lexicons/api"; export async function publishToPublication({ root_entity, @@ -108,10 +109,18 @@ export async function publishToPublication({ let existingRecord = (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; + + // Extract theme for standalone documents (not for publications) + let theme: PubLeafletPublication.Theme | undefined; + if (!publication_uri) { + theme = await extractThemeFromFacts(facts, root_entity, agent); + } + let record: PubLeafletDocument.Record = { $type: "pub.leaflet.document", author: credentialSession.did!, ...(publication_uri && { publication: publication_uri }), + ...(theme && { theme }), publishedAt: new Date().toISOString(), ...existingRecord, title: title || "Untitled", @@ -606,3 +615,146 @@ type ExcludeString = T extends string ? never : T /* maybe literal, not the whole `string` */ : T; /* not a string */ + +async function extractThemeFromFacts( + facts: Fact[], + root_entity: string, + agent: AtpBaseClient, +): Promise { + let scan = scanIndexLocal(facts); + + let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data + .value; + let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data + .value; + let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value; + let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0] + ?.data.value; + let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value; + let showPageBackground = !scan.eav( + root_entity, + "theme/card-border-hidden", + )?.[0]?.data.value; + let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0]; + let backgroundImageRepeat = scan.eav( + root_entity, + "theme/background-image-repeat", + )?.[0]; + + // Helper to convert hex/hsba color string to RGB/RGBA object + const parseColorToRGB = ( + colorStr: string, + ): + | { $type: "pub.leaflet.theme.color#rgb"; r: number; g: number; b: number } + | { + $type: "pub.leaflet.theme.color#rgba"; + r: number; + g: number; + b: number; + a: number; + } + | undefined => { + // Try hex format first: #RRGGBB + const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(colorStr); + if (hexMatch) { + return { + $type: "pub.leaflet.theme.color#rgb" as const, + r: parseInt(hexMatch[1], 16), + g: parseInt(hexMatch[2], 16), + b: parseInt(hexMatch[3], 16), + }; + } + + // Try hsba format: hsba(h, s%, b%, a) + const hsbaMatch = + /^hsba\((\d+),\s*(\d+)%,\s*(\d+)%,\s*(\d+(?:\.\d+)?)\)$/i.exec(colorStr); + if (hsbaMatch) { + const h = parseInt(hsbaMatch[1]); + const s = parseInt(hsbaMatch[2]) / 100; + const b = parseInt(hsbaMatch[3]) / 100; + const a = Math.round(parseFloat(hsbaMatch[4]) * 100); + + // Convert HSB to RGB + const c = b * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = b - c; + + let r = 0, + g = 0, + bl = 0; + if (h >= 0 && h < 60) { + r = c; + g = x; + bl = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + bl = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + bl = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + bl = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + bl = c; + } else { + r = c; + g = 0; + bl = x; + } + + return { + $type: "pub.leaflet.theme.color#rgba" as const, + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((bl + m) * 255), + a, + }; + } + + return undefined; + }; + + let theme: PubLeafletPublication.Theme = { + showPageBackground: showPageBackground ?? true, + }; + + if (pageBackground) theme.backgroundColor = parseColorToRGB(pageBackground); + if (cardBackground) theme.pageBackground = parseColorToRGB(cardBackground); + if (primary) theme.primary = parseColorToRGB(primary); + if (accentBackground) + theme.accentBackground = parseColorToRGB(accentBackground); + if (accentText) theme.accentText = parseColorToRGB(accentText); + + // Upload background image if present + if (backgroundImage?.data) { + let imageData = await fetch(backgroundImage.data.src); + if (imageData.status === 200) { + let binary = await imageData.blob(); + let blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + + theme.backgroundImage = { + $type: "pub.leaflet.theme.backgroundImage", + image: blob.data.blob, + repeat: backgroundImageRepeat?.data.value ? true : false, + ...(backgroundImageRepeat?.data.value && { + width: backgroundImageRepeat.data.value, + }), + }; + } + } + + // Only return theme if at least one property is set + if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) { + return theme; + } + + return undefined; +} diff --git a/app/(home-pages)/discover/PubListing.tsx b/app/(home-pages)/discover/PubListing.tsx index fae42889..285a36cd 100644 --- a/app/(home-pages)/discover/PubListing.tsx +++ b/app/(home-pages)/discover/PubListing.tsx @@ -16,7 +16,7 @@ export const PubListing = ( }, ) => { let record = props.record as PubLeafletPublication.Record; - let theme = usePubTheme(record); + let theme = usePubTheme(record.theme); let backgroundImage = record?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( record?.theme?.backgroundImage?.image?.ref, diff --git a/app/(home-pages)/reader/ReaderContent.tsx b/app/(home-pages)/reader/ReaderContent.tsx index 11e49566..53b6edb5 100644 --- a/app/(home-pages)/reader/ReaderContent.tsx +++ b/app/(home-pages)/reader/ReaderContent.tsx @@ -102,7 +102,7 @@ const Post = (props: Post) => { let postRecord = props.documents.data as PubLeafletDocument.Record; let postUri = new AtUri(props.documents.uri); - let theme = usePubTheme(pubRecord); + let theme = usePubTheme(pubRecord?.theme); let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref ? blobRefToSrc( pubRecord?.theme?.backgroundImage?.image?.ref, diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index f791d173..ca0b98d1 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -160,13 +160,13 @@ export default async function Post(props: { return (
( PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage) ? { diff --git a/components/ThemeManager/PublicationThemeProvider.tsx b/components/ThemeManager/PublicationThemeProvider.tsx index cb593333..0429fb4e 100644 --- a/components/ThemeManager/PublicationThemeProvider.tsx +++ b/components/ThemeManager/PublicationThemeProvider.tsx @@ -26,15 +26,15 @@ function parseThemeColor( } let useColor = ( - record: PubLeafletPublication.Record | null | undefined, + theme: PubLeafletPublication.Record["theme"] | null | undefined, c: keyof typeof PubThemeDefaults, ) => { return useMemo(() => { - let v = record?.theme?.[c]; + let v = theme?.[c]; if (isColor(v)) { return parseThemeColor(v); } else return parseColor(PubThemeDefaults[c]); - }, [record?.theme?.[c]]); + }, [theme?.[c]]); }; let isColor = ( c: any, @@ -53,10 +53,10 @@ export function PublicationThemeProviderDashboard(props: { return ( {props.children} @@ -66,20 +66,17 @@ export function PublicationThemeProviderDashboard(props: { } export function PublicationBackgroundProvider(props: { - record?: PubLeafletPublication.Record | null; + theme?: PubLeafletPublication.Record["theme"] | null; pub_creator: string; className?: string; children: React.ReactNode; }) { - let backgroundImage = props.record?.theme?.backgroundImage?.image?.ref - ? blobRefToSrc( - props.record?.theme?.backgroundImage?.image?.ref, - props.pub_creator, - ) + let backgroundImage = props.theme?.backgroundImage?.image?.ref + ? blobRefToSrc(props.theme?.backgroundImage?.image?.ref, props.pub_creator) : null; - let backgroundImageRepeat = props.record?.theme?.backgroundImage?.repeat; - let backgroundImageSize = props.record?.theme?.backgroundImage?.width || 500; + let backgroundImageRepeat = props.theme?.backgroundImage?.repeat; + let backgroundImageSize = props.theme?.backgroundImage?.width || 500; return (
{props.children} @@ -107,16 +104,18 @@ export function PublicationThemeProvider(props: { ); } -export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { - let bgLeaflet = useColor(record, "backgroundColor"); - let bgPage = useColor(record, "pageBackground"); - bgPage = record?.theme?.pageBackground ? bgPage : bgLeaflet; - let showPageBackground = record?.theme?.showPageBackground; +export const usePubTheme = ( + theme?: PubLeafletPublication.Record["theme"] | null, +) => { + let bgLeaflet = useColor(theme, "backgroundColor"); + let bgPage = useColor(theme, "pageBackground"); + bgPage = theme?.pageBackground ? bgPage : bgLeaflet; + let showPageBackground = theme?.showPageBackground; - let primary = useColor(record, "primary"); + let primary = useColor(theme, "primary"); - let accent1 = useColor(record, "accentBackground"); - let accent2 = useColor(record, "accentText"); + let accent1 = useColor(theme, "accentBackground"); + let accent2 = useColor(theme, "accentText"); let highlight1 = useEntity(null, "theme/highlight-1")?.data.value; let highlight2 = useColorAttribute(null, "theme/highlight-2"); @@ -136,10 +135,10 @@ export const usePubTheme = (record?: PubLeafletPublication.Record | null) => { }; export const useLocalPubTheme = ( - record: PubLeafletPublication.Record | undefined, + theme: PubLeafletPublication.Record["theme"] | undefined, showPageBackground?: boolean, ) => { - const pubTheme = usePubTheme(record); + const pubTheme = usePubTheme(theme); const [localOverrides, setTheme] = useState>({}); const mergedTheme = useMemo(() => { diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx index d0798d62..0a30de2a 100644 --- a/components/ThemeManager/ThemeProvider.tsx +++ b/components/ThemeManager/ThemeProvider.tsx @@ -73,7 +73,7 @@ export function ThemeProvider(props: { return ( ); @@ -339,7 +339,9 @@ export const ThemeBackgroundProvider = (props: { return ( {props.children} diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 35d90268..1ebf4d50 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1436,6 +1436,10 @@ export const schemaDict = { type: 'string', format: 'at-identifier', }, + theme: { + type: 'ref', + ref: 'lex:pub.leaflet.publication#theme', + }, pages: { type: 'array', items: { diff --git a/lexicons/api/types/pub/leaflet/document.ts b/lexicons/api/types/pub/leaflet/document.ts index 066fe693..08cfbf1e 100644 --- a/lexicons/api/types/pub/leaflet/document.ts +++ b/lexicons/api/types/pub/leaflet/document.ts @@ -6,6 +6,7 @@ import { CID } from 'multiformats/cid' import { validate as _validate } from '../../../lexicons' import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' +import type * as PubLeafletPublication from './publication' import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' import type * as PubLeafletPagesCanvas from './pages/canvas' @@ -21,6 +22,7 @@ export interface Record { publishedAt?: string publication?: string author: string + theme?: PubLeafletPublication.Theme pages: ( | $Typed | $Typed diff --git a/lexicons/pub/leaflet/document.json b/lexicons/pub/leaflet/document.json index 005bbd39..32e338a4 100644 --- a/lexicons/pub/leaflet/document.json +++ b/lexicons/pub/leaflet/document.json @@ -42,6 +42,10 @@ "type": "string", "format": "at-identifier" }, + "theme": { + "type": "ref", + "ref": "pub.leaflet.publication#theme" + }, "pages": { "type": "array", "items": { diff --git a/lexicons/src/document.ts b/lexicons/src/document.ts index 7484ad34..e743ea97 100644 --- a/lexicons/src/document.ts +++ b/lexicons/src/document.ts @@ -22,6 +22,7 @@ export const PubLeafletDocument: LexiconDoc = { publishedAt: { type: "string", format: "datetime" }, publication: { type: "string", format: "at-uri" }, author: { type: "string", format: "at-identifier" }, + theme: { type: "ref", ref: "pub.leaflet.publication#theme" }, pages: { type: "array", items: {