From 4273e324e53743de564844f3983f8468294ea520 Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 17 Mar 2026 14:23:51 -0700 Subject: [PATCH] setup boilerplate for data and rendering This includes zod types creation for API response validation, and exploration of path based resolution of rendering particles. --- js/package.json | 5 +- js/src/App.tsx | 57 ++-- js/src/api/client.ts | 181 ++++++----- js/src/api/types.ts | 289 +++++++++--------- js/src/features/network-selector.tsx | 86 ++++++ js/src/features/particles/folder-view.tsx | 18 ++ .../features/particles/particle-list-view.tsx | 43 +++ .../particles/particle-view-resolver.tsx | 65 ++++ js/src/features/particles/stream-view.tsx | 18 ++ js/src/features/streams/stream-list.tsx | 98 ------ js/src/hooks/use-particle-children.ts | 29 ++ js/src/hooks/use-particle.ts | 25 ++ js/src/lib/firestore-paths.ts | 23 ++ js/src/lib/stream-utils.ts | 37 --- js/src/lib/utils.ts | 5 + js/src/pages/path-resolver.tsx | 33 ++ js/src/pages/streams-page.tsx | 83 ----- js/src/stores/app-store.ts | 104 +------ js/yarn.lock | 122 +++++++- 19 files changed, 729 insertions(+), 592 deletions(-) create mode 100644 js/src/features/network-selector.tsx create mode 100644 js/src/features/particles/folder-view.tsx create mode 100644 js/src/features/particles/particle-list-view.tsx create mode 100644 js/src/features/particles/particle-view-resolver.tsx create mode 100644 js/src/features/particles/stream-view.tsx delete mode 100644 js/src/features/streams/stream-list.tsx create mode 100644 js/src/hooks/use-particle-children.ts create mode 100644 js/src/hooks/use-particle.ts create mode 100644 js/src/lib/firestore-paths.ts delete mode 100644 js/src/lib/stream-utils.ts create mode 100644 js/src/pages/path-resolver.tsx delete mode 100644 js/src/pages/streams-page.tsx diff --git a/js/package.json b/js/package.json index 11b7525..e7bc81a 100644 --- a/js/package.json +++ b/js/package.json @@ -31,6 +31,7 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron/fuses": "^1.8.0", "@tailwindcss/vite": "^4.2.0", + "@tanstack/eslint-plugin-query": "^5.91.4", "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^25.3.0", "@types/react": "^19.2.14", @@ -41,10 +42,11 @@ "electron": "40.6.0", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", - "typescript": "~4.5.4", + "typescript": "^5.9.3", "vite": "^5.4.21" }, "dependencies": { + "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-squirrel-startup": "^1.0.1", @@ -58,6 +60,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", + "zod": "^4.3.6", "zustand": "^5.0.11" } } diff --git a/js/src/App.tsx b/js/src/App.tsx index 07c7215..72ffd96 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,40 +1,16 @@ import { useEffect } from "react"; import { HashRouter, Routes, Route } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { useAppStore } from "@/stores/app-store"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; -import { StreamsPage } from "@/pages/streams-page"; -import { StreamPlayerPage } from "@/pages/stream-player-page"; import { } from "@/firebase"; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' +import PathResolver from "./pages/path-resolver"; -function AuthenticatedApp() { - const fetchStartup = useAppStore((s) => s.fetchStartup); - const isLoading = useAppStore((s) => s.isLoading); - - useEffect(() => { - fetchStartup(); - }, [fetchStartup]); - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - - return ( - - - - } /> - } /> - - - - ); -} +const queryClient = new QueryClient(); const App = () => { const status = useAuthStore((s) => s.status); @@ -59,4 +35,23 @@ const App = () => { return ; }; -export default App; +function AuthenticatedApp() { + // NOTE: Hash router provides history, despite using catch-all + return ( + + + } /> + + + ); +} + +const AppWithProviders = () => ( + + + + + +); + +export default AppWithProviders; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index f08f0d8..d27afa0 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -1,17 +1,19 @@ import { useSessionStore } from "@/stores/session-store"; +import type { z } from "zod"; +import { + DepotObjectSchema, + HumanSchema, + ListNetworksResponseSchema, + NetworkSchema, + PrepareUploadResponseSchema, + SignInResponseSchema, +} from "./types"; import type { - CreateStreamParticleRequest, - CreateStreamRequest, - Human, - MarkSeenBatchRequest, + AddMembersRequest, + CreateNetworkRequest, PrepareUploadRequest, - PrepareUploadResponse, RequestCodeRequest, SignInRequest, - SignInResponse, - StartupResponse, - Stream, - StreamParticle, } from "./types"; export class ApiError extends Error { @@ -37,11 +39,11 @@ class ApiClient { this.config = config; } - private async request( + private async fetch( method: string, path: string, body?: unknown, - ): Promise { + ): Promise { const headers: Record = { "Content-Type": "application/json", }; @@ -67,109 +69,106 @@ class ApiClient { throw new ApiError(response.status, text); } - if (response.status === 204) { - return undefined as T; - } + return response; + } - return response.json() as Promise; + private async request( + schema: z.ZodType, + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await this.fetch(method, path, body); + const json = await response.json(); + return schema.parse(json); + } + + private async requestVoid( + method: string, + path: string, + body?: unknown, + ): Promise { + await this.fetch(method, path, body); } // --- Auth --- async requestCode(data: RequestCodeRequest): Promise { - await this.request("POST", "/auth/request-code", data); + await this.requestVoid("POST", "/auth/request-code", data); } - async signIn(data: SignInRequest): Promise { - return this.request("POST", "/auth/sign-in", data); + async signIn(data: SignInRequest) { + return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); } - async me(): Promise { - return this.request("GET", "/auth/me"); + async me() { + return this.request(HumanSchema, "GET", "/auth/me"); } async signOut(): Promise { - await this.request("POST", "/auth/sign-out"); + await this.requestVoid("POST", "/auth/sign-out"); } - // --- Startup --- - - async startup(): Promise { - return this.request("GET", "/startup"); - } - - // --- Streams --- - - async createStream( - networkId: string, - data: CreateStreamRequest, - ): Promise { - return this.request( - "POST", - `/networks/${networkId}/streams`, - data, + // TODO: security: require passing in the particle id once api deprecates this + async getParticleDownloadUrl(objectId: string): Promise { + const response = await this.fetch( + "GET", + `/particles/${objectId}/download`, ); - } - - async createStreamParticle( - streamId: string, - data: CreateStreamParticleRequest, - ): Promise { - return this.request( - "POST", - `/streams/${streamId}/particles`, - data, - ); - } - - // --- Particles --- - - async markSeen(particleId: string): Promise { - await this.request("POST", `/particles/${particleId}/seen`); - } - - async ackParticle(particleId: string): Promise { - await this.request("POST", `/particles/${particleId}/ack`); - } - - async markSeenBatch(data: MarkSeenBatchRequest): Promise { - await this.request("POST", "/particles/seen", data); - } - - async getParticleDownloadUrl(particleId: string): Promise { - const token = this.config.getToken(); - const headers: Record = {}; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch( - `${this.config.baseUrl}/particles/${particleId}/download`, - { headers, redirect: "follow" }, - ); - - if (response.status === 401) { - this.config.onUnauthorized(); - throw new ApiError(401, "Unauthorized"); - } - - if (!response.ok) { - throw new ApiError(response.status, "Failed to get download URL"); - } - return response.url; } // --- Depot --- - async prepareUpload( - data: PrepareUploadRequest, - ): Promise { - return this.request("POST", "/depot/upload", data); + async prepareUpload(data: PrepareUploadRequest) { + return this.request( + PrepareUploadResponseSchema, + "POST", + "/depot/upload", + data, + ); } - async confirmUpload(objectId: string): Promise { - await this.request("POST", `/depot/objects/${objectId}/confirm`); + async confirmUpload(objectId: string) { + return this.request( + DepotObjectSchema, + "POST", + `/depot/objects/${objectId}/confirm`, + ); + } + + + // --- Networks --- + + async listNetworks() { + return this.request( + ListNetworksResponseSchema, + "GET", + "/networks", + ); + } + + async createNetwork(data: CreateNetworkRequest) { + return this.request(NetworkSchema, "POST", "/networks", data); + } + + async getNetwork(id: string) { + return this.request(NetworkSchema, "GET", `/networks/${id}`); + } + + async addMembers(networkId: string, data: AddMembersRequest): Promise { + await this.requestVoid( + "POST", + `/networks/${networkId}/members`, + data, + ); + } + + async removeMember(networkId: string, email: string): Promise { + await this.requestVoid( + "DELETE", + `/networks/${networkId}/members/${email}`, + ); } } diff --git a/js/src/api/types.ts b/js/src/api/types.ts index e0eb6fa..4c62545 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -1,168 +1,169 @@ -// --- Core entities --- +import { z } from "zod"; -export interface Human { - id: string | null; - email: string; - email_prefix: string; - created_at: string | null; -} +export const HumanSchema = z.object({ + id: z.string().nullable(), + created_at: z.coerce.date().nullable(), + email: z.string().email(), + email_prefix: z.string(), +}); -export type StreamStatus = "open" | "closed" | "unspecified"; +export type Human = z.infer; -export interface AckInfo { - email: string; - acked_at: string; -} +export const NetworkSchema = z.object({ + id: z.string(), + name: z.string(), + admin_human: HumanSchema, + humans: z.array(HumanSchema), + created_at: z.coerce.date(), +}); -// --- Particle types --- +export type Network = z.infer; -export type ParticleType = - | "media" - | "text" - | "quest" - | "paper" - | "file" - | "folder"; +export const ListNetworksResponseSchema = z.array(NetworkSchema); +export type ListNetworksResponse = z.infer; -export interface MediaParticleData { - object_id: string; - duration_ms: number; - mime_type: string; -} +// --- Network request/response types --- -export interface TextParticleData { - content: string; -} +const CreateNetworkRequestSchema = z.object({ + name: z.string(), +}); +export type CreateNetworkRequest = z.infer; -export interface QuestParticleData { - title: string; - description: string; - status?: string; - assigned_to?: string; - due_date?: string; -} - -export interface PaperParticleData { - title: string; - content: string; -} - -export interface FileParticleData { - object_id: string; - filename: string; - mime_type: string; - size: number; -} - -export interface FolderParticleData { - name: string; - color?: string; -} - -export interface ParticleDataMap { - media: MediaParticleData; - text: TextParticleData; - quest: QuestParticleData; - paper: PaperParticleData; - file: FileParticleData; - folder: FolderParticleData; -} - -export function getParticleData( - particle: StreamParticle, - type: T, -): ParticleDataMap[T] { - return particle.data as ParticleDataMap[T]; -} - -export interface StreamParticle { - id: string; - type: ParticleType; - data: unknown; - created_by_email: string; - seen: boolean; - acks: AckInfo[]; - updated_at: string; - created_at: string; -} - -export interface Stream { - id: string; - name: string; - description: string; - status: StreamStatus; - members?: string[]; - particles: StreamParticle[]; - unseen_count: number; - updated_at: string; - created_at: string; -} - -export interface Network { - id: string; - name: string; - admin_human: Human; - humans: Human[]; - open_stream_count: number; - open_stream_capacity: number; - created_at: string; -} - -export interface NetworkWithStreams extends Network { - streams: Stream[]; -} +const AddMembersRequestSchema = z.object({ + email_addresses: z.array(z.string().email()), +}); +export type AddMembersRequest = z.infer; // --- Depot types --- -export interface PrepareUploadRequest { - network_id: string; - name: string; - content_type: string; - content_length: number; +const PrepareUploadRequestSchema = z.object({ + network_id: z.string(), + name: z.string(), + content_type: z.string(), + content_length: z.number(), +}); +export type PrepareUploadRequest = z.infer; + +export const PrepareUploadResponseSchema = z.object({ + object_id: z.string(), + upload_url: z.string(), + upload_headers: z.record(z.string(), z.string()), +}); +export type PrepareUploadResponse = z.infer; + +export const DepotObjectSchema = z.object({ + id: z.string(), + name: z.string(), + content_type: z.string(), + content_length: z.number(), + contains_content: z.boolean(), + created_at: z.coerce.date(), +}); +export type DepotObject = z.infer; + +// --- Particle data schemas --- + +export const StreamParticleDataSchema = z.object({ + name: z.string(), + status: z.enum(["open", "closed"]), + description: z.string().optional(), +}); +export type StreamParticleData = z.infer; + +export const FolderParticleDataSchema = z.object({ + name: z.string(), + color: z.string().optional(), +}); +export type FolderParticleData = z.infer; + +export const MediaParticleDataSchema = z.object({ + object_id: z.string(), + mime_type: z.string(), + duration_ms: z.number(), + size_bytes: z.number(), +}); +export type MediaParticleData = z.infer; + +export const FileParticleDataSchema = z.object({ + object_id: z.string(), + filename: z.string(), + mime_type: z.string(), + size_bytes: z.number(), +}); +export type FileParticleData = z.infer; + +export const TextParticleDataSchema = z.object({ + content: z.string(), +}); +export type TextParticleData = z.infer; + +export const QuestParticleDataSchema = z.object({ + title: z.string(), + description: z.string(), + status: z.string().optional(), + assigned_to: z.string().email().optional(), +}); +export type QuestParticleData = z.infer; + +export const PaperParticleDataSchema = z.object({ + title: z.string(), + content: z.string(), +}); +export type PaperParticleData = z.infer; + +export interface ParticleDataMap { + stream: StreamParticleData; + folder: FolderParticleData; + media: MediaParticleData; + file: FileParticleData; + text: TextParticleData; + quest: QuestParticleData; + paper: PaperParticleData; } -export interface PrepareUploadResponse { - object_id: string; - upload_url: string; - upload_headers: Record; +// --- Unified Particle types --- + +interface ParticleBase { + id: string; + created_at: Date; + created_by: string; } -// --- Stream mutation types --- +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 interface CreateStreamRequest { - name: string; - description: string; - visibility: "network_all" | "custom"; - member_emails?: string[]; -} +export type ParticleType = Particle["type"]; -export interface CreateStreamParticleRequest { - type: ParticleType; - data: unknown; -} +/** Container types can have children subcollections */ +export const CONTAINER_TYPES: ReadonlySet = new Set(["stream", "folder"]); -export interface MarkSeenBatchRequest { - particle_ids: string[]; +export function isContainerType(type: ParticleType): boolean { + return CONTAINER_TYPES.has(type); } // --- Auth types --- -export interface RequestCodeRequest { - email: string; -} +const RequestCodeRequestSchema = z.object({ + email: z.string().email(), +}); +export type RequestCodeRequest = z.infer; -export interface SignInRequest { - email: string; - code: string; -} +const SignInRequestSchema = z.object({ + email: z.string().email(), + code: z.string(), +}); +export type SignInRequest = z.infer; -export interface SignInResponse { - human: Human; - token: string; -} - -// --- Startup --- - -export interface StartupResponse { - networks: NetworkWithStreams[]; -} +export const SignInResponseSchema = z.object({ + human: HumanSchema, + token: z.string(), +}); +export type SignInResponse = z.infer; diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx new file mode 100644 index 0000000..2e8383a --- /dev/null +++ b/js/src/features/network-selector.tsx @@ -0,0 +1,86 @@ +import { useNavigate } from "react-router-dom"; +import { LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Muted } from "@/components/ui/typography"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useAuthStore } from "@/stores/auth-store"; +import { WindowControls } from "@/components/window-controls"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/api/client"; +import { Progress } from "@/components/ui/progress"; + +export function NetworkSelector() { + const navigate = useNavigate(); + const signOut = useAuthStore((s) => s.signOut); + const user = useAuthStore((s) => s.user); + + const { data, isPending, error } = useQuery({ + queryKey: ["networks"], + queryFn: () => apiClient.listNetworks(), + }); + + if (isPending) { + return ; + } + + if (error) { + return ( +
+

Failed to load networks

+

{error.message}

+
+ ); + } + + if (data?.length === 0) { + return ( +
+

+ You don't have access to any networks yet. Please email us to get started. +

+ + team@flowylabs.ai + +
+ ); + } + + return ( +
+
+ +
+ {user && {user.email_prefix}} + +
+ +
+ +
+
+ ); +} diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx new file mode 100644 index 0000000..40b29ad --- /dev/null +++ b/js/src/features/particles/folder-view.tsx @@ -0,0 +1,18 @@ +interface FolderViewProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Folder-specific view — will eventually show children as cards/list. + * Placeholder for now. + */ +export function FolderView({ networkId, particleSegments }: FolderViewProps) { + return ( +
+

+ Folder view — {networkId}/{particleSegments.join("/")} +

+
+ ); +} diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx new file mode 100644 index 0000000..54f66cd --- /dev/null +++ b/js/src/features/particles/particle-list-view.tsx @@ -0,0 +1,43 @@ +import { useParticleChildren } from "@/hooks/use-particle-children"; + +interface ParticleListViewProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Grid/list of child particles for a container (folder, stream root, or network root). + */ +export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) { + const { children, isLoading } = useParticleChildren(networkId, particleSegments); + + if (isLoading) { + return ( +
+

Loading particles...

+
+ ); + } + + if (children.length === 0) { + return ( +
+

No particles yet

+
+ ); + } + + return ( +
+ {children.map((child) => ( +
+

{child.id}

+

{child.type}

+
+ ))} +
+ ); +} diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx new file mode 100644 index 0000000..af2bc77 --- /dev/null +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -0,0 +1,65 @@ +import { useParticle } from "@/hooks/use-particle"; +import { StreamView } from "./stream-view"; +import { FolderView } from "./folder-view"; +import { ParticleListView } from "./particle-list-view"; +import { isContainerType } from "@/api/types"; + +interface ParticleViewResolverProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Resolves a particle by its path segments and renders the appropriate view + * based on particle type. This is the extensibility point for future particle types. + */ +export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) { + const { particle, isLoading, error } = useParticle(networkId, particleSegments); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

Failed to load particle

+
+ ); + } + + // While the hook is stubbed, particle will be null — show a placeholder + if (!particle) { + return ( +
+

