wire firestore crud for particles

This commit is contained in:
talksik
2026-03-17 16:38:06 -07:00
parent 8207872a0c
commit 58c6e8a01a
7 changed files with 264 additions and 80 deletions
+40 -38
View File
@@ -61,84 +61,86 @@ export const DepotObjectSchema = z.object({
}); });
export type DepotObject = z.infer<typeof DepotObjectSchema>; export type DepotObject = z.infer<typeof DepotObjectSchema>;
// --- Particle data schemas --- // --- Particle property schemas ---
export const StreamParticleDataSchema = z.object({ export const StreamPropertiesSchema = z.object({
name: z.string(), name: z.string(),
status: z.enum(["open", "closed"]), status: z.enum(["open", "closed"]),
description: z.string().optional(), description: z.string().optional(),
}); });
export type StreamParticleData = z.infer<typeof StreamParticleDataSchema>; export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderParticleDataSchema = z.object({ export const FolderPropertiesSchema = z.object({
name: z.string(), name: z.string(),
color: z.string().optional(), color: z.string().optional(),
}); });
export type FolderParticleData = z.infer<typeof FolderParticleDataSchema>; export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
export const MediaParticleDataSchema = z.object({ export const MediaPropertiesSchema = z.object({
object_id: z.string(), object_id: z.string(),
mime_type: z.string(), mime_type: z.string(),
duration_ms: z.number(), duration_ms: z.number(),
size_bytes: z.number(), size_bytes: z.number(),
}); });
export type MediaParticleData = z.infer<typeof MediaParticleDataSchema>; export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
export const FileParticleDataSchema = z.object({ export const FilePropertiesSchema = z.object({
object_id: z.string(), object_id: z.string(),
filename: z.string(), filename: z.string(),
mime_type: z.string(), mime_type: z.string(),
size_bytes: z.number(), size_bytes: z.number(),
}); });
export type FileParticleData = z.infer<typeof FileParticleDataSchema>; export type FileProperties = z.infer<typeof FilePropertiesSchema>;
export const TextParticleDataSchema = z.object({ export const TextPropertiesSchema = z.object({
content: z.string(), content: z.string(),
}); });
export type TextParticleData = z.infer<typeof TextParticleDataSchema>; export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestParticleDataSchema = z.object({ export const QuestPropertiesSchema = z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
status: z.string().optional(), status: z.string().optional(),
assigned_to: z.string().email().optional(), assigned_to: z.string().email().optional(),
}); });
export type QuestParticleData = z.infer<typeof QuestParticleDataSchema>; export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export const PaperParticleDataSchema = z.object({ export const PaperPropertiesSchema = z.object({
title: z.string(), title: z.string(),
content: z.string(), content: z.string(),
}); });
export type PaperParticleData = z.infer<typeof PaperParticleDataSchema>; export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
export interface ParticleDataMap { export interface ParticlePropertiesMap {
stream: StreamParticleData; stream: StreamProperties;
folder: FolderParticleData; folder: FolderProperties;
media: MediaParticleData; media: MediaProperties;
file: FileParticleData; file: FileProperties;
text: TextParticleData; text: TextProperties;
quest: QuestParticleData; quest: QuestProperties;
paper: PaperParticleData; paper: PaperProperties;
} }
// --- Unified Particle types --- // --- Unified Particle types ---
interface ParticleBase { const ParticleBaseSchema = z.object({
id: string; id: z.string(),
created_at: Date; created_at: z.coerce.date(),
created_by: string; created_by_email: z.string().email(),
} updated_at: z.coerce.date().optional()
});
export type Particle = ParticleBase & export const ParticleSchema = z.discriminatedUnion("type", [
( ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
| { type: "stream"; data: StreamParticleData } ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
| { type: "folder"; data: FolderParticleData } ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
| { type: "media"; data: MediaParticleData } ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
| { type: "file"; data: FileParticleData } ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
| { type: "text"; data: TextParticleData } ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
| { type: "quest"; data: QuestParticleData } ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
| { type: "paper"; data: PaperParticleData } ]);
);
export type Particle = z.infer<typeof ParticleSchema>;
export type ParticleType = Particle["type"]; export type ParticleType = Particle["type"];
+16
View File
@@ -15,6 +15,22 @@ export function StreamView({ networkId, particleSegments, streamParticle }: Stre
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Stream view {networkId}/{particleSegments.join("/")} Stream view {networkId}/{particleSegments.join("/")}
</p> </p>
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
{error && <p className="text-destructive text-sm">Failed to load stream data</p>}
{!isLoading && !error && (
<div className="mt-4">
<p className="text-sm font-medium">Stream Children:</p>
<ul className="list-disc list-inside">
{children.map((child) => (
<li key={child.id} className="text-sm">
{child.id} ({child.type})
</li>
))}
</ul>
</div>
)}
</div> </div>
); );
} }
+1 -18
View File
@@ -1,5 +1,5 @@
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { doc, getDoc, collection, initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore"; import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore";
const firebaseConfig = { const firebaseConfig = {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
@@ -17,20 +17,3 @@ export const firestoreDb = initializeFirestore(firebaseApp,
localCache: localCache:
persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() }) 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();
+22
View File
@@ -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<T extends ParticleType = ParticleType> {
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,
),
});
}
+31 -14
View File
@@ -1,3 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticleChildren } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths"; import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
@@ -7,23 +9,38 @@ interface UseParticleChildrenResult {
error: Error | null; 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( export function useParticleChildren(
networkId: string, networkId: string,
parentSegments: string[], parentSegments: string[],
): UseParticleChildrenResult { ): UseParticleChildrenResult {
// For children, append "/children" to the parent's doc path, const [children, setChildren] = useState<Particle[]>([]);
// or use the root collection if no parent segments. const [isLoading, setIsLoading] = useState(true);
const _collectionPath = parentSegments.length === 0 const [error, setError] = useState<Error | null>(null);
? firestorePath(networkId, [])
: `${firestorePath(networkId, parentSegments)}/children`;
return { const collectionPath = useMemo(() => {
children: [], if (parentSegments.length === 0) return firestorePath(networkId, []);
isLoading: false, return `${firestorePath(networkId, parentSegments)}/children`;
error: null, }, [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 };
} }
+31 -10
View File
@@ -1,3 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticle } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths"; import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
@@ -7,19 +9,38 @@ interface UseParticleResult {
error: Error | null; 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( export function useParticle(
networkId: string, networkId: string,
segments: string[], segments: string[],
): UseParticleResult { ): UseParticleResult {
const _path = firestorePath(networkId, segments); const [particle, setParticle] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
return { const path = useMemo(
particle: null, () => firestorePath(networkId, segments),
isLoading: false, [networkId, segments.join("/")],
error: null, );
};
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 };
} }
+123
View File
@@ -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<Particle> = {
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<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: string,
): Promise<string> {
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<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> {
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<string, any> = {};
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
});
}