From a42cb481a2b63a529773cc52de6e780c9b713f21 Mon Sep 17 00:00:00 2001 From: Ivan Velychko Date: Thu, 15 Jan 2026 21:49:56 +0200 Subject: [PATCH 1/7] feat: implement pdf upload feature - updated Mutator interface to support pdfs upload - updated editable and readable pages to support PDF type render - update RenderableType, FlattenedRenderType, SwitchableRenderableType to include PDF --- apps/web/core/constants.ts | 2 + apps/web/core/io/dto/properties.ts | 4 +- apps/web/core/sync/use-mutate.tsx | 104 ++++++++++++++++++ apps/web/core/utils/property/properties.ts | 7 ++ apps/web/core/v2.types.ts | 11 +- .../editable-fields/editable-fields.tsx | 48 ++++++++ .../entity-page/editable-entity-page.tsx | 27 ++++- .../entity-page/readable-entity-page.tsx | 2 +- .../entity-page/renderable-type-dropdown.tsx | 2 + 9 files changed, 197 insertions(+), 10 deletions(-) diff --git a/apps/web/core/constants.ts b/apps/web/core/constants.ts index 9073cd08a5..5f728e696b 100644 --- a/apps/web/core/constants.ts +++ b/apps/web/core/constants.ts @@ -17,6 +17,8 @@ export const ROOT_SPACE = '2a98e6b4-3728-44a4-9b8e-02e15f0677c8'; export const GEO_LOCATION = '9cf5c1b0-15dc-451c-bfd2-97db64806aff'; export const FORMAT_PROPERTY = '396f8c72-dfd0-4b57-91ea-09c1b9321b2f'; +export const PDF_TYPE = '14a39e59-d987-4596-956a-c2dd4165c210'; +export const PDF_URL = '9d69ac47-7023-43e2-b72a-91a335eb54d9'; export const PLACE = '783bc688-e65f-4e54-b67f-a5643d78345e'; export const ADDRESS = '5c6e72fb-8340-47c0-8281-8be159ecd495'; export const VENUE_PROPERTY = 'f28bbb6b-f4e8-465d-9de7-a09085e224b9'; diff --git a/apps/web/core/io/dto/properties.ts b/apps/web/core/io/dto/properties.ts index a53e36f2cf..47f0f2fc1f 100644 --- a/apps/web/core/io/dto/properties.ts +++ b/apps/web/core/io/dto/properties.ts @@ -1,6 +1,6 @@ import { SystemIds } from '@graphprotocol/grc-20'; -import { GEO_LOCATION } from '~/core/constants'; +import { GEO_LOCATION, PDF_TYPE } from '~/core/constants'; import { DataType as AppDataType, Property } from '~/core/v2.types'; import { DataType, RemoteProperty } from '../v2/v2.schema'; @@ -29,6 +29,8 @@ export function getStrictRenderableType(renderableType: RemoteProperty['renderab return 'URL'; case GEO_LOCATION: return 'GEO_LOCATION'; + case PDF_TYPE: + return 'PDF'; default: return undefined; } diff --git a/apps/web/core/sync/use-mutate.tsx b/apps/web/core/sync/use-mutate.tsx index aa92745f55..93ab542182 100644 --- a/apps/web/core/sync/use-mutate.tsx +++ b/apps/web/core/sync/use-mutate.tsx @@ -2,6 +2,7 @@ import { Graph, Position, SystemIds } from '@graphprotocol/grc-20'; import { Draft, produce } from 'immer'; import { DATA_TYPE_PROPERTY, RENDERABLE_TYPE_PROPERTY } from '../constants'; +import { PDF_TYPE, PDF_URL } from '../constants'; import { ID } from '../id'; import { OmitStrict } from '../types'; import { DataType, Relation, Value } from '../v2.types'; @@ -66,6 +67,16 @@ export interface Mutator { spaceId: string; }) => Promise<{ imageId: string; relationId: string }>; }; + pdfs: { + createAndLink: (params: { + file: File; + fromEntityId: string; + fromEntityName?: string | null; + relationPropertyId: string; + relationPropertyName: string | null; + spaceId: string; + }) => Promise<{ imageId: string; relationId: string }>; + }; setAsPublished: (valueIds: string[], relationIds: string[]) => void; } @@ -339,6 +350,99 @@ function createMutator(store: GeoStore): Mutator { return { imageId, relationId }; }, }, + pdfs: { + createAndLink: async ({ + file, + fromEntityId, + fromEntityName, + relationPropertyId, + relationPropertyName, + spaceId, + }) => { + // Create the image entity using the Graph API + // Use TESTNET network to upload to Pinata via alternative gateway + const { id: pdfId, ops: createImageOps } = await Graph.createImage({ + blob: file, + network: 'TESTNET', + }); + + // Process the operations returned by Graph.createImage + for (const op of createImageOps) { + if (op.type === 'CREATE_RELATION') { + store.setRelation({ + id: op.relation.id, + entityId: op.relation.entity, + fromEntity: { + id: op.relation.fromEntity, + name: null, + }, + type: { + id: op.relation.type, + name: 'PDF', + }, + toEntity: { + id: PDF_TYPE, + name: 'PDF', + value: PDF_TYPE, + }, + spaceId, + position: Position.generate(), + verified: false, + renderableType: 'RELATION', + }); + } else if (op.type === 'UPDATE_ENTITY') { + // Create values for each property in the entity update + for (const value of op.entity.values) { + store.setValue({ + id: ID.createValueId({ + entityId: op.entity.id, + propertyId: PDF_URL, + spaceId, + }), + entity: { + id: op.entity.id, + name: null, + }, + property: { + id: PDF_URL, + name: 'PDF Property', + dataType: 'TEXT', + renderableType: 'URL', + }, + spaceId, + value: value.value, + }); + } + } + } + + // Create relation from parent entity to image entity + const relationId = ID.createEntityId(); + store.setRelation({ + id: relationId, + entityId: ID.createEntityId(), + fromEntity: { + id: fromEntityId, + name: fromEntityName || '', + }, + type: { + id: relationPropertyId, + name: relationPropertyName || '', + }, + toEntity: { + id: pdfId, + name: null, + value: pdfId, + }, + spaceId, + position: Position.generate(), + verified: false, + renderableType: 'PDF', + }); + + return { imageId: pdfId, relationId }; + }, + }, setAsPublished: (valueIds, relationIds) => { store.setAsPublished(valueIds, relationIds); }, diff --git a/apps/web/core/utils/property/properties.ts b/apps/web/core/utils/property/properties.ts index 66168bdfb8..b5759b6fec 100644 --- a/apps/web/core/utils/property/properties.ts +++ b/apps/web/core/utils/property/properties.ts @@ -4,6 +4,7 @@ import { DATA_TYPE_PROPERTY, FORMAT_PROPERTY, GEO_LOCATION, + PDF_TYPE, PLACE, RENDERABLE_TYPE_PROPERTY, UNIT_PROPERTY, @@ -78,6 +79,11 @@ export function mapPropertyType(type: SwitchableRenderableType): PropertyTypeMap baseDataType: 'RELATION', renderableTypeId: PLACE, }; + case 'PDF': + return { + baseDataType: 'RELATION', + renderableTypeId: PDF_TYPE, + }; default: { // This ensures exhaustive type checking const _exhaustiveCheck: never = type; @@ -104,6 +110,7 @@ export const typeToBaseDataType: Record = { POINT: 'POINT', GEO_LOCATION: 'POINT', PLACE: 'RELATION', + PDF: 'RELATION', } as const; /** diff --git a/apps/web/core/v2.types.ts b/apps/web/core/v2.types.ts index a435e95963..ac30387962 100644 --- a/apps/web/core/v2.types.ts +++ b/apps/web/core/v2.types.ts @@ -1,5 +1,5 @@ export type DataType = 'TEXT' | 'NUMBER' | 'CHECKBOX' | 'TIME' | 'POINT' | 'RELATION'; -export type RenderableType = 'IMAGE' | 'URL' | 'GEO_LOCATION'; // GEO_LOCATION needs to be migrated to SDK +export type RenderableType = 'IMAGE' | 'URL' | 'GEO_LOCATION' | 'PDF'; // GEO_LOCATION needs to be migrated to SDK export type RawRenderableType = string; // UUIDs of renderable types export type Property = { @@ -104,7 +104,7 @@ export type Entity = { export type EntityWithSchema = Entity & { schema: Property[] }; -export type RenderableEntityType = 'IMAGE' | 'RELATION' | 'DATA' | 'TEXT' | 'POINT'; +export type RenderableEntityType = 'IMAGE' | 'RELATION' | 'DATA' | 'TEXT' | 'POINT' | 'PDF'; // 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 @@ -122,7 +122,8 @@ export type FlattenedRenderType = | 'URL' | 'GEO_LOCATION' | 'RELATION' - | 'IMAGE'; + | 'IMAGE' + | 'PDF'; // The types of renderables don't map 1:1 to the triple value types. We might // also render relations with a specific type, e.g., an Image entity or a @@ -137,7 +138,8 @@ export type SwitchableRenderableType = | 'NUMBER' | 'POINT' | 'GEO_LOCATION' - | 'PLACE'; + | 'PLACE' + | 'PDF'; /** * Human-readable labels for switchable renderable types @@ -153,6 +155,7 @@ export const SWITCHABLE_RENDERABLE_TYPE_LABELS: Record(null); + const handleFileInputClick = () => { + // This is a hack to get around label htmlFor triggering a file input not working with nested React components. + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleChange = async (e: ChangeEvent) => { + if (e.target.files && onFileChange) { + const file = e.target.files[0]; + setIsUploading(true); + try { + await onFileChange(file); + } finally { + setIsUploading(false); + } + } + }; + + return ( +
+ {imageSrc && ( +
+ +
+ )} +
+ : }> + {isUploading ? 'Uploading...' : 'Upload'} + + {imageSrc && } />} +
+ + +
+ ); +} + export function TableImageField({ imageSrc, onFileChange, onImageRemove, variant = 'avatar' }: ImageFieldProps) { const [isUploading, setIsUploading] = React.useState(false); const fileInputRef = useRef(null); diff --git a/apps/web/partials/entity-page/editable-entity-page.tsx b/apps/web/partials/entity-page/editable-entity-page.tsx index 88699ce48f..a814e34810 100644 --- a/apps/web/partials/entity-page/editable-entity-page.tsx +++ b/apps/web/partials/entity-page/editable-entity-page.tsx @@ -7,6 +7,7 @@ import { DATA_TYPE_PROPERTY, FORMAT_PROPERTY, IS_TYPE_PROPERTY, + PDF_TYPE, RENDERABLE_TYPE_PROPERTY, VALUE_TYPE_PROPERTY, } from '~/core/constants'; @@ -25,7 +26,7 @@ import { AddTypeButton, SquareButton } from '~/design-system/button'; import { Checkbox, getChecked } from '~/design-system/checkbox'; import { LinkableRelationChip } from '~/design-system/chip'; import { DateField } from '~/design-system/editable-fields/date-field'; -import { ImageZoom, PageImageField, PageStringField } from '~/design-system/editable-fields/editable-fields'; +import { ImageZoom, PageImageField, PageStringField, PdfField } from '~/design-system/editable-fields/editable-fields'; import { GeoLocationPointFields, GeoLocationWrapper } from '~/design-system/editable-fields/geo-location-field'; import { NumberField } from '~/design-system/editable-fields/number-field'; import { Create } from '~/design-system/icons/create'; @@ -207,7 +208,7 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) }); // For IMAGE properties, get the image URL from related image entities - const imageRelation = relations.find(r => r.renderableType === 'IMAGE'); + const imageRelation = relations.find(r => r.renderableType === 'IMAGE' || r.renderableType === 'PDF'); const imageEntityId = imageRelation?.toEntity.id; // Use the efficient hook to get only the image URL for this specific entity @@ -253,6 +254,24 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) onImageRemove={() => console.log(`remove`)} /> + ) : property.renderableTypeStrict === 'PDF' ? ( +
+ { + // Use the consolidated helper to create and link the image + await storage.pdfs.createAndLink({ + file, + fromEntityId: id, + fromEntityName: name, + relationPropertyId: propertyId, + relationPropertyName: typeOfName, + spaceId, + }); + }} + onImageRemove={() => console.log(`remove`)} + /> +
) : propertyId === VENUE_PROPERTY ? (
- {property.renderableTypeStrict === 'IMAGE' ? ( + {property.renderableTypeStrict === 'IMAGE' || property.renderableTypeStrict === 'PDF' ? ( relations.map(r => { const relationId = r.id; const relationName = r.toEntity.name; @@ -442,7 +461,7 @@ export function RelationsGroup({ propertyId, id, spaceId }: RelationsGroupProps) /> )} - {property.renderableType !== SystemIds.IMAGE && ( + {property.renderableType === SystemIds.IMAGE || property.renderableType === PDF_TYPE ? null : (
{ From d6a323f29c95c1ca1057132c3052eca1854de92c Mon Sep 17 00:00:00 2001 From: Ivan Velychko Date: Sat, 17 Jan 2026 12:57:55 +0200 Subject: [PATCH 2/7] feat: added uploading pdf state and renderable pdf zoom --- .../editable-fields/editable-fields.tsx | 23 ++++++++++++++++--- .../web/design-system/uploading-pdf-state.tsx | 13 +++++++++++ .../entity-page/editable-entity-page.tsx | 1 - .../entity-page/readable-entity-page.tsx | 21 +++++++++++++++-- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 apps/web/design-system/uploading-pdf-state.tsx diff --git a/apps/web/design-system/editable-fields/editable-fields.tsx b/apps/web/design-system/editable-fields/editable-fields.tsx index b2c50f5fac..2bc98d5449 100644 --- a/apps/web/design-system/editable-fields/editable-fields.tsx +++ b/apps/web/design-system/editable-fields/editable-fields.tsx @@ -9,6 +9,7 @@ import { useOptimisticValueWithSideEffect } from '~/core/hooks/use-debounced-val import { useImageWithFallback } from '~/core/hooks/use-image-with-fallback'; import { SmallButton, SquareButton } from '~/design-system/button'; +import { UploadingPdfState } from '~/design-system/uploading-pdf-state'; import { Dots } from '../dots'; import { Trash } from '../icons/trash'; @@ -150,6 +151,16 @@ export function ImageZoom({ imageSrc, variant = 'default' }: ImageZoomProps) { ); } +export function PdfZoom({ pdfSrc }: { pdfSrc: string }) { + const { src, onError } = useImageWithFallback(pdfSrc); + + return ( +
+