+ Particle: {particleSegments.join(" / ")} +

+
+ ); + } + + switch (particle.type) { + case "stream": + return ; + case "folder": + return ; + default: + // For container types we haven't built a view for, fall back to list + if (isContainerType(particle.type)) { + return ; + } + // Leaf particle — placeholder + return ( +
+

+ {particle.type} particle: {particle.id} +

+
+ ); + } +} diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx new file mode 100644 index 0000000..b853b46 --- /dev/null +++ b/js/src/features/particles/stream-view.tsx @@ -0,0 +1,18 @@ +interface StreamViewProps { + networkId: string; + particleSegments: string[]; +} + +/** + * Stream-specific view — will eventually show media/text children in playback order. + * Placeholder for now. + */ +export function StreamView({ networkId, particleSegments }: StreamViewProps) { + return ( +
+

+ Stream view — {networkId}/{particleSegments.join("/")} +

+
+ ); +} diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx deleted file mode 100644 index 0124621..0000000 --- a/js/src/features/streams/stream-list.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { Badge } from "@/components/ui/badge"; -import { Card } from "@/components/ui/card"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { useAppStore } from "@/stores/app-store"; -import { flattenStreams } from "@/lib/stream-utils"; -import { formatDistanceToNow } from "@/lib/time-utils"; -import { ParticlePreview } from "./particle-preview"; - -function getInitials(email: string): string { - const prefix = email.split("@")[0] ?? ""; - return prefix.slice(0, 2).toUpperCase(); -} - -export function StreamList() { - const networks = useAppStore((s) => s.networks); - const selectedNetworkId = useAppStore((s) => s.selectedNetworkId); - const navigate = useNavigate(); - - const streams = flattenStreams(networks, selectedNetworkId); - - if (streams.length === 0) { - return ( -
-

No streams yet

-
- ); - } - - return ( -
- {streams.map((stream) => { - const lastParticle = - stream.particles.length > 0 - ? stream.particles[stream.particles.length - 1] - : null; - - const timeSource = lastParticle?.created_at ?? stream.updated_at; - const senderEmail = lastParticle?.created_by_email; - const senderPrefix = senderEmail?.split("@")[0]; - - return ( - navigate(`/streams/${stream.id}`)} - > - {/* Preview hero area */} -
- {lastParticle ? ( - - ) : ( -
-

- No messages yet -

-
- )} - - {/* Unseen badge overlay */} - {stream.unseen_count > 0 && ( - - {stream.unseen_count} - - )} -
- - {/* Footer: avatar + stream info */} -
- {senderEmail ? ( - - - {getInitials(senderEmail)} - - - ) : ( -
- )} - -
-

{stream.name}

-

- {senderPrefix && {senderPrefix}} - {senderPrefix && timeSource && · } - {timeSource && {formatDistanceToNow(timeSource)}} -

-
-
- - ); - })} -
- ); -} diff --git a/js/src/hooks/use-particle-children.ts b/js/src/hooks/use-particle-children.ts new file mode 100644 index 0000000..e2b6d44 --- /dev/null +++ b/js/src/hooks/use-particle-children.ts @@ -0,0 +1,29 @@ +import { firestorePath } from "@/lib/firestore-paths"; +import type { Particle } from "@/api/types"; + +interface UseParticleChildrenResult { + children: Particle[]; + isLoading: boolean; + 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`; + + return { + children: [], + isLoading: false, + error: null, + }; +} diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts new file mode 100644 index 0000000..a629088 --- /dev/null +++ b/js/src/hooks/use-particle.ts @@ -0,0 +1,25 @@ +import { firestorePath } from "@/lib/firestore-paths"; +import type { Particle } from "@/api/types"; + +interface UseParticleResult { + particle: Particle | null; + isLoading: boolean; + 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); + + return { + particle: null, + isLoading: false, + error: null, + }; +} diff --git a/js/src/lib/firestore-paths.ts b/js/src/lib/firestore-paths.ts new file mode 100644 index 0000000..12e245c --- /dev/null +++ b/js/src/lib/firestore-paths.ts @@ -0,0 +1,23 @@ +/** + * Map URL segments to Firestore paths. + * + * Firestore structure: + * networks/{networkId}/particles/{particleId} + * networks/{networkId}/particles/{particleId}/children/{childId} + * ...and so on for arbitrary depth. + * + * Examples: + * segments = [] → "networks/{nid}/particles" + * segments = ["p1"] → "networks/{nid}/particles/p1" + * segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2" + */ +export function firestorePath(networkId: string, segments: string[]): string { + const base = `networks/${networkId}/particles`; + if (segments.length === 0) return base; + + const parts: string[] = [base, segments[0]]; + for (let i = 1; i < segments.length; i++) { + parts.push("children", segments[i]); + } + return parts.join("/"); +} diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts deleted file mode 100644 index fb95abf..0000000 --- a/js/src/lib/stream-utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NetworkWithStreams, Stream } from "@/api/types"; - -export interface FlatStream extends Stream { - networkId: string; - networkName: string; -} - -export function flattenStreams( - networks: NetworkWithStreams[], - selectedNetworkId: string | null, -): FlatStream[] { - const filtered = selectedNetworkId - ? networks.filter((n) => n.id === selectedNetworkId) - : networks; - - const streams: FlatStream[] = filtered.flatMap((n) => - n.streams.map((s) => ({ - ...s, - networkId: n.id, - networkName: n.name, - })), - ); - - return streams.sort((a, b) => { - const aTime = getLatestParticleTime(a); - const bTime = getLatestParticleTime(b); - return bTime - aTime; - }); -} - -function getLatestParticleTime(stream: Stream): number { - if (stream.particles.length === 0) { - return new Date(stream.updated_at).getTime() || 0; - } - const last = stream.particles[stream.particles.length - 1]; - return new Date(last.created_at).getTime(); -} diff --git a/js/src/lib/utils.ts b/js/src/lib/utils.ts index b178ee7..fd4498c 100644 --- a/js/src/lib/utils.ts +++ b/js/src/lib/utils.ts @@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function getInitials(email: string): string { + const prefix = email.split("@")[0] ?? ""; + return prefix.slice(0, 2).toUpperCase(); +} diff --git a/js/src/pages/path-resolver.tsx b/js/src/pages/path-resolver.tsx new file mode 100644 index 0000000..718a72e --- /dev/null +++ b/js/src/pages/path-resolver.tsx @@ -0,0 +1,33 @@ +import { useLocation } from "react-router-dom"; +import { NetworkSelector } from "@/features/network-selector"; +import { ParticleListView } from "@/features/particles/particle-list-view"; +import { ParticleViewResolver } from "@/features/particles/particle-view-resolver"; + +function parsePathSegments(path: string): string[] { + return path.split("/").filter(Boolean); +} + +/** + * URL structure: + * / → network selector + * /:networkId → root particles for that network + * /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children) + */ +export default function PathResolver() { + const segments = parsePathSegments(useLocation().pathname); + + // No segments → show network selector + if (segments.length === 0) { + return ; + } + + const [networkId, ...particleSegments] = segments; + + // /:networkId with no particle segments → root particle list + if (particleSegments.length === 0) { + return ; + } + + // /:networkId/:p1/:p2/... → resolve and render the container particle + return ; +} diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx deleted file mode 100644 index 6b5dd23..0000000 --- a/js/src/pages/streams-page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Plus, LogOut } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Muted } from "@/components/ui/typography"; -import { Progress } from "@/components/ui/progress"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useAppStore } from "@/stores/app-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { StreamList } from "@/features/streams/stream-list"; -import { CreateStreamDialog } from "@/features/streams/create-stream-dialog"; -import { WindowControls } from "@/components/window-controls"; - -export function StreamsPage() { - const networks = useAppStore((s) => s.networks); - const selectedNetworkId = useAppStore((s) => s.selectedNetworkId); - const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork); - const signOut = useAuthStore((s) => s.signOut); - const user = useAuthStore((s) => s.user); - - const selectedNetwork = selectedNetworkId - ? networks.find((n) => n.id === selectedNetworkId) - : null; - - return ( -
- {/* Top bar — draggable for frameless window */} -
- - - - -
- - {selectedNetworkId && ( - - - - )} - - {user && ( - {user.email_prefix} - )} - - -
- - {/* Stream list */} - - - -
- ); -} diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts index f0b3034..1c7e56a 100644 --- a/js/src/stores/app-store.ts +++ b/js/src/stores/app-store.ts @@ -1,107 +1,15 @@ import { create } from "zustand"; -import { apiClient } from "@/api/client"; -import type { - AckInfo, - NetworkWithStreams, - Stream, - StreamParticle, -} from "@/api/types"; +/** + * Minimal app-level store. Navigation state is now URL-driven via PathResolver. + * Stream/particle state will move to Firestore hooks. + */ interface AppState { - networks: NetworkWithStreams[]; selectedNetworkId: string | null; - isLoading: boolean; - - fetchStartup: () => Promise; setSelectedNetwork: (id: string | null) => void; - addStream: (networkId: string, stream: Stream) => void; - addParticleToStream: (streamId: string, particle: StreamParticle) => void; - markParticlesSeen: (particleIds: string[]) => void; - ackParticle: (particleId: string, email: string) => void; } -export const useAppStore = create((set, get) => ({ - networks: [], +export const useAppStore = create((set) => ({ selectedNetworkId: null, - isLoading: false, - - fetchStartup: async () => { - set({ isLoading: true }); - try { - const data = await apiClient.startup(); - const state = get(); - const shouldAutoSelect = - !state.selectedNetworkId && data.networks.length > 0; - set({ - networks: data.networks, - ...(shouldAutoSelect - ? { selectedNetworkId: data.networks[0].id } - : {}), - }); - } finally { - set({ isLoading: false }); - } - }, - - setSelectedNetwork: (id) => { - set({ selectedNetworkId: id }); - }, - - addStream: (networkId, stream) => { - set({ - networks: get().networks.map((n) => - n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n, - ), - }); - }, - - addParticleToStream: (streamId, particle) => { - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => - s.id === streamId - ? { ...s, particles: [...s.particles, particle] } - : s, - ), - })), - }); - }, - - ackParticle: (particleId, email) => { - const ack: AckInfo = { email, acked_at: new Date().toISOString() }; - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => ({ - ...s, - particles: s.particles.map((p) => - p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p, - ), - })), - })), - }); - }, - - markParticlesSeen: (particleIds) => { - const idSet = new Set(particleIds); - set({ - networks: get().networks.map((n) => ({ - ...n, - streams: n.streams.map((s) => { - const unseenMarked = s.particles.filter( - (p) => !p.seen && idSet.has(p.id), - ).length; - if (unseenMarked === 0) return s; - return { - ...s, - unseen_count: Math.max(0, s.unseen_count - unseenMarked), - particles: s.particles.map((p) => - idSet.has(p.id) ? { ...p, seen: true } : p, - ), - }; - }), - })), - }); - }, + setSelectedNetwork: (id) => set({ selectedNetworkId: id }), })); diff --git a/js/yarn.lock b/js/yarn.lock index ea9f191..40f0e72 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -814,7 +814,7 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== @@ -2649,6 +2649,25 @@ "@tailwindcss/oxide" "4.2.0" tailwindcss "4.2.0" +"@tanstack/eslint-plugin-query@^5.91.4": + version "5.91.4" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz#b12f35280379aef0787074932ad698fd9bc621cc" + integrity sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ== + dependencies: + "@typescript-eslint/utils" "^8.48.0" + +"@tanstack/query-core@5.90.20": + version "5.90.20" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.20.tgz#e12128e39210715d4ce4fb299c33498ac297771e" + integrity sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg== + +"@tanstack/react-query@^5.90.21": + version "5.90.21" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.21.tgz#e0eb40831a76510be438109435b8807ef63ab1b9" + integrity sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg== + dependencies: + "@tanstack/query-core" "5.90.20" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -2875,6 +2894,15 @@ "@typescript-eslint/typescript-estree" "5.62.0" debug "^4.3.4" +"@typescript-eslint/project-service@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.1.tgz#16af9fe16eedbd7085e4fdc29baa73715c0c55c5" + integrity sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.57.1" + "@typescript-eslint/types" "^8.57.1" + debug "^4.4.3" + "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" @@ -2883,6 +2911,19 @@ "@typescript-eslint/types" "5.62.0" "@typescript-eslint/visitor-keys" "5.62.0" +"@typescript-eslint/scope-manager@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz#4524d7e7b420cb501807499684d435ae129aaf35" + integrity sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg== + dependencies: + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/visitor-keys" "8.57.1" + +"@typescript-eslint/tsconfig-utils@8.57.1", "@typescript-eslint/tsconfig-utils@^8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz#9233443ec716882a6f9e240fd900a73f0235f3d7" + integrity sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg== + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2898,6 +2939,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== +"@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.1.tgz#54b27a8a25a7b45b4f978c3f8e00c4c78f11142c" + integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2911,6 +2957,21 @@ semver "^7.3.7" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz#a9fd28d4a0ec896aa9a9a7e0cead62ea24f99e76" + integrity sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g== + dependencies: + "@typescript-eslint/project-service" "8.57.1" + "@typescript-eslint/tsconfig-utils" "8.57.1" + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/visitor-keys" "8.57.1" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.4.0" + "@typescript-eslint/utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2925,6 +2986,16 @@ eslint-scope "^5.1.1" semver "^7.3.7" +"@typescript-eslint/utils@^8.48.0": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.1.tgz#e40f5a7fcff02fd24092a7b52bd6ec029fb50465" + integrity sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.57.1" + "@typescript-eslint/types" "8.57.1" + "@typescript-eslint/typescript-estree" "8.57.1" + "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -2933,6 +3004,14 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz#3af4f88118924d3be983d4b8ae84803f11fe4563" + integrity sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A== + dependencies: + "@typescript-eslint/types" "8.57.1" + eslint-visitor-keys "^5.0.0" + "@ungap/structured-clone@^1.2.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" @@ -4375,6 +4454,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + eslint@^8.57.1: version "8.57.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" @@ -4653,7 +4737,7 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -fdir@^6.2.0: +fdir@^6.2.0, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -6162,6 +6246,13 @@ minimatch@^10.0.1: dependencies: brace-expansion "^5.0.2" +minimatch@^10.2.2: + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== + dependencies: + brace-expansion "^5.0.2" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -6772,7 +6863,7 @@ picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2: +picomatch@^4.0.2, picomatch@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -7391,7 +7482,7 @@ semver@^6.2.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7: +semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -7940,6 +8031,14 @@ tinyexec@^1.0.1: resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + tldts-core@^7.0.23: version "7.0.23" resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309" @@ -8002,6 +8101,11 @@ trim-repeated@^1.0.0: dependencies: escape-string-regexp "^1.0.2" +ts-api-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" + integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== + ts-morph@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73" @@ -8139,10 +8243,10 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@~4.5.4: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typescript@~5.4.5: version "5.4.5" @@ -8586,7 +8690,7 @@ zod@^3.24.1: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== -"zod@^3.25 || ^4.0": +"zod@^3.25 || ^4.0", zod@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==