diff --git a/apps/web/core/constants.ts b/apps/web/core/constants.ts index f9dc9ec83e..73f15ca118 100644 --- a/apps/web/core/constants.ts +++ b/apps/web/core/constants.ts @@ -41,6 +41,11 @@ export const VALID_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'imag export const IMAGE_ACCEPT = VALID_IMAGE_TYPES.join(','); export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB +// PDF file types and upload constraints +export const VALID_PDF_TYPES = ['application/pdf']; +export const PDF_ACCEPT = VALID_PDF_TYPES.join(','); +export const MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB + export const DATA_TYPE_PROPERTY = '6d29d57849bb4959baf72cc696b1671a'; /** DataType → entity ID for Data Type relations. */ @@ -78,6 +83,9 @@ export const DOCUMENTATION_SPACE_ENTITY_ID = '46162f0614d448b2b00d8c3bbd7e5194'; export const GEO_LOCATION = '9cf5c1b015dc451cbfd297db64806aff'; export const FORMAT_PROPERTY = '396f8c72dfd04b5791ea09c1b9321b2f'; +export const PDF_TYPE = '14a39e59d9874596956ac2dd4165c210'; +export const PDF_URL = '9d69ac47702343e2b72a91a335eb54d9'; + export const PLACE = '783bc688e65f4e54b67fa5643d78345e'; export const ADDRESS = '5c6e72fb834047c082818be159ecd495'; export const VENUE_PROPERTY = 'f28bbb6bf4e8465d9de7a09085e224b9'; diff --git a/apps/web/core/io/dto/properties.ts b/apps/web/core/io/dto/properties.ts index e1b9507379..802a3667d7 100644 --- a/apps/web/core/io/dto/properties.ts +++ b/apps/web/core/io/dto/properties.ts @@ -1,6 +1,6 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; -import { ADDRESS, GEO_LOCATION, PLACE, VIDEO_RENDERABLE_TYPE } from '~/core/constants'; +import { ADDRESS, GEO_LOCATION, PDF_TYPE, PLACE, VIDEO_RENDERABLE_TYPE } from '~/core/constants'; import { DataType as AppDataType, Property, RenderableType } from '~/core/types'; import { getDataTypeFromEntityId } from '~/core/utils/property/properties'; @@ -34,6 +34,8 @@ export function getStrictRenderableType(renderableType: string | null): Renderab return 'IMAGE'; case VIDEO_RENDERABLE_TYPE: return 'VIDEO'; + case PDF_TYPE: + return 'PDF'; case SystemIds.URL: return 'URL'; case GEO_LOCATION: diff --git a/apps/web/core/io/dto/relations.ts b/apps/web/core/io/dto/relations.ts index a6d046dcf4..ded1680290 100644 --- a/apps/web/core/io/dto/relations.ts +++ b/apps/web/core/io/dto/relations.ts @@ -1,12 +1,16 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { PDF_TYPE, PDF_URL } from '~/core/constants'; import { RemoteEntityType, RemoteRelation } from '~/core/io/schema'; import { Relation, RenderableEntityType } from '~/core/types'; import { getSpaceRank } from '~/core/utils/space/space-ranking'; export function RelationDtoLive(relation: RemoteRelation): Relation { const ipfsUrlPropertyHex = SystemIds.IMAGE_URL_PROPERTY.replace(/-/g, ''); - const mediaEntityUrlValue = relation.toEntity.valuesList.find(v => v.propertyId === ipfsUrlPropertyHex)?.text ?? null; + const pdfUrlPropertyHex = PDF_URL; // Already hex format + const imageUrlValue = relation.toEntity.valuesList.find(v => v.propertyId === ipfsUrlPropertyHex)?.text ?? null; + const pdfUrlValue = relation.toEntity.valuesList.find(v => v.propertyId === pdfUrlPropertyHex)?.text ?? null; + const mediaEntityUrlValue = imageUrlValue ?? pdfUrlValue; const baseRenderableType = v2_getRenderableEntityType(relation.toEntity.types); const renderableType = mediaEntityUrlValue && baseRenderableType === 'RELATION' ? 'IMAGE' : baseRenderableType; @@ -33,7 +37,7 @@ export function RelationDtoLive(relation: RemoteRelation): Relation { toEntity: { id: toEntityId, name: toEntityName, - value: renderableType === 'IMAGE' || renderableType === 'VIDEO' ? (mediaEntityUrlValue ?? '') : toEntityId, + value: renderableType === 'IMAGE' || renderableType === 'VIDEO' || renderableType === 'PDF' ? (mediaEntityUrlValue ?? '') : toEntityId, }, }; } @@ -70,6 +74,10 @@ function v2_getRenderableEntityType(types: readonly RemoteEntityType[]): Rendera return 'VIDEO'; } + if (typeIds.includes(PDF_TYPE)) { + return 'PDF'; + } + if (typeIds.includes(dataBlockHex)) { return 'DATA'; } diff --git a/apps/web/core/io/dto/snapshot-to-diff.ts b/apps/web/core/io/dto/snapshot-to-diff.ts index 3aba033ba9..7d8f1e82e4 100644 --- a/apps/web/core/io/dto/snapshot-to-diff.ts +++ b/apps/web/core/io/dto/snapshot-to-diff.ts @@ -1,5 +1,6 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { PDF_TYPE, PDF_URL } from '~/core/constants'; import type { BlockChange, EntityDiff, RelationChange, SimpleValueType, ValueChange } from '~/core/utils/diff/types'; import type { ApiBlockSnapshot, ApiEntitySnapshotResponse, ApiVersionedRelation, ApiVersionedValue } from '../rest'; @@ -22,17 +23,18 @@ const { * Determine block type from a TYPES_PROPERTY relation. * Shared logic with `detectBlockType` in diff.ts, but operates on raw API relation shapes. */ -function classifyBlockType(typeEntityId: string): 'textBlock' | 'imageBlock' | 'videoBlock' | 'dataBlock' | null { +function classifyBlockType(typeEntityId: string): 'textBlock' | 'imageBlock' | 'videoBlock' | 'pdfBlock' | 'dataBlock' | null { if (typeEntityId === TEXT_BLOCK) return 'textBlock'; if (typeEntityId === IMAGE_BLOCK || typeEntityId === IMAGE_TYPE) return 'imageBlock'; if (typeEntityId === VIDEO_TYPE || typeEntityId === VIDEO_BLOCK) return 'videoBlock'; + if (typeEntityId === PDF_TYPE) return 'pdfBlock'; if (typeEntityId === DATA_BLOCK) return 'dataBlock'; return null; } function getBlockTypeFromRelations( relations: readonly ApiVersionedRelation[] -): 'textBlock' | 'imageBlock' | 'videoBlock' | 'dataBlock' { +): 'textBlock' | 'imageBlock' | 'videoBlock' | 'pdfBlock' | 'dataBlock' { for (const rel of relations) { if (rel.typeId === TYPES_PROPERTY) { const blockType = classifyBlockType(rel.toEntityId); @@ -137,6 +139,15 @@ function snapshotBlockToChange(block: ApiBlockSnapshot): BlockChange { after: url, }; } + case 'pdfBlock': { + const url = block.values.find(v => v.propertyId === PDF_URL)?.text ?? null; + return { + id: block.id, + type: 'pdfBlock', + before: null, + after: url, + }; + } case 'dataBlock': { const name = block.values.find(v => v.propertyId === NAME_PROPERTY)?.text ?? null; return { diff --git a/apps/web/core/io/rest/schemas/diff-shared.ts b/apps/web/core/io/rest/schemas/diff-shared.ts index 4973171b69..5e8416f97f 100644 --- a/apps/web/core/io/rest/schemas/diff-shared.ts +++ b/apps/web/core/io/rest/schemas/diff-shared.ts @@ -37,6 +37,7 @@ export const ApiBlockDiffSchema = Schema.Struct({ Schema.Literal('textBlock'), Schema.Literal('imageBlock'), Schema.Literal('videoBlock'), + Schema.Literal('pdfBlock'), Schema.Literal('dataBlock') ), before: Schema.NullOr(Schema.String), diff --git a/apps/web/core/state/editor/block-types.ts b/apps/web/core/state/editor/block-types.ts index e923a4b161..71be5bfd7b 100644 --- a/apps/web/core/state/editor/block-types.ts +++ b/apps/web/core/state/editor/block-types.ts @@ -1,5 +1,6 @@ import { IdUtils, Position, SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { PDF_TYPE } from '~/core/constants'; import { EntityId } from '~/core/io/substream-schema'; import { Relation } from '~/core/types'; @@ -7,13 +8,15 @@ type BlockTypeId = | typeof SystemIds.TEXT_BLOCK | typeof SystemIds.IMAGE_TYPE | typeof SystemIds.DATA_BLOCK - | typeof SystemIds.VIDEO_TYPE; + | typeof SystemIds.VIDEO_TYPE + | typeof PDF_TYPE; const BLOCK_TYPE_NAMES: Record = { [SystemIds.TEXT_BLOCK]: 'Text Block', [SystemIds.IMAGE_TYPE]: 'Image', [SystemIds.DATA_BLOCK]: 'Data Block', [SystemIds.VIDEO_TYPE]: 'Video', + [PDF_TYPE]: 'PDF', }; export function getRelationForBlockType( diff --git a/apps/web/core/state/editor/types.ts b/apps/web/core/state/editor/types.ts index 090db8c57c..8bb414981e 100644 --- a/apps/web/core/state/editor/types.ts +++ b/apps/web/core/state/editor/types.ts @@ -45,6 +45,14 @@ export type Content = id: string; src: string; }; + } + | { + type: 'pdf'; + content: Content[]; + attrs: { + id: string; + src: string; + }; }; type Mark = { diff --git a/apps/web/core/state/editor/use-editor.tsx b/apps/web/core/state/editor/use-editor.tsx index cec7bbff10..7718ebc9a1 100644 --- a/apps/web/core/state/editor/use-editor.tsx +++ b/apps/web/core/state/editor/use-editor.tsx @@ -11,6 +11,7 @@ import { useSearchParams } from 'next/navigation'; import { storage } from '~/core/sync/use-mutate'; import { getRelations, getValues, useValues } from '~/core/sync/use-store'; import { Relation, RenderableEntityType, Value } from '~/core/types'; +import { PDF_TYPE, PDF_URL } from '~/core/constants'; import { getImagePath, getVideoPath, validateEntityId } from '~/core/utils/utils'; import type { ServerBlock } from '~/partials/editor/server-content'; @@ -100,6 +101,8 @@ function makeNewBlockRelation({ return 'IMAGE'; case 'video': return 'VIDEO'; + case 'pdf': + return 'PDF'; default: return 'TEXT'; } @@ -349,6 +352,30 @@ export function useEditorStore() { ]; } + if (toEntity?.type === 'PDF') { + // Read PDF URL from Values using PDF_URL property + const pdfUrlValues = getValues({ + mergeWith: initialBlockValues, + selector: value => value.entity.id === block.block.id && value.property.id === PDF_URL, + }); + const pdfUrlValue = pdfUrlValues?.[0]?.value || toEntity.value || ''; + const pdfSrc = pdfUrlValue ? getImagePath(pdfUrlValue) : ''; + + sBlocks.push({ type: 'pdf', src: pdfSrc }); + + return [ + { + type: 'pdf', + attrs: { + id: block.block.id, + src: pdfSrc, + relationId: block.relationId, + spaceId, + }, + }, + ]; + } + if (toEntity?.type === 'DATA') { sBlocks.push({ type: 'data' }); @@ -472,6 +499,8 @@ export function useEditorStore() { return SystemIds.IMAGE_TYPE; case 'video': return SystemIds.VIDEO_TYPE; + case 'pdf': + return PDF_TYPE; default: return SystemIds.TEXT_BLOCK; } @@ -498,6 +527,12 @@ export function useEditorStore() { storage.relations.set(relation); break; } + case PDF_TYPE: { + // Create a Types relation to mark this entity as a PDF type + const relation = getRelationForBlockType(node.id, PDF_TYPE, spaceId); + storage.relations.set(relation); + break; + } case SystemIds.DATA_BLOCK: { // @TODO(performance): upsertMany for (const relation of makeInitialDataEntityRelations(EntityId(node.id), spaceId)) { @@ -550,6 +585,9 @@ export function useEditorStore() { // Video block persistence is handled directly in video-node.tsx component // using storage.entities.name.set() for title break; + case 'pdf': + // PDF block persistence is handled directly in pdf-node.tsx component + break; default: break; } diff --git a/apps/web/core/sync/use-mutate.tsx b/apps/web/core/sync/use-mutate.tsx index e612d42301..acab04ce0a 100644 --- a/apps/web/core/sync/use-mutate.tsx +++ b/apps/web/core/sync/use-mutate.tsx @@ -7,8 +7,12 @@ import { DATA_TYPE_ENTITY_IDS, DATA_TYPE_PROPERTY, GEO_LOCATION, + MAX_PDF_SIZE_BYTES, + PDF_TYPE, + PDF_URL, PLACE, RENDERABLE_TYPE_PROPERTY, + VALID_PDF_TYPES, VIDEO_RENDERABLE_TYPE, } from '../constants'; import { ID } from '../id'; @@ -24,6 +28,7 @@ const RENDERABLE_TYPE_ENTITY_LABELS: Record = { [SystemIds.IMAGE]: 'Image', [VIDEO_RENDERABLE_TYPE]: 'Video', [GEO_LOCATION]: 'Geo Location', + [PDF_TYPE]: 'PDF', [PLACE]: 'Place', [ADDRESS]: 'Address', }; @@ -100,6 +105,16 @@ export interface Mutator { spaceId: string; }) => Promise<{ videoId: string; relationId: string }>; }; + pdfs: { + createAndLink: (params: { + file: File; + fromEntityId: string; + fromEntityName?: string | null; + relationPropertyId: string; + relationPropertyName: string | null; + spaceId: string; + }) => Promise<{ pdfId: string; relationId: string }>; + }; setAsPublished: (valueIds: string[], relationIds: string[]) => void; } @@ -567,6 +582,117 @@ function createMutator(store: GeoStore): Mutator { return { videoId: videoIdStr, relationId }; }, }, + pdfs: { + createAndLink: async ({ + file, + fromEntityId, + fromEntityName, + relationPropertyId, + relationPropertyName, + spaceId, + }) => { + if (!VALID_PDF_TYPES.includes(file.type)) { + throw new Error(`Invalid file type "${file.type}". Expected a PDF file.`); + } + + if (file.size > MAX_PDF_SIZE_BYTES) { + throw new Error(`File size ${file.size} exceeds the 50MB limit.`); + } + + // Upload the PDF file using the same mechanism as images/videos + const { id: pdfId, ops: createPdfOps } = await Graph.createImage({ + blob: file, + network: 'TESTNET', + }); + + let ipfsUrl: string | undefined; + for (const op of createPdfOps) { + if (op.type === 'createEntity') { + const ipfsValue = op.values.find(pv => { + const valStr = extractValueString(pv.value); + return valStr.startsWith('ipfs://'); + }); + if (ipfsValue) { + ipfsUrl = extractValueString(ipfsValue.value); + break; + } + } + } + + // Create a PDF entity with the IPFS URL + const pdfIdStr = toHexId(pdfId); + if (ipfsUrl) { + store.setValue({ + id: ID.createValueId({ + entityId: pdfIdStr, + propertyId: PDF_URL, + spaceId, + }), + entity: { + id: pdfIdStr, + name: null, + }, + property: { + id: PDF_URL, + name: 'PDF URL', + dataType: 'TEXT', + renderableType: 'URL', + }, + spaceId, + value: ipfsUrl, + }); + } + + // Add Types relation to mark the entity as a PDF type + store.setRelation({ + id: ID.createEntityId(), + entityId: ID.createEntityId(), + fromEntity: { + id: pdfIdStr, + name: null, + }, + type: { + id: SystemIds.TYPES_PROPERTY, + name: 'Types', + }, + toEntity: { + id: PDF_TYPE, + name: 'PDF', + value: PDF_TYPE, + }, + spaceId, + position: Position.generate(), + verified: false, + renderableType: 'RELATION', + }); + + // Create relation from parent entity to PDF entity + const relationId = ID.createEntityId(); + store.setRelation({ + id: relationId, + entityId: ID.createEntityId(), + fromEntity: { + id: fromEntityId, + name: fromEntityName || '', + }, + type: { + id: relationPropertyId, + name: relationPropertyName || '', + }, + toEntity: { + id: pdfIdStr, + name: null, + value: pdfIdStr, + }, + spaceId, + position: Position.generate(), + verified: false, + renderableType: 'PDF', + }); + + return { pdfId: pdfIdStr, relationId }; + }, + }, setAsPublished: (valueIds, relationIds) => { store.setAsPublished(valueIds, relationIds); }, diff --git a/apps/web/core/types.ts b/apps/web/core/types.ts index 83252f29ae..932ce34e06 100644 --- a/apps/web/core/types.ts +++ b/apps/web/core/types.ts @@ -86,10 +86,10 @@ export type DataType = // Renderable Types // ============================================================================== -export type RenderableType = 'IMAGE' | 'VIDEO' | 'URL' | 'GEO_LOCATION' | 'PLACE' | 'ADDRESS'; // GEO_LOCATION needs to be migrated to SDK +export type RenderableType = 'IMAGE' | 'VIDEO' | 'PDF' | 'URL' | 'GEO_LOCATION' | 'PLACE' | 'ADDRESS'; // GEO_LOCATION needs to be migrated to SDK export type RawRenderableType = string; // UUIDs of renderable types -export type RenderableEntityType = 'IMAGE' | 'VIDEO' | 'RELATION' | 'DATA' | 'TEXT' | 'POINT'; +export type RenderableEntityType = 'IMAGE' | 'VIDEO' | 'PDF' | 'RELATION' | 'DATA' | 'TEXT' | 'POINT'; // Renderable fields are a special data model to represent us rendering both // triples and relations in the same way. This is used across tables and entity @@ -113,6 +113,7 @@ export type FlattenedRenderType = | 'RELATION' | 'IMAGE' | 'VIDEO' + | 'PDF' | 'PLACE' | 'ADDRESS'; @@ -128,6 +129,7 @@ export type SwitchableRenderableType = | 'TIME' | 'IMAGE' | 'VIDEO' + | 'PDF' | 'BOOLEAN' | 'INTEGER' | 'FLOAT' @@ -146,6 +148,7 @@ export const SWITCHABLE_RENDERABLE_TYPE_LABELS: Record = new Set([VIEW_PROPERTY, SHOWN_COLUMNS, PROPERTIES]); @@ -265,7 +266,7 @@ export async function postProcessDiffs( // Collect media URLs per entity and inject into parent relations for ImagePropertyCell/VideoPropertyCell rendering. const mediaPropertyEntityUrls = new Map< string, - { before: string | null; after: string | null; mediaType: 'image' | 'video' } + { before: string | null; after: string | null; mediaType: 'image' | 'video' | 'pdf' } >(); for (const entityId of mediaPropertyEntityIds) { const entity = entityMap.get(entityId); @@ -277,12 +278,14 @@ export async function postProcessDiffs( v => (v.after && v.after.startsWith('ipfs://')) || (v.before && v.before.startsWith('ipfs://')) ); if (mediaValue) { - let mediaType: 'image' | 'video' = 'image'; + let mediaType: 'image' | 'video' | 'pdf' = 'image'; for (const rel of entity.relations) { if (rel.typeId === TYPES_PROPERTY) { const typeId = rel.after?.toEntityId ?? rel.before?.toEntityId; if (typeId === VIDEO_TYPE || typeId === VIDEO_BLOCK) { mediaType = 'video'; + } else if (typeId === PDF_TYPE) { + mediaType = 'pdf'; } } } @@ -484,7 +487,7 @@ export async function postProcessDiffs( // b) fetchedEntityMap — entity was fetched in step 4 for name resolution // (history/single-entity path where the media property entity is a foreign entity // not included in the input, but already fetched to resolve its name) - type MediaUrlResult = { url: string; mediaType: 'image' | 'video' } | null; + type MediaUrlResult = { url: string; mediaType: 'image' | 'video' | 'pdf' } | null; const resolveMediaUrl = (targetId: string, side: 'after' | 'before'): MediaUrlResult => { // (a) entity was in the input diff — use the before/after from its diff values @@ -506,15 +509,22 @@ export async function postProcessDiffs( if (fetched && !blocksWithParent.has(targetId)) { const isImage = fetched.types.some(t => t.id === IMAGE_TYPE || t.id === IMAGE_BLOCK); const isVideo = fetched.types.some(t => t.id === VIDEO_TYPE || t.id === VIDEO_BLOCK); - if (isImage || isVideo) { + const isPdf = fetched.types.some(t => t.id === PDF_TYPE); + if (isImage || isVideo || isPdf) { const url = resolveImageUrlFromEntity(fetched); - if (url) return { url, mediaType: isVideo ? 'video' : 'image' }; + if (url) return { url, mediaType: isVideo ? 'video' : isPdf ? 'pdf' : 'image' }; } } return null; }; + const mediaUrlFields = (media: { url: string; mediaType: string }) => { + if (media.mediaType === 'video') return { videoUrl: media.url }; + if (media.mediaType === 'pdf') return { pdfUrl: media.url }; + return { imageUrl: media.url }; + }; + let processed: EntityDiff[] = entities .filter(e => !mergedBlockRelEntityIds.has(e.entityId) && !mediaPropertyEntityIds.has(e.entityId)) .map(e => { @@ -528,7 +538,7 @@ export async function postProcessDiffs( ? { after: { ...r.after, - ...(afterMedia.mediaType === 'image' ? { imageUrl: afterMedia.url } : { videoUrl: afterMedia.url }), + ...mediaUrlFields(afterMedia), }, } : {}), @@ -536,9 +546,7 @@ export async function postProcessDiffs( ? { before: { ...r.before, - ...(beforeMedia.mediaType === 'image' - ? { imageUrl: beforeMedia.url } - : { videoUrl: beforeMedia.url }), + ...mediaUrlFields(beforeMedia), }, } : {}), @@ -721,6 +729,17 @@ export function entityDiffToBlockChange(entity: EntityDiff): BlockChange | null }; } + if (blockType === 'pdfBlock') { + const contentValue = entity.values.find(v => v.propertyId === PDF_URL); + + return { + id: entity.entityId, + type: 'pdfBlock', + before: contentValue?.before ?? null, + after: contentValue?.after ?? null, + }; + } + const contentValue = entity.values.find(v => v.propertyId === MARKDOWN_CONTENT) ?? entity.values.find(v => v.type === 'TEXT'); const before = contentValue?.before ?? null; @@ -736,13 +755,14 @@ export function entityDiffToBlockChange(entity: EntityDiff): BlockChange | null } as TextBlockChange; } -export function detectBlockType(entity: EntityDiff): 'textBlock' | 'imageBlock' | 'videoBlock' | 'dataBlock' { +export function detectBlockType(entity: EntityDiff): 'textBlock' | 'imageBlock' | 'videoBlock' | 'pdfBlock' | 'dataBlock' { for (const rel of entity.relations) { if (rel.typeId === TYPES_PROPERTY) { const typeId = rel.after?.toEntityId ?? rel.before?.toEntityId; if (typeId === TEXT_BLOCK) return 'textBlock'; if (typeId === IMAGE_BLOCK || typeId === IMAGE_TYPE) return 'imageBlock'; if (typeId === VIDEO_TYPE || typeId === VIDEO_BLOCK) return 'videoBlock'; + if (typeId === PDF_TYPE) return 'pdfBlock'; if (typeId === DATA_BLOCK) return 'dataBlock'; } } @@ -796,6 +816,7 @@ export async function fromLocal( // or VIDEO_TYPE but are NOT blocks. const imageEntityIds = new Set(); const videoEntityIds = new Set(); + const pdfEntityIds = new Set(); for (const relation of localRelations) { if (relation.renderableType === 'IMAGE' && !actualBlockEntityIds.has(relation.toEntity.id)) { imageEntityIds.add(relation.toEntity.id); @@ -803,10 +824,13 @@ export async function fromLocal( if (relation.renderableType === 'VIDEO' && !actualBlockEntityIds.has(relation.toEntity.id)) { videoEntityIds.add(relation.toEntity.id); } + if (relation.renderableType === 'PDF' && !actualBlockEntityIds.has(relation.toEntity.id)) { + pdfEntityIds.add(relation.toEntity.id); + } } const remoteEntities = new Map(); - const idsToFetch = new Set([...allChangedEntityIds, ...mediaTargetEntityIds, ...imageEntityIds, ...videoEntityIds]); + const idsToFetch = new Set([...allChangedEntityIds, ...mediaTargetEntityIds, ...imageEntityIds, ...videoEntityIds, ...pdfEntityIds]); if (idsToFetch.size > 0) { try { @@ -853,6 +877,7 @@ export async function fromLocal( type MediaSides = { before: string | null; after: string | null }; const imageEntityUrls = new Map(); const videoEntityUrls = new Map(); + const pdfEntityUrls = new Map(); for (const diff of diffs) { const resolveMediaValue = () => diff.values.find(v => v.propertyId === IMAGE_URL_PROPERTY && (v.after || v.before)) ?? @@ -867,6 +892,11 @@ export async function fromLocal( if (ipfsValue) videoEntityUrls.set(diff.entityId, { before: ipfsValue.before ?? null, after: ipfsValue.after ?? null }); } + if (pdfEntityIds.has(diff.entityId)) { + const ipfsValue = resolveMediaValue(); + if (ipfsValue) + pdfEntityUrls.set(diff.entityId, { before: ipfsValue.before ?? null, after: ipfsValue.after ?? null }); + } } // Fall back to remote entity for media entities with no local changes. for (const entityId of imageEntityIds) { @@ -881,8 +911,14 @@ export async function fromLocal( if (url) videoEntityUrls.set(entityId, { before: url, after: url }); } } + for (const entityId of pdfEntityIds) { + if (!pdfEntityUrls.has(entityId)) { + const url = resolveImageUrlFromEntity(remoteEntities.get(entityId)); + if (url) pdfEntityUrls.set(entityId, { before: url, after: url }); + } + } - const mediaPropertyEntityIds = new Set([...imageEntityIds, ...videoEntityIds]); + const mediaPropertyEntityIds = new Set([...imageEntityIds, ...videoEntityIds, ...pdfEntityIds]); const filteredDiffs = diffs .filter(d => !mediaPropertyEntityIds.has(d.entityId)) @@ -893,8 +929,11 @@ export async function fromLocal( const beforeImageUrl = r.before ? (imageEntityUrls.get(r.before.toEntityId)?.before ?? null) : null; const afterVideoUrl = r.after ? (videoEntityUrls.get(r.after.toEntityId)?.after ?? null) : null; const beforeVideoUrl = r.before ? (videoEntityUrls.get(r.before.toEntityId)?.before ?? null) : null; + const afterPdfUrl = r.after ? (pdfEntityUrls.get(r.after.toEntityId)?.after ?? null) : null; + const beforePdfUrl = r.before ? (pdfEntityUrls.get(r.before.toEntityId)?.before ?? null) : null; - if (!afterImageUrl && !beforeImageUrl && !afterVideoUrl && !beforeVideoUrl) return r; + if (!afterImageUrl && !beforeImageUrl && !afterVideoUrl && !beforeVideoUrl && !afterPdfUrl && !beforePdfUrl) + return r; return { ...r, @@ -902,6 +941,8 @@ export async function fromLocal( ...(r.before && beforeImageUrl ? { before: { ...r.before, imageUrl: beforeImageUrl } } : {}), ...(r.after && afterVideoUrl ? { after: { ...r.after, videoUrl: afterVideoUrl } } : {}), ...(r.before && beforeVideoUrl ? { before: { ...r.before, videoUrl: beforeVideoUrl } } : {}), + ...(r.after && afterPdfUrl ? { after: { ...r.after, pdfUrl: afterPdfUrl } } : {}), + ...(r.before && beforePdfUrl ? { before: { ...r.before, pdfUrl: beforePdfUrl } } : {}), }; }), })); diff --git a/apps/web/core/utils/diff/types.ts b/apps/web/core/utils/diff/types.ts index 8e8409d842..80d7d55ff1 100644 --- a/apps/web/core/utils/diff/types.ts +++ b/apps/web/core/utils/diff/types.ts @@ -56,6 +56,7 @@ export interface RelationChange { position?: string | null; imageUrl?: string | null; videoUrl?: string | null; + pdfUrl?: string | null; } | null; after?: { toEntityId: string; @@ -64,6 +65,7 @@ export interface RelationChange { position?: string | null; imageUrl?: string | null; videoUrl?: string | null; + pdfUrl?: string | null; } | null; } @@ -89,6 +91,13 @@ export interface VideoBlockChange { after: string | null; } +export interface PdfBlockChange { + id: string; + type: 'pdfBlock'; + before: string | null; + after: string | null; +} + export interface DataBlockChange { id: string; type: 'dataBlock'; @@ -99,7 +108,7 @@ export interface DataBlockChange { relations?: RelationChange[]; } -export type BlockChange = TextBlockChange | ImageBlockChange | VideoBlockChange | DataBlockChange; +export type BlockChange = TextBlockChange | ImageBlockChange | VideoBlockChange | PdfBlockChange | DataBlockChange; export interface EntityDiff { entityId: string; diff --git a/apps/web/core/utils/property/properties.test.ts b/apps/web/core/utils/property/properties.test.ts index ee0486f6a9..7f42767df9 100644 --- a/apps/web/core/utils/property/properties.test.ts +++ b/apps/web/core/utils/property/properties.test.ts @@ -5,7 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DATA_TYPE_PROPERTY, RENDERABLE_TYPE_PROPERTY } from '~/core/constants'; import { Property, Relation, SwitchableRenderableType } from '~/core/types'; -import { constructDataType, getCurrentRenderableType, mapPropertyType, reconstructFromStore } from './properties'; +import { + constructDataType, + getCurrentRenderableType, + mapPropertyType, + reconstructFromStore, + typeToBaseDataType, +} from './properties'; // Mock the DTO module — use real SDK IDs so tests match actual usage vi.mock('~/core/io/dto/properties', () => ({ @@ -47,6 +53,7 @@ vi.mock('~/core/constants', () => ({ EMBEDDING: 'EMBEDDING_ENTITY_ID', }, GEO_LOCATION: 'GEO_LOCATION_ID', + PDF_TYPE: 'PDF_TYPE_ID', FORMAT_PROPERTY: 'FORMAT_PROPERTY_ID', UNIT_PROPERTY: 'UNIT_PROPERTY_ID', VIDEO_RENDERABLE_TYPE: 'VIDEO_RENDERABLE_TYPE_ID', @@ -128,6 +135,15 @@ describe('Properties', () => { }); }); + it('should map PDF property type correctly', () => { + const result = mapPropertyType('PDF'); + expect(result).toEqual({ + baseDataType: 'RELATION', + renderableTypeId: 'PDF_TYPE_ID', + }); + expect(typeToBaseDataType.PDF).toBe('RELATION'); + }); + it('should handle unknown property type with exhaustive check', () => { // This test ensures our switch statement handles unknown types const unknownType = 'UNKNOWN_TYPE' as SwitchableRenderableType; diff --git a/apps/web/core/utils/property/properties.ts b/apps/web/core/utils/property/properties.ts index 0130fc6cc2..fcc35d5d05 100644 --- a/apps/web/core/utils/property/properties.ts +++ b/apps/web/core/utils/property/properties.ts @@ -6,6 +6,7 @@ import { DATA_TYPE_PROPERTY, FORMAT_PROPERTY, GEO_LOCATION, + PDF_TYPE, PLACE, RELATION_ENTITY_RELATIONSHIP_TYPE, RENDERABLE_TYPE_PROPERTY, @@ -81,6 +82,11 @@ export function mapPropertyType(type: SwitchableRenderableType): PropertyTypeMap baseDataType: 'RELATION', renderableTypeId: VIDEO_RENDERABLE_TYPE, }; + case 'PDF': + return { + baseDataType: 'RELATION', + renderableTypeId: PDF_TYPE, + }; // GRC-20 v2 numeric types case 'INTEGER': return { @@ -155,6 +161,7 @@ export const typeToBaseDataType: Record = { RELATION: 'RELATION', IMAGE: 'RELATION', VIDEO: 'RELATION', + PDF: 'RELATION', // GRC-20 v2 numeric types INTEGER: 'INTEGER', FLOAT: 'FLOAT', diff --git a/apps/web/core/utils/use-entity-media.ts b/apps/web/core/utils/use-entity-media.ts index 3b5b20b929..d736218fe3 100644 --- a/apps/web/core/utils/use-entity-media.ts +++ b/apps/web/core/utils/use-entity-media.ts @@ -21,6 +21,18 @@ export function useImageUrlFromEntity(imageEntityId: string | undefined, spaceId return imageUrlValue?.value; } +export function usePdfUrlFromEntity(pdfEntityId: string | undefined, spaceId: string): string | undefined { + const pdfValues = useValues({ + selector: v => v.entity.id === pdfEntityId && v.spaceId === spaceId, + }); + + if (!pdfEntityId || pdfValues.length === 0) return undefined; + + const pdfUrlValue = pdfValues.find(v => typeof v.value === 'string' && v.value.startsWith('ipfs://')); + + return pdfUrlValue?.value; +} + export function useVideoUrlFromEntity(videoEntityId: string | undefined, spaceId: string): string | undefined { const videoValues = useValues({ selector: v => v.entity.id === videoEntityId && v.spaceId === spaceId, diff --git a/apps/web/design-system/chip.tsx b/apps/web/design-system/chip.tsx index 4b9e030d71..7bc025f53a 100644 --- a/apps/web/design-system/chip.tsx +++ b/apps/web/design-system/chip.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import { cva } from 'class-variance-authority'; +import { useImageWithFallback } from '~/core/hooks/use-image-with-fallback'; import { useSpace } from '~/core/hooks/use-space'; import { useVideoWithFallback } from '~/core/hooks/use-video-with-fallback'; import { EntityId } from '~/core/io/substream-schema'; @@ -17,6 +18,7 @@ import { GeoImage } from '~/design-system/geo-image'; import { CheckCircle } from '~/design-system/icons/check-circle'; import { CheckCloseSmall } from '~/design-system/icons/check-close-small'; import { Create as CreateIcon } from '~/design-system/icons/create'; +import { PdfFile } from '~/design-system/icons/file-pdf'; import { Image as ImageIcon } from '~/design-system/icons/image'; import { RelationSmall } from '~/design-system/icons/relation-small'; import { TopRanked } from '~/design-system/icons/top-ranked'; @@ -354,7 +356,7 @@ function RelationDots({ color }: RelationDotsProps) { type LinkableMediaChipProps = { isEditing: boolean; - mediaType: 'IMAGE' | 'VIDEO'; + mediaType: 'IMAGE' | 'VIDEO' | 'PDF'; mediaSrc?: string; isUploading?: boolean; @@ -420,6 +422,10 @@ export function LinkableMediaChip({ // For videos, use the video fallback hook to get the correct URL const { src: videoSrc, onError: onVideoError } = useVideoWithFallback(mediaType === 'VIDEO' ? mediaSrc : undefined); + // For PDFs, use the image fallback hook to resolve the IPFS URL + const { src: pdfSrc } = useImageWithFallback(mediaType === 'PDF' ? mediaSrc ?? '' : ''); + + const containerSize = mediaType === 'PDF' ? 'h-[200px] w-[173px]' : 'h-20 w-20'; return (
-
- {mediaType === 'VIDEO' ? ( +
+ {mediaType === 'PDF' ? ( + pdfSrc ? ( +
+ +
+ ) : ( +
+ +
+ ) + ) : mediaType === 'VIDEO' ? ( videoSrc ? (