diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 4c62545..629e144 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -61,84 +61,86 @@ export const DepotObjectSchema = z.object({ }); export type DepotObject = z.infer; -// --- Particle data schemas --- +// --- Particle property schemas --- -export const StreamParticleDataSchema = z.object({ +export const StreamPropertiesSchema = z.object({ name: z.string(), status: z.enum(["open", "closed"]), description: z.string().optional(), }); -export type StreamParticleData = z.infer; +export type StreamProperties = z.infer; -export const FolderParticleDataSchema = z.object({ +export const FolderPropertiesSchema = z.object({ name: z.string(), color: z.string().optional(), }); -export type FolderParticleData = z.infer; +export type FolderProperties = z.infer; -export const MediaParticleDataSchema = z.object({ +export const MediaPropertiesSchema = z.object({ object_id: z.string(), mime_type: z.string(), duration_ms: z.number(), size_bytes: z.number(), }); -export type MediaParticleData = z.infer; +export type MediaProperties = z.infer; -export const FileParticleDataSchema = z.object({ +export const FilePropertiesSchema = z.object({ object_id: z.string(), filename: z.string(), mime_type: z.string(), size_bytes: z.number(), }); -export type FileParticleData = z.infer; +export type FileProperties = z.infer; -export const TextParticleDataSchema = z.object({ +export const TextPropertiesSchema = z.object({ content: z.string(), }); -export type TextParticleData = z.infer; +export type TextProperties = z.infer; -export const QuestParticleDataSchema = z.object({ +export const QuestPropertiesSchema = z.object({ title: z.string(), description: z.string(), status: z.string().optional(), assigned_to: z.string().email().optional(), }); -export type QuestParticleData = z.infer; +export type QuestProperties = z.infer; -export const PaperParticleDataSchema = z.object({ +export const PaperPropertiesSchema = z.object({ title: z.string(), content: z.string(), }); -export type PaperParticleData = z.infer; +export type PaperProperties = z.infer; -export interface ParticleDataMap { - stream: StreamParticleData; - folder: FolderParticleData; - media: MediaParticleData; - file: FileParticleData; - text: TextParticleData; - quest: QuestParticleData; - paper: PaperParticleData; +export interface ParticlePropertiesMap { + stream: StreamProperties; + folder: FolderProperties; + media: MediaProperties; + file: FileProperties; + text: TextProperties; + quest: QuestProperties; + paper: PaperProperties; } // --- Unified Particle types --- -interface ParticleBase { - id: string; - created_at: Date; - created_by: string; -} +const ParticleBaseSchema = z.object({ + id: z.string(), + created_at: z.coerce.date(), + created_by_email: z.string().email(), + updated_at: z.coerce.date().optional() +}); -export type Particle = ParticleBase & - ( - | { type: "stream"; data: StreamParticleData } - | { type: "folder"; data: FolderParticleData } - | { type: "media"; data: MediaParticleData } - | { type: "file"; data: FileParticleData } - | { type: "text"; data: TextParticleData } - | { type: "quest"; data: QuestParticleData } - | { type: "paper"; data: PaperParticleData } - ); +export const ParticleSchema = z.discriminatedUnion("type", [ + ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }), + ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }), +]); + +export type Particle = z.infer; export type ParticleType = Particle["type"]; diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 61919f8..836ca16 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -15,6 +15,22 @@ export function StreamView({ networkId, particleSegments, streamParticle }: Stre

Stream view — {networkId}/{particleSegments.join("/")}

