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>;
|
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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
|
||||||
|
|||||||
@@ -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 { 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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