diff --git a/app/ycode/components/Canvas.tsx b/app/ycode/components/Canvas.tsx index 05ce106e..07e93e5d 100644 --- a/app/ycode/components/Canvas.tsx +++ b/app/ycode/components/Canvas.tsx @@ -22,6 +22,7 @@ import { getCanvasIframeHtml } from '@/lib/canvas-utils'; import { CanvasPortalProvider } from '@/lib/canvas-portal-context'; import { cn } from '@/lib/utils'; import { loadSwiperCss } from '@/lib/slider-utils'; +import { resolveReferenceFieldsSync } from '@/lib/collection-utils'; import { useEditorStore } from '@/stores/useEditorStore'; import { useFontsStore } from '@/stores/useFontsStore'; import { useColorVariablesStore } from '@/stores/useColorVariablesStore'; @@ -298,6 +299,19 @@ export default function Canvas({ return serializeLayers(layers, components, editingComponentVariables); }, [layers, components, editingComponentVariables]); + // Enrich page collection item data with reference field dotted keys + // so variables like "refFieldId.targetFieldId" resolve on canvas + const enrichedPageCollectionItemData = useMemo(() => { + const values = pageCollectionItem?.values; + if (!values || !pageCollectionFields?.length) return values || null; + return resolveReferenceFieldsSync( + values, + pageCollectionFields, + collectionItems, + collectionFields + ); + }, [pageCollectionItem?.values, pageCollectionFields, collectionItems, collectionFields]); + // Collect layer IDs that should be hidden on canvas (display: hidden with on-load) const editorHiddenLayerIds = useMemo(() => { if (disableEditorHiddenLayers) return undefined; @@ -472,7 +486,7 @@ export default function Canvas({ hoveredLayerId={effectiveHoveredLayerId} pageId={pageId} pageCollectionItemId={pageCollectionItem?.id} - pageCollectionItemData={pageCollectionItem?.values || null} + pageCollectionItemData={enrichedPageCollectionItemData} onLayerClick={handleLayerClick} onLayerUpdate={onLayerUpdate} onLayerHover={handleLayerHover} @@ -495,7 +509,8 @@ export default function Canvas({ editingComponentId, editingComponentVariables, pageId, - pageCollectionItem, + pageCollectionItem?.id, + enrichedPageCollectionItemData, handleLayerClick, onLayerUpdate, handleLayerHover, diff --git a/app/ycode/components/CollectionItemSheet.tsx b/app/ycode/components/CollectionItemSheet.tsx index bf2c9a1b..37ef6b49 100644 --- a/app/ycode/components/CollectionItemSheet.tsx +++ b/app/ycode/components/CollectionItemSheet.tsx @@ -569,6 +569,7 @@ export default function CollectionItemSheet({ variant="full" withFormatting={true} excludedLinkTypes={['asset', 'field']} + hidePageContextOptions={true} onExpandClick={() => setExpandedRichTextField(field.id)} /> ) : field.type === 'reference' && field.reference_collection_id ? ( diff --git a/app/ycode/components/LinkItemOptions.tsx b/app/ycode/components/LinkItemOptions.tsx new file mode 100644 index 00000000..e25287e8 --- /dev/null +++ b/app/ycode/components/LinkItemOptions.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { SelectItem, SelectSeparator } from '@/components/ui/select'; +import type { CollectionItemWithValues, CollectionField } from '@/types'; +import type { ReferenceItemOption } from '@/lib/collection-field-utils'; + +interface CollectionItemSelectOptionsProps { + canUseCurrentPageItem: boolean; + canUseCurrentCollectionItem: boolean; + referenceItemOptions: ReferenceItemOption[]; + collectionItems: CollectionItemWithValues[]; + /** Fields for the linked page's collection, used to derive display names */ + collectionFields: CollectionField[]; +} + +/** Derives a human-readable label for a collection item. */ +function getDisplayName(item: CollectionItemWithValues, collectionFields: CollectionField[]): string { + const nameField = collectionFields.find(f => f.key === 'name'); + if (nameField && item.values[nameField.id]) return item.values[nameField.id]; + const values = Object.values(item.values); + return values[0] || item.id; +} + +/** + * Shared SelectContent items for CMS item pickers used in link settings. + * Renders "Current page item", "Current collection item", reference field options, + * a separator, and the concrete item list. + */ +export default function LinkItemOptions({ + canUseCurrentPageItem, + canUseCurrentCollectionItem, + referenceItemOptions, + collectionItems, + collectionFields, +}: CollectionItemSelectOptionsProps) { + const hasSpecialOptions = canUseCurrentPageItem || canUseCurrentCollectionItem || referenceItemOptions.length > 0; + + return ( + <> + {canUseCurrentPageItem && ( + + + Current page item + + + )} + {canUseCurrentCollectionItem && ( + + + Current collection item + + + )} + {referenceItemOptions.map((opt) => ( + + + {opt.label} + + + ))} + {hasSpecialOptions && } + {collectionItems.map((item) => ( + + {getDisplayName(item, collectionFields)} + + ))} + > + ); +} diff --git a/app/ycode/components/LinkSettings.tsx b/app/ycode/components/LinkSettings.tsx index da50726e..3a5cb787 100644 --- a/app/ycode/components/LinkSettings.tsx +++ b/app/ycode/components/LinkSettings.tsx @@ -16,7 +16,9 @@ import { Checkbox } from '@/components/ui/checkbox'; import Icon, { type IconProps } from '@/components/ui/icon'; import SettingsPanel from './SettingsPanel'; import RichTextEditor from './RichTextEditor'; -import { filterFieldGroupsByType, flattenFieldGroups, LINK_FIELD_TYPES } from '@/lib/collection-field-utils'; +import { filterFieldGroupsByType, flattenFieldGroups, LINK_FIELD_TYPES, buildReferenceItemOptions } from '@/lib/collection-field-utils'; +import { generateLinkHref } from '@/lib/link-utils'; +import LinkItemOptions from './LinkItemOptions'; import { FieldSelectDropdown, type FieldGroup, type FieldSourceType } from './CollectionFieldSelector'; import ComponentVariableLabel, { VARIABLE_TYPE_ICONS } from './ComponentVariableLabel'; import { @@ -239,6 +241,12 @@ export default function LinkSettings(props: LinkSettingsProps) { const currentPage = currentPageId ? pages.find(p => p.id === currentPageId) : null; const isCurrentPageDynamic = currentPage?.is_dynamic || false; + // "Current page item" only makes sense when both pages use the same collection + const currentPageCollectionId = currentPage?.settings?.cms?.collection_id || null; + const targetPageCollectionId = selectedPage?.settings?.cms?.collection_id || null; + const canUseCurrentPageItem = isDynamicPage && isCurrentPageDynamic + && !!currentPageCollectionId && currentPageCollectionId === targetPageCollectionId; + // Check if the layer itself is a collection layer const isCollectionLayer = !!(layer && getCollectionVariable(layer)); @@ -261,6 +269,12 @@ export default function LinkSettings(props: LinkSettingsProps) { const hasCollectionFields = !!(collectionGroup && collectionGroup.fields.length > 0 && isInsideCollectionLayer); const canUseCurrentCollectionItem = hasCollectionFields || isCollectionLayer; + // Find reference fields that point to the target page's collection + const referenceItemOptions = useMemo( + () => buildReferenceItemOptions(isDynamicPage, targetPageCollectionId, fieldGroups), + [isDynamicPage, targetPageCollectionId, fieldGroups] + ); + // Get collection ID from dynamic page settings const pageCollectionId = selectedPage?.settings?.cms?.collection_id || null; @@ -646,27 +660,10 @@ export default function LinkSettings(props: LinkSettingsProps) { // Get asset info for display const selectedAsset = assetId ? getAsset(assetId) : null; - // Get display name for selected collection item - const getItemDisplayName = useCallback( - (itemId: string) => { - if (itemId === 'current') return 'Current Item'; - const item = collectionItems.find((i) => i.id === itemId); - if (!item) return itemId; - - // Get fields from store for the page's collection - const collectionFields = pageCollectionId ? collectionsStoreFields[pageCollectionId] : []; - - // Find the field with key === 'name' - const nameField = collectionFields?.find((field) => field.key === 'name'); - if (nameField && item.values[nameField.id]) { - return item.values[nameField.id]; - } - - // Fall back to first available value - const values = Object.values(item.values); - return values[0] || itemId; - }, - [collectionItems, pageCollectionId, collectionsStoreFields] + // Fields for the linked page's collection (for display names) + const linkedPageCollectionFields = useMemo( + () => pageCollectionId ? collectionsStoreFields[pageCollectionId] || [] : [], + [pageCollectionId, collectionsStoreFields] ); // Layer mode requires a layer @@ -903,28 +900,13 @@ export default function LinkSettings(props: LinkSettingsProps) { - {/* Current page item option (when on a dynamic page AND linking to a dynamic page) */} - {isDynamicPage && isCurrentPageDynamic && ( - - - Current page item - - - )} - {/* Current collection item option (when inside a collection layer OR when the layer IS a collection layer) */} - {canUseCurrentCollectionItem && ( - - - Current collection item - - - )} - {((isDynamicPage && isCurrentPageDynamic) || canUseCurrentCollectionItem) && } - {collectionItems.map((item) => ( - - {getItemDisplayName(item.id)} - - ))} + diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx index e4f8aa11..f30a29b5 100644 --- a/app/ycode/components/RichTextEditor.tsx +++ b/app/ycode/components/RichTextEditor.tsx @@ -98,6 +98,8 @@ interface RichTextEditorProps { size?: 'xs' | 'sm'; /** Link types to exclude from the link settings dropdown */ excludedLinkTypes?: LinkType[]; + /** Hide "Current page item" and "Reference field" options (e.g. when editing CMS item content) */ + hidePageContextOptions?: boolean; /** Stretch editor to fill parent height (scrolls content instead of growing) */ fullHeight?: boolean; /** Callback to open the full editor sheet (shown as expand button in toolbar) */ @@ -320,6 +322,7 @@ const RichTextEditor = forwardRef(({ variant = 'compact', size = 'xs', excludedLinkTypes = [], + hidePageContextOptions = false, fullHeight = false, onExpandClick, allowedFieldTypes = RICH_TEXT_ONLY_FIELD_TYPES, @@ -813,6 +816,7 @@ const RichTextEditor = forwardRef(({ open={linkPopoverOpen} onOpenChange={setLinkPopoverOpen} excludedLinkTypes={excludedLinkTypes} + hidePageContextOptions={hidePageContextOptions} trigger={ (({ open={linkPopoverOpen} onOpenChange={setLinkPopoverOpen} excludedLinkTypes={excludedLinkTypes} + hidePageContextOptions={hidePageContextOptions} trigger={ ; collections?: Collection[]; + /** Hide "Current page item" and "Reference field" options */ + hidePageContextOptions?: boolean; } export default function RichTextEditorSheet({ @@ -44,6 +46,7 @@ export default function RichTextEditorSheet({ fieldGroups, allFields, collections, + hidePageContextOptions = false, }: RichTextEditorSheetProps) { return ( @@ -83,6 +86,7 @@ export default function RichTextEditorSheet({ variant="full" fullHeight allowedFieldTypes={RICH_TEXT_FIELD_TYPES} + hidePageContextOptions={hidePageContextOptions} /> diff --git a/app/ycode/components/RichTextLinkPopover.tsx b/app/ycode/components/RichTextLinkPopover.tsx index 854cbb03..f8a6f868 100644 --- a/app/ycode/components/RichTextLinkPopover.tsx +++ b/app/ycode/components/RichTextLinkPopover.tsx @@ -42,6 +42,8 @@ export interface RichTextLinkPopoverProps { disabled?: boolean; /** Link types to exclude from the dropdown */ excludedLinkTypes?: LinkType[]; + /** Hide "Current page item" and "Reference field" options (e.g. when editing CMS item content) */ + hidePageContextOptions?: boolean; } /** @@ -59,6 +61,7 @@ export default function RichTextLinkPopover({ onOpenChange: controlledOnOpenChange, disabled = false, excludedLinkTypes = [], + hidePageContextOptions = false, }: RichTextLinkPopoverProps) { // Use controlled state if provided, otherwise internal state const [internalOpen, setInternalOpen] = useState(false); @@ -268,6 +271,7 @@ export default function RichTextLinkPopover({ isInsideCollectionLayer={isInsideCollectionLayer} layer={layer} excludedLinkTypes={excludedLinkTypes} + hidePageContextOptions={hidePageContextOptions} /> diff --git a/app/ycode/components/RichTextLinkSettings.tsx b/app/ycode/components/RichTextLinkSettings.tsx index 297b9651..86dae949 100644 --- a/app/ycode/components/RichTextLinkSettings.tsx +++ b/app/ycode/components/RichTextLinkSettings.tsx @@ -38,7 +38,8 @@ import { collectionsApi } from '@/lib/api'; import { getLayerIcon, getLayerName, getCollectionVariable } from '@/lib/layer-utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import PageSelector from './PageSelector'; -import { filterFieldGroupsByType, flattenFieldGroups, LINK_FIELD_TYPES, type FieldGroup } from '@/lib/collection-field-utils'; +import { filterFieldGroupsByType, flattenFieldGroups, LINK_FIELD_TYPES, buildReferenceItemOptions, type FieldGroup } from '@/lib/collection-field-utils'; +import LinkItemOptions from './LinkItemOptions'; export interface RichTextLinkSettingsProps { /** Current link settings */ @@ -57,6 +58,8 @@ export interface RichTextLinkSettingsProps { layer?: Layer | null; /** Link types to exclude from the dropdown */ excludedLinkTypes?: LinkType[]; + /** Hide "Current page item" and "Reference field" options (e.g. when editing CMS item content) */ + hidePageContextOptions?: boolean; } /** @@ -71,6 +74,7 @@ export default function RichTextLinkSettings({ isInsideCollectionLayer = false, layer, excludedLinkTypes = [], + hidePageContextOptions = false, }: RichTextLinkSettingsProps) { const [collectionItems, setCollectionItems] = useState([]); const [loadingItems, setLoadingItems] = useState(false); @@ -175,6 +179,13 @@ export default function RichTextLinkSettings({ const currentPage = currentPageId ? pages.find(p => p.id === currentPageId) : null; const isCurrentPageDynamic = currentPage?.is_dynamic || false; + // "Current page item" only makes sense when both pages use the same collection, + // and is never relevant when editing CMS item content directly + const currentPageCollectionId = currentPage?.settings?.cms?.collection_id || null; + const targetPageCollectionId = selectedPage?.settings?.cms?.collection_id || null; + const canUseCurrentPageItem = !hidePageContextOptions && isDynamicPage && isCurrentPageDynamic + && !!currentPageCollectionId && currentPageCollectionId === targetPageCollectionId; + // Check if the layer itself is a collection layer const isCollectionLayer = !!(layer && getCollectionVariable(layer)); @@ -196,6 +207,13 @@ export default function RichTextLinkSettings({ const hasCollectionFields = !!(collectionGroup && collectionGroup.fields.length > 0 && isInsideCollectionLayer); const canUseCurrentCollectionItem = hasCollectionFields || isCollectionLayer; + // Find reference fields that point to the target page's collection. + // Hidden when editing CMS item content directly (no page context). + const referenceItemOptions = useMemo( + () => hidePageContextOptions ? [] : buildReferenceItemOptions(isDynamicPage, targetPageCollectionId, fieldGroups), + [hidePageContextOptions, isDynamicPage, targetPageCollectionId, fieldGroups] + ); + // Get collection ID from dynamic page settings const pageCollectionId = selectedPage?.settings?.cms?.collection_id || null; @@ -488,23 +506,10 @@ export default function RichTextLinkSettings({ // Get asset info for display const selectedAsset = assetId ? getAsset(assetId) : null; - // Get display name for selected collection item - const getItemDisplayName = useCallback( - (itemId: string) => { - if (itemId === 'current') return 'Current Item'; - const item = collectionItems.find((i) => i.id === itemId); - if (!item) return itemId; - - const collectionFields = pageCollectionId ? collectionsStoreFields[pageCollectionId] : []; - const nameField = collectionFields?.find((field) => field.key === 'name'); - if (nameField && item.values[nameField.id]) { - return item.values[nameField.id]; - } - - const values = Object.values(item.values); - return values[0] || itemId; - }, - [collectionItems, pageCollectionId, collectionsStoreFields] + // Fields for the linked page's collection (for display names) + const linkedPageCollectionFields = useMemo( + () => pageCollectionId ? collectionsStoreFields[pageCollectionId] || [] : [], + [pageCollectionId, collectionsStoreFields] ); return ( @@ -660,26 +665,13 @@ export default function RichTextLinkSettings({ - {isDynamicPage && isCurrentPageDynamic && ( - - - Current page item - - - )} - {canUseCurrentCollectionItem && ( - - - Current collection item - - - )} - {((isDynamicPage && isCurrentPageDynamic) || canUseCurrentCollectionItem) && } - {collectionItems.map((item) => ( - - {getItemDisplayName(item.id)} - - ))} + diff --git a/components/PageRenderer.tsx b/components/PageRenderer.tsx index 0f972b3e..6228269d 100644 --- a/components/PageRenderer.tsx +++ b/components/PageRenderer.tsx @@ -12,11 +12,69 @@ import { buildCustomFontsCss, buildFontClassesCss, getGoogleFontLinks } from '@/ import { collectLayerAssetIds, getAssetProxyUrl } from '@/lib/asset-utils'; import { getAllPages } from '@/lib/repositories/pageRepository'; import { getAllPageFolders } from '@/lib/repositories/pageFolderRepository'; -import { getItemWithValues } from '@/lib/repositories/collectionItemRepository'; +import { getItemWithValues, getItemsWithValues } from '@/lib/repositories/collectionItemRepository'; import { getFieldsByCollectionId } from '@/lib/repositories/collectionFieldRepository'; +import { REF_PAGE_PREFIX, REF_COLLECTION_PREFIX } from '@/lib/link-utils'; import { getClassesString } from '@/lib/layer-utils'; import type { Layer, Component, Page, CollectionItemWithValues, CollectionField, Locale, PageFolder } from '@/types'; +interface PageLinkRef { collection_item_id: string; page_id: string } + +/** Recursively collect all page link refs ({collection_item_id, page_id}) from a Tiptap JSON node */ +function collectTiptapPageLinks(node: any): PageLinkRef[] { + if (!node || typeof node !== 'object') return []; + const results: PageLinkRef[] = []; + if (node.marks && Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (mark.type === 'richTextLink' && mark.attrs?.type === 'page' + && mark.attrs.page?.collection_item_id && mark.attrs.page?.id) { + results.push({ collection_item_id: mark.attrs.page.collection_item_id, page_id: mark.attrs.page.id }); + } + } + } + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) results.push(...collectTiptapPageLinks(child)); + } + return results; +} + +/** + * Walk a layer tree and return every page link ref from both layer-level links + * and richTextLink marks inside rich text variables. + */ +function collectLayerPageLinks(layers: Layer[]): PageLinkRef[] { + const results: PageLinkRef[] = []; + const scan = (layer: Layer) => { + if (layer.variables?.link?.type === 'page') { + const { collection_item_id, id: page_id } = layer.variables.link.page ?? {}; + if (collection_item_id && page_id) results.push({ collection_item_id, page_id }); + } + const textVar = layer.variables?.text as any; + if (textVar?.type === 'dynamic_rich_text' && textVar.data?.content) { + results.push(...collectTiptapPageLinks(textVar.data.content)); + } + if (layer.children) layer.children.forEach(scan); + }; + layers.forEach(scan); + return results; +} + +/** + * Extract collection item slugs from resolved collection layers. + * These are populated by resolveCollectionLayers with `_collectionItemId` / `_collectionItemSlug`. + */ +function extractCollectionItemSlugs(layers: Layer[]): Record { + const slugs: Record = {}; + const scan = (layer: Layer) => { + if (layer._collectionItemId && layer._collectionItemSlug) { + slugs[layer._collectionItemId] = layer._collectionItemSlug; + } + if (layer.children) layer.children.forEach(scan); + }; + layers.forEach(scan); + return slugs; +} + /** Recursively check if any layer in the tree is a slider */ function hasSliderLayers(layers: Layer[]): boolean { for (const layer of layers) { @@ -107,44 +165,14 @@ export default async function PageRenderer({ // Components are passed through for rich-text embedded component rendering in LayerRenderer. const resolvedLayers = layers || []; - // Scan layers for collection_item_ids referenced in link settings - // Excludes special keywords like 'current-page' and 'current-collection' which are resolved at runtime - const findCollectionItemIds = (layers: Layer[]): Set => { - const itemIds = new Set(); - const specialKeywords = ['current-page', 'current-collection']; - const scan = (layer: Layer) => { - const itemId = layer.variables?.link?.page?.collection_item_id; - if (layer.variables?.link?.type === 'page' && itemId && !specialKeywords.includes(itemId)) { - itemIds.add(itemId); - } - if (layer.children) { - layer.children.forEach(scan); - } - }; - layers.forEach(scan); - return itemIds; - }; - - // Extract collection item slugs from resolved collection layers - // These are populated by resolveCollectionLayers with `_collectionItemId` and `_collectionItemSlug` - const extractCollectionItemSlugs = (layers: Layer[]): Record => { - const slugs: Record = {}; - const scan = (layer: Layer) => { - // Check for SSR-resolved collection item with ID and slug - const itemId = layer._collectionItemId; - const itemSlug = layer._collectionItemSlug; - if (itemId && itemSlug) { - slugs[itemId] = itemSlug; - } - if (layer.children) { - layer.children.forEach(scan); - } - }; - layers.forEach(scan); - return slugs; - }; - - const referencedItemIds = findCollectionItemIds(resolvedLayers); + // Single tree traversal — derive both sets from the flat list + const allPageLinks = collectLayerPageLinks(resolvedLayers); + const DYNAMIC_KEYWORDS = new Set(['current-page', 'current-collection']); + const referencedItemIds = new Set( + allPageLinks + .filter(l => !DYNAMIC_KEYWORDS.has(l.collection_item_id) && !l.collection_item_id.startsWith('ref-')) + .map(l => l.collection_item_id) + ); // Build collection item slugs map const collectionItemSlugs: Record = {}; @@ -193,6 +221,26 @@ export default async function PageRenderer({ } } } + + // Fetch slugs for all items in collections targeted by ref-* links + const refTargetCollectionIds = new Set( + allPageLinks + .filter(l => l.collection_item_id.startsWith(REF_PAGE_PREFIX) || l.collection_item_id.startsWith(REF_COLLECTION_PREFIX)) + .map(l => pages.find(p => p.id === l.page_id)?.settings?.cms?.collection_id) + .filter((id): id is string => !!id) + ); + for (const collId of refTargetCollectionIds) { + const fields = await getFieldsByCollectionId(collId, false); + const slugField = fields.find(f => f.key === 'slug'); + if (!slugField) continue; + + const { items } = await getItemsWithValues(collId, false); + for (const item of items) { + if (item.values[slugField.id]) { + collectionItemSlugs[item.id] = item.values[slugField.id]; + } + } + } } catch (error) { console.error('[PageRenderer] Error fetching link resolution data:', error); } diff --git a/lib/collection-field-utils.ts b/lib/collection-field-utils.ts index 8725aa14..d5352377 100644 --- a/lib/collection-field-utils.ts +++ b/lib/collection-field-utils.ts @@ -601,6 +601,42 @@ export function flattenFieldGroups(fieldGroups: FieldGroup[] | undefined): Colle return fieldGroups?.flatMap(g => g.fields) || []; } +/** Prefix for reference-field-based collection item resolution (page source) */ +export const REF_PAGE_PREFIX = 'ref-page:'; +/** Prefix for reference-field-based collection item resolution (collection source) */ +export const REF_COLLECTION_PREFIX = 'ref-collection:'; + +/** A resolved option for a reference field pointing to a specific CMS page collection. */ +export interface ReferenceItemOption { + value: string; + label: string; +} + +/** + * Build select options for reference fields that point to a given target collection. + * Used in link settings dropdowns to offer "Category - Reference field" style options. + */ +export function buildReferenceItemOptions( + isDynamicPage: boolean, + targetPageCollectionId: string | null, + fieldGroups: FieldGroup[] | undefined +): ReferenceItemOption[] { + if (!isDynamicPage || !targetPageCollectionId || !fieldGroups) return []; + const options: ReferenceItemOption[] = []; + for (const group of fieldGroups) { + const prefix = group.source === 'page' ? REF_PAGE_PREFIX : REF_COLLECTION_PREFIX; + for (const field of group.fields) { + if (field.type === 'reference' && field.reference_collection_id === targetPageCollectionId) { + options.push({ + value: `${prefix}${field.id}`, + label: `${field.name} - Reference field`, + }); + } + } + } + return options; +} + /** * Check if any fields match a predicate across all groups. */ diff --git a/lib/link-utils.ts b/lib/link-utils.ts index a9a40ea3..113bb4f7 100644 --- a/lib/link-utils.ts +++ b/lib/link-utils.ts @@ -194,6 +194,29 @@ export function hasLinkInTree(layer: Layer): boolean { return false; } +export { REF_PAGE_PREFIX, REF_COLLECTION_PREFIX } from '@/lib/collection-field-utils'; +import { REF_PAGE_PREFIX, REF_COLLECTION_PREFIX } from '@/lib/collection-field-utils'; + +/** + * Resolve a ref-* collection_item_id to the actual referenced item ID + * by looking up the reference field value in the current item data. + */ +export function resolveRefCollectionItemId( + collectionItemId: string, + pageCollectionItemData?: Record, + collectionItemData?: Record +): string | undefined { + if (collectionItemId.startsWith(REF_PAGE_PREFIX)) { + const fieldId = collectionItemId.slice(REF_PAGE_PREFIX.length); + return pageCollectionItemData?.[fieldId]; + } + if (collectionItemId.startsWith(REF_COLLECTION_PREFIX)) { + const fieldId = collectionItemId.slice(REF_COLLECTION_PREFIX.length); + return collectionItemData?.[fieldId]; + } + return undefined; +} + /** * Context for resolving links (page, asset, field types) */ @@ -436,13 +459,21 @@ export function generateLinkHref( if (page.is_dynamic && linkSettings.page.collection_item_id && collectionItemSlugs) { let itemSlug: string | undefined; - // Handle special "current" keywords + // Handle special "current" keywords and reference field resolution if (linkSettings.page.collection_item_id === 'current-page') { // Use the page's collection item (for dynamic pages) itemSlug = pageCollectionItemId ? collectionItemSlugs[pageCollectionItemId] : undefined; } else if (linkSettings.page.collection_item_id === 'current-collection') { // Use the current collection layer's item itemSlug = collectionItemId ? collectionItemSlugs[collectionItemId] : undefined; + } else if (linkSettings.page.collection_item_id.startsWith('ref-')) { + // Resolve via reference field value from current item data + const refItemId = resolveRefCollectionItemId( + linkSettings.page.collection_item_id, + pageCollectionItemData, + collectionItemData + ); + itemSlug = refItemId ? collectionItemSlugs[refItemId] : undefined; } else { // Use the specific item slug itemSlug = collectionItemSlugs[linkSettings.page.collection_item_id]; diff --git a/lib/page-fetcher.ts b/lib/page-fetcher.ts index de2c4f0b..e9d96723 100644 --- a/lib/page-fetcher.ts +++ b/lib/page-fetcher.ts @@ -19,7 +19,9 @@ export interface PaginationContext { defaultPage?: number; } -import { resolveFieldLinkValue } from '@/lib/link-utils'; +import { resolveFieldLinkValue, resolveRefCollectionItemId, generateLinkHref } from '@/lib/link-utils'; +import type { LinkResolutionContext } from '@/lib/link-utils'; +import { getLinkSettingsFromMark } from '@/lib/tiptap-extensions/rich-text-link'; import { SWIPER_CLASS_MAP, SWIPER_DATA_ATTR_MAP } from '@/lib/templates/utilities'; import { resolveInlineVariables, resolveInlineVariablesFromData } from '@/lib/inline-variables'; import { buildLayerTranslationKey, getTranslationByKey, hasValidTranslationValue, getTranslationValue } from '@/lib/localisation-utils'; @@ -3007,6 +3009,7 @@ function renderTiptapToHtml( content: any, textStyles?: Record, renderComponentHtml?: RenderComponentHtmlFn, + linkContext?: LinkResolutionContext, ): string { if (!content || typeof content !== 'object') { return ''; @@ -3049,6 +3052,21 @@ function renderTiptapToHtml( text = `${text}`; } break; + case 'richTextLink': { + const rtLinkSettings = getLinkSettingsFromMark(mark.attrs || {}); + if (rtLinkSettings.type && linkContext) { + const href = generateLinkHref(rtLinkSettings, linkContext); + if (href) { + const target = mark.attrs.target ? ` target="${escapeHtml(mark.attrs.target)}"` : ''; + const rel = mark.attrs.rel + ? ` rel="${escapeHtml(mark.attrs.rel)}"` + : (mark.attrs.target === '_blank' ? ' rel="noopener noreferrer"' : ''); + const download = mark.attrs.download ? ' download' : ''; + text = `${text}`; + } + } + break; + } case 'dynamicStyle': { // Handle dynamic styles (headings, paragraphs, custom styles) const styleKeys: string[] = mark.attrs?.styleKeys || []; @@ -3079,7 +3097,7 @@ function renderTiptapToHtml( const paragraphClass = mergedStyles?.paragraph?.classes || ''; // Empty paragraphs use non-breaking space to preserve the empty line const innerHtml = content.content && content.content.length > 0 - ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml)).join('') + ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml, linkContext)).join('') : '\u00A0'; // Wrap in span with paragraph styles for proper block display return `${innerHtml}`; @@ -3093,7 +3111,7 @@ function renderTiptapToHtml( const headingClass = mergedStyles?.[styleKey]?.classes || ''; // Empty headings use non-breaking space to preserve the empty line const innerHtml = content.content && content.content.length > 0 - ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml)).join('') + ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml, linkContext)).join('') : '\u00A0'; // Use span to avoid nesting issues (h1 inside p is invalid) return `${innerHtml}`; @@ -3101,7 +3119,7 @@ function renderTiptapToHtml( // Handle doc (root) if (content.type === 'doc' && Array.isArray(content.content)) { - return content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml)).join(''); + return content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml, linkContext)).join(''); } // Handle bullet list @@ -3109,7 +3127,7 @@ function renderTiptapToHtml( const listClass = textStyles?.bulletList?.classes || ''; const classAttr = listClass ? ` class="${escapeHtml(listClass)}"` : ''; const items = content.content - ? content.content.map((item: any) => renderTiptapToHtml(item, textStyles, renderComponentHtml)).join('') + ? content.content.map((item: any) => renderTiptapToHtml(item, textStyles, renderComponentHtml, linkContext)).join('') : ''; return `${items}`; } @@ -3119,7 +3137,7 @@ function renderTiptapToHtml( const listClass = textStyles?.orderedList?.classes || ''; const classAttr = listClass ? ` class="${escapeHtml(listClass)}"` : ''; const items = content.content - ? content.content.map((item: any) => renderTiptapToHtml(item, textStyles, renderComponentHtml)).join('') + ? content.content.map((item: any) => renderTiptapToHtml(item, textStyles, renderComponentHtml, linkContext)).join('') : ''; return `${items}`; } @@ -3127,7 +3145,7 @@ function renderTiptapToHtml( // Handle list item if (content.type === 'listItem') { const innerHtml = content.content - ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml)).join('') + ? content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml, linkContext)).join('') : ''; return `${innerHtml}`; } @@ -3160,7 +3178,7 @@ function renderTiptapToHtml( // Fallback: recursively process content if (Array.isArray(content.content)) { - return content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml)).join(''); + return content.content.map((node: any) => renderTiptapToHtml(node, textStyles, renderComponentHtml, linkContext)).join(''); } return ''; @@ -3572,11 +3590,19 @@ function layerToHtml( if (linkedPage.is_dynamic && linkSettings.page.collection_item_id && collectionItemSlugs) { let itemSlug: string | undefined; - // Handle special "current" keywords - use the current collection item ID + // Handle special "current" keywords and reference field resolution if (linkSettings.page.collection_item_id === 'current-page' || linkSettings.page.collection_item_id === 'current-collection') { // Use the current collection item's slug (from effectiveCollectionItemId) itemSlug = effectiveCollectionItemId ? collectionItemSlugs[effectiveCollectionItemId] : undefined; + } else if (linkSettings.page.collection_item_id.startsWith('ref-')) { + // Resolve via reference field value from current item data + const refItemId = resolveRefCollectionItemId( + linkSettings.page.collection_item_id, + pageCollectionItemData, + effectiveCollectionItemData + ); + itemSlug = refItemId ? collectionItemSlugs[refItemId] : undefined; } else { // Use the specific item slug itemSlug = collectionItemSlugs[linkSettings.page.collection_item_id]; @@ -3793,7 +3819,19 @@ function layerToHtml( .join(''); } : undefined; - textContent = renderTiptapToHtml(textVariable.data.content, layer.textStyles, componentRenderer); + const richTextLinkContext: LinkResolutionContext = { + pages, + folders, + collectionItemSlugs, + collectionItemId: effectiveCollectionItemId, + collectionItemData: effectiveCollectionItemData, + pageCollectionItemData, + locale, + translations, + anchorMap, + layerDataMap: effectiveLayerDataMap, + }; + textContent = renderTiptapToHtml(textVariable.data.content, layer.textStyles, componentRenderer, richTextLinkContext); isRichText = true; } }