fix folder from root
This commit is contained in:
@@ -244,6 +244,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
// e.g. ["network:123"] - visible to everyone in the network
|
// e.g. ["network:123"] - visible to everyone in the network
|
||||||
visible_to: z.array(z.string()),
|
visible_to: z.array(z.string()),
|
||||||
|
// Set to created_at on creation, bumped when children are added — keeps
|
||||||
|
// folders present in activity-ordered container queries.
|
||||||
|
last_child_created_at: z.coerce.date().optional(),
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('media'),
|
type: z.literal('media'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLiveParticle } from '@/hooks/use-particle';
|
|||||||
import { particlePath, type ParticlePath } from '@/lib/particle-path';
|
import { particlePath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import Layout from '@/features/layout';
|
||||||
import { StreamView } from '@/features/particles/stream-view';
|
import { StreamView } from '@/features/particles/stream-view';
|
||||||
import { FolderView } from '@/features/particles/folder-view';
|
import { FolderView } from '@/features/particles/folder-view';
|
||||||
import { MediaParticleView } from '@/features/particles/media-particle-view';
|
import { MediaParticleView } from '@/features/particles/media-particle-view';
|
||||||
@@ -32,9 +33,11 @@ export default function ParticleViewResolver() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Layout>
|
||||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
<div className="flex h-full items-center justify-center">
|
||||||
</div>
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,21 +45,33 @@ export default function ParticleViewResolver() {
|
|||||||
// Errors here are almost always Firestore permission-denied — the user lost
|
// Errors here are almost always Firestore permission-denied — the user lost
|
||||||
// access to the network or to a custom-visibility particle. The React Router
|
// access to the network or to a custom-visibility particle. The React Router
|
||||||
// stays on the dead route, so without an explicit escape the user is stuck.
|
// stays on the dead route, so without an explicit escape the user is stuck.
|
||||||
return <InaccessibleParticle />;
|
return (
|
||||||
|
<Layout>
|
||||||
|
<InaccessibleParticle />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streams render their own full-screen chrome; folders and leaves live
|
||||||
|
// inside the app Layout (breadcrumbs, full-height column) like the root.
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'stream':
|
case 'stream':
|
||||||
return <StreamView streamParticle={particle} path={path} />;
|
return <StreamView streamParticle={particle} path={path} />;
|
||||||
case 'folder':
|
case 'folder':
|
||||||
return <FolderView folderParticle={particle} path={path} />;
|
return (
|
||||||
|
<Layout>
|
||||||
|
<FolderView folderParticle={particle} path={path} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<LeafParticleView
|
<Layout>
|
||||||
particle={particle}
|
<LeafParticleView
|
||||||
containerPath={particlePath(networkId, segments.slice(0, -1))}
|
particle={particle}
|
||||||
networkId={networkId}
|
containerPath={particlePath(networkId, segments.slice(0, -1))}
|
||||||
/>
|
networkId={networkId}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +148,7 @@ function LeafParticleView({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:2rem]">
|
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ interface UseContainerChildrenResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activityTime(particle: Particle): number {
|
function activityTime(particle: Particle): number {
|
||||||
if (particle.type === 'stream' && particle.last_child_created_at) {
|
if (
|
||||||
|
(particle.type === 'stream' || particle.type === 'folder') &&
|
||||||
|
particle.last_child_created_at
|
||||||
|
) {
|
||||||
return particle.last_child_created_at.getTime();
|
return particle.last_child_created_at.getTime();
|
||||||
}
|
}
|
||||||
return particle.created_at.getTime();
|
return particle.created_at.getTime();
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
? (raw.updated_at as Timestamp).toDate()
|
? (raw.updated_at as Timestamp).toDate()
|
||||||
: undefined,
|
: undefined,
|
||||||
visible_to: raw.visible_to,
|
visible_to: raw.visible_to,
|
||||||
|
last_child_created_at: raw.last_child_created_at
|
||||||
|
? (raw.last_child_created_at as Timestamp).toDate()
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
case 'media':
|
case 'media':
|
||||||
case 'file':
|
case 'file':
|
||||||
@@ -281,6 +284,25 @@ export function subscribeToLatestChild(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort bump of the parent container's last_child_created_at when a
|
||||||
|
// child is created. The particle processor worker does this for stream
|
||||||
|
// parents, but skips folders, so the client keeps folder activity fresh
|
||||||
|
// itself. Same value semantics as the worker: the child's created_at, so it
|
||||||
|
// stays directly comparable with playback markers.
|
||||||
|
function bumpParentLastChildCreatedAt(
|
||||||
|
collectionPath: string,
|
||||||
|
childCreatedAt: Date,
|
||||||
|
): void {
|
||||||
|
const parentDocPath = collectionPath.replace(/\/children$/, '');
|
||||||
|
// The network root (networks/{id}) is not a particle doc — nothing to bump.
|
||||||
|
if (!parentDocPath.includes('/children/')) return;
|
||||||
|
updateDoc(doc(firestoreDb, parentDocPath), {
|
||||||
|
last_child_created_at: Timestamp.fromDate(childCreatedAt),
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: ordering freshness only.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// This creates a new particle document with the given properties and returns its ID.
|
// This creates a new particle document with the given properties and returns its ID.
|
||||||
export async function createParticle<T extends ParticleType>(
|
export async function createParticle<T extends ParticleType>(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
@@ -296,15 +318,21 @@ export async function createParticle<T extends ParticleType>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: '', // ignored by toFirestore, but needed to satisfy the type
|
id: '', // ignored by toFirestore, but needed to satisfy the type
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: createdAt,
|
||||||
created_by_human_id: createdByHumanId,
|
created_by_human_id: createdByHumanId,
|
||||||
...(visibleTo ? { visible_to: visibleTo } : {}),
|
...(visibleTo ? { visible_to: visibleTo } : {}),
|
||||||
|
// Containers start with last_child_created_at = created_at so they appear
|
||||||
|
// in activity-ordered queries before they have any children (Firestore
|
||||||
|
// orderBy drops docs missing the field).
|
||||||
|
...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
|
||||||
});
|
});
|
||||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
|
bumpParentLastChildCreatedAt(collectionPath, createdAt);
|
||||||
return ref.id;
|
return ref.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,15 +346,18 @@ export async function createStreamParticle(
|
|||||||
throw new Error('visibleTo is required for streams and cannot be empty');
|
throw new Error('visibleTo is required for streams and cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: '',
|
id: '',
|
||||||
type: 'stream',
|
type: 'stream',
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: createdAt,
|
||||||
created_by_human_id: createdByHumanId,
|
created_by_human_id: createdByHumanId,
|
||||||
visible_to: visibleTo,
|
visible_to: visibleTo,
|
||||||
|
last_child_created_at: createdAt,
|
||||||
});
|
});
|
||||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
|
bumpParentLastChildCreatedAt(collectionPath, createdAt);
|
||||||
return ref.id;
|
return ref.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user