wire firestore crud for particles
This commit is contained in:
+40
-38
@@ -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"];
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user