+ + {isLoading &&

Loading stream data...

} + {error &&

Failed to load stream data

} + + {!isLoading && !error && ( +
+

Stream Children:

+
    + {children.map((child) => ( +
  • + {child.id} ({child.type}) +
  • + ))} +
+
+ )} ); } diff --git a/js/src/firebase.ts b/js/src/firebase.ts index a4568ce..06e569b 100644 --- a/js/src/firebase.ts +++ b/js/src/firebase.ts @@ -1,5 +1,5 @@ import { initializeApp } from 'firebase/app'; -import { doc, getDoc, collection, initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore"; +import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore"; const firebaseConfig = { apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", @@ -17,20 +17,3 @@ export const firestoreDb = initializeFirestore(firebaseApp, localCache: persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() }) }); - - -async function readData() { - const citiesRef = collection(firestoreDb, "cities"); - const docRef = doc(citiesRef, "SF"); - const docSnap = await getDoc(docRef); - if (docSnap.exists()) { - const docData = docSnap.data(); - if (docData) { - console.log(docData); - } - } else { - console.log("Document doesn't exist"); - } -} - -readData(); diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts new file mode 100644 index 0000000..f86a915 --- /dev/null +++ b/js/src/hooks/use-create-particle.ts @@ -0,0 +1,22 @@ +import { useMutation } from "@tanstack/react-query"; +import { createParticle } from "@/lib/firestore-particles"; +import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; + +interface CreateParticleParams { + collectionPath: string; + type: T; + properties: ParticlePropertiesMap[T]; + createdByEmail: string; +} + +export function useCreateParticle() { + return useMutation({ + mutationFn: (params: CreateParticleParams) => + createParticle( + params.collectionPath, + params.type, + params.properties, + params.createdByEmail, + ), + }); +} diff --git a/js/src/hooks/use-particle-children.ts b/js/src/hooks/use-particle-children.ts index e2b6d44..03d3be9 100644 --- a/js/src/hooks/use-particle-children.ts +++ b/js/src/hooks/use-particle-children.ts @@ -1,3 +1,5 @@ +import { useState, useEffect, useMemo } from "react"; +import { subscribeToParticleChildren } from "@/lib/firestore-particles"; import { firestorePath } from "@/lib/firestore-paths"; import type { Particle } from "@/api/types"; @@ -7,23 +9,38 @@ interface UseParticleChildrenResult { error: Error | null; } -/** - * Stub hook — returns placeholder data for the children of a container particle. - * Real Firestore reads will be wired up later. - */ export function useParticleChildren( networkId: string, parentSegments: string[], ): UseParticleChildrenResult { - // For children, append "/children" to the parent's doc path, - // or use the root collection if no parent segments. - const _collectionPath = parentSegments.length === 0 - ? firestorePath(networkId, []) - : `${firestorePath(networkId, parentSegments)}/children`; + const [children, setChildren] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - return { - children: [], - isLoading: false, - error: null, - }; + const collectionPath = useMemo(() => { + if (parentSegments.length === 0) return firestorePath(networkId, []); + return `${firestorePath(networkId, parentSegments)}/children`; + }, [networkId, parentSegments.join("/")]); + + useEffect(() => { + setIsLoading(true); + setError(null); + setChildren([]); + + const unsubscribe = subscribeToParticleChildren( + collectionPath, + (data) => { + setChildren(data); + setIsLoading(false); + }, + (err) => { + setError(err); + setIsLoading(false); + }, + ); + + return unsubscribe; + }, [collectionPath]); + + return { children, isLoading, error }; } diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index a629088..6a04a1d 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -1,3 +1,5 @@ +import { useState, useEffect, useMemo } from "react"; +import { subscribeToParticle } from "@/lib/firestore-particles"; import { firestorePath } from "@/lib/firestore-paths"; import type { Particle } from "@/api/types"; @@ -7,19 +9,38 @@ interface UseParticleResult { error: Error | null; } -/** - * Stub hook — returns placeholder data for a particle at the given path. - * Real Firestore reads will be wired up later. - */ export function useParticle( networkId: string, segments: string[], ): UseParticleResult { - const _path = firestorePath(networkId, segments); + const [particle, setParticle] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - return { - particle: null, - isLoading: false, - error: null, - }; + const path = useMemo( + () => firestorePath(networkId, segments), + [networkId, segments.join("/")], + ); + + useEffect(() => { + setIsLoading(true); + setError(null); + setParticle(null); + + const unsubscribe = subscribeToParticle( + path, + (data) => { + setParticle(data); + setIsLoading(false); + }, + (err) => { + setError(err); + setIsLoading(false); + }, + ); + + return unsubscribe; + }, [path]); + + return { particle, isLoading, error }; } diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts new file mode 100644 index 0000000..ccb298a --- /dev/null +++ b/js/src/lib/firestore-particles.ts @@ -0,0 +1,123 @@ +import { + collection, + doc, + onSnapshot, + addDoc, + updateDoc, + query, + orderBy, + serverTimestamp, + Timestamp, + type DocumentData, + type FirestoreDataConverter, + type QueryDocumentSnapshot, + type SnapshotOptions, + type Unsubscribe, +} from "firebase/firestore"; +import { firestoreDb } from "@/firebase"; +import { ParticleSchema } from "@/api/types"; +import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types"; + +// --- Converter --- + +const particleConverter: FirestoreDataConverter = { + toFirestore(particle: Particle): DocumentData { + const { id: _id, created_at, updated_at, ...rest } = particle; + return { + ...rest, + created_at: Timestamp.fromDate(created_at), + ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }), + }; + }, + fromFirestore( + snap: QueryDocumentSnapshot, + options?: SnapshotOptions, + ): Particle { + const raw = snap.data(options); + return ParticleSchema.parse({ + id: snap.id, + type: raw.type, + properties: raw.properties, + created_at: (raw.created_at as Timestamp).toDate(), + created_by_email: raw.created_by_email, + updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, + }); + }, +}; + +// --- Typed reference helpers --- + +function typedDoc(path: string) { + return doc(firestoreDb, path).withConverter(particleConverter); +} + +function typedCollection(path: string) { + return collection(firestoreDb, path).withConverter(particleConverter); +} + +// --- Exported operations --- + +export function subscribeToParticle( + docPath: string, + onData: (particle: Particle | null) => void, + onError: (error: Error) => void, +): Unsubscribe { + return onSnapshot( + typedDoc(docPath), + (snap) => { + onData(snap.exists() ? snap.data() : null); + }, + onError, + ); +} + +export function subscribeToParticleChildren( + collectionPath: string, + onData: (children: Particle[]) => void, + onError: (error: Error) => void, +): Unsubscribe { + const q = query(typedCollection(collectionPath), orderBy("created_at")); + return onSnapshot( + q, + (snap) => { + onData(snap.docs.map((d) => d.data())); + }, + onError, + ); +} + +export async function createParticle( + collectionPath: string, + type: T, + properties: ParticlePropertiesMap[T], + createdByEmail: string, +): Promise { + const particle: Particle = ParticleSchema.parse({ + id: "", // ignored by toFirestore, but needed to satisfy the type + type, + properties, + created_at: new Date(), + created_by_email: createdByEmail, + updated_at: null, + }); + const ref = await addDoc(typedCollection(collectionPath), particle); + return ref.id; +} + +// This allows updating properties without overwriting the entire properties object +export async function updateParticle( + docPath: string, + properties: Partial, +): Promise { + const particleRef = typedDoc(docPath); + // Take the partial and create a new object with dot notation + // e.g. { title: "New Title" } becomes { "properties.title": "New Title" } + const updatedProperties: Record = {}; + for (const key in properties) { + updatedProperties[`properties.${key}`] = properties[key]; + } + await updateDoc(particleRef, { + ...updatedProperties, + updated_at: serverTimestamp(), + }); +}