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>;
// --- 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<typeof StreamParticleDataSchema>;
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderParticleDataSchema = z.object({
export const FolderPropertiesSchema = z.object({
name: z.string(),
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(),
mime_type: z.string(),
duration_ms: 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(),
filename: z.string(),
mime_type: z.string(),
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(),
});
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(),
description: z.string(),
status: z.string().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(),
content: z.string(),
});
export type PaperParticleData = z.infer<typeof PaperParticleDataSchema>;
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
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<typeof ParticleSchema>;
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">
Stream view {networkId}/{particleSegments.join("/")}
</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>
);
}
+1 -18
View File
@@ -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();
+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 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<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
+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 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<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
+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(),
});
}