setup boilerplate for data and rendering
This includes zod types creation for API response validation, and exploration of path based resolution of rendering particles.
This commit is contained in:
+4
-1
@@ -31,6 +31,7 @@
|
|||||||
"@electron-forge/plugin-vite": "^7.11.1",
|
"@electron-forge/plugin-vite": "^7.11.1",
|
||||||
"@electron/fuses": "^1.8.0",
|
"@electron/fuses": "^1.8.0",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -41,10 +42,11 @@
|
|||||||
"electron": "40.6.0",
|
"electron": "40.6.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"typescript": "~4.5.4",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-31
@@ -1,40 +1,16 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { HashRouter, Routes, Route } from "react-router-dom";
|
import { HashRouter, Routes, Route } from "react-router-dom";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
import { StreamsPage } from "@/pages/streams-page";
|
|
||||||
import { StreamPlayerPage } from "@/pages/stream-player-page";
|
|
||||||
import { } from "@/firebase";
|
import { } from "@/firebase";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import PathResolver from "./pages/path-resolver";
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
const queryClient = new QueryClient();
|
||||||
const fetchStartup = useAppStore((s) => s.fetchStartup);
|
|
||||||
const isLoading = useAppStore((s) => s.isLoading);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStartup();
|
|
||||||
}, [fetchStartup]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<HashRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<StreamsPage />} />
|
|
||||||
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
|
|
||||||
</Routes>
|
|
||||||
</HashRouter>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
@@ -59,4 +35,23 @@ const App = () => {
|
|||||||
return <AuthenticatedApp />;
|
return <AuthenticatedApp />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
function AuthenticatedApp() {
|
||||||
|
// NOTE: Hash router provides history, despite using catch-all
|
||||||
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="*" element={<PathResolver />} />
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppWithProviders = () => (
|
||||||
|
<TooltipProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AppWithProviders;
|
||||||
|
|||||||
+90
-91
@@ -1,17 +1,19 @@
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
DepotObjectSchema,
|
||||||
|
HumanSchema,
|
||||||
|
ListNetworksResponseSchema,
|
||||||
|
NetworkSchema,
|
||||||
|
PrepareUploadResponseSchema,
|
||||||
|
SignInResponseSchema,
|
||||||
|
} from "./types";
|
||||||
import type {
|
import type {
|
||||||
CreateStreamParticleRequest,
|
AddMembersRequest,
|
||||||
CreateStreamRequest,
|
CreateNetworkRequest,
|
||||||
Human,
|
|
||||||
MarkSeenBatchRequest,
|
|
||||||
PrepareUploadRequest,
|
PrepareUploadRequest,
|
||||||
PrepareUploadResponse,
|
|
||||||
RequestCodeRequest,
|
RequestCodeRequest,
|
||||||
SignInRequest,
|
SignInRequest,
|
||||||
SignInResponse,
|
|
||||||
StartupResponse,
|
|
||||||
Stream,
|
|
||||||
StreamParticle,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -37,11 +39,11 @@ class ApiClient {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async fetch(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<T> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
@@ -67,109 +69,106 @@ class ApiClient {
|
|||||||
throw new ApiError(response.status, text);
|
throw new ApiError(response.status, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
return response;
|
||||||
return undefined as T;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
private async request<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
await this.fetch(method, path, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
|
||||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||||
await this.request<void>("POST", "/auth/request-code", data);
|
await this.requestVoid("POST", "/auth/request-code", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(data: SignInRequest): Promise<SignInResponse> {
|
async signIn(data: SignInRequest) {
|
||||||
return this.request<SignInResponse>("POST", "/auth/sign-in", data);
|
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async me(): Promise<Human> {
|
async me() {
|
||||||
return this.request<Human>("GET", "/auth/me");
|
return this.request(HumanSchema, "GET", "/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut(): Promise<void> {
|
async signOut(): Promise<void> {
|
||||||
await this.request<void>("POST", "/auth/sign-out");
|
await this.requestVoid("POST", "/auth/sign-out");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Startup ---
|
// TODO: security: require passing in the particle id once api deprecates this
|
||||||
|
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||||
async startup(): Promise<StartupResponse> {
|
const response = await this.fetch(
|
||||||
return this.request<StartupResponse>("GET", "/startup");
|
"GET",
|
||||||
}
|
`/particles/${objectId}/download`,
|
||||||
|
|
||||||
// --- Streams ---
|
|
||||||
|
|
||||||
async createStream(
|
|
||||||
networkId: string,
|
|
||||||
data: CreateStreamRequest,
|
|
||||||
): Promise<Stream> {
|
|
||||||
return this.request<Stream>(
|
|
||||||
"POST",
|
|
||||||
`/networks/${networkId}/streams`,
|
|
||||||
data,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
async createStreamParticle(
|
|
||||||
streamId: string,
|
|
||||||
data: CreateStreamParticleRequest,
|
|
||||||
): Promise<StreamParticle> {
|
|
||||||
return this.request<StreamParticle>(
|
|
||||||
"POST",
|
|
||||||
`/streams/${streamId}/particles`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Particles ---
|
|
||||||
|
|
||||||
async markSeen(particleId: string): Promise<void> {
|
|
||||||
await this.request<void>("POST", `/particles/${particleId}/seen`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async ackParticle(particleId: string): Promise<void> {
|
|
||||||
await this.request<void>("POST", `/particles/${particleId}/ack`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
|
|
||||||
await this.request<void>("POST", "/particles/seen", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getParticleDownloadUrl(particleId: string): Promise<string> {
|
|
||||||
const token = this.config.getToken();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
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;
|
return response.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
|
|
||||||
async prepareUpload(
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
data: PrepareUploadRequest,
|
return this.request(
|
||||||
): Promise<PrepareUploadResponse> {
|
PrepareUploadResponseSchema,
|
||||||
return this.request<PrepareUploadResponse>("POST", "/depot/upload", data);
|
"POST",
|
||||||
|
"/depot/upload",
|
||||||
|
data,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmUpload(objectId: string): Promise<void> {
|
async confirmUpload(objectId: string) {
|
||||||
await this.request<void>("POST", `/depot/objects/${objectId}/confirm`);
|
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<void> {
|
||||||
|
await this.requestVoid(
|
||||||
|
"POST",
|
||||||
|
`/networks/${networkId}/members`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(networkId: string, email: string): Promise<void> {
|
||||||
|
await this.requestVoid(
|
||||||
|
"DELETE",
|
||||||
|
`/networks/${networkId}/members/${email}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-144
@@ -1,168 +1,169 @@
|
|||||||
// --- Core entities ---
|
import { z } from "zod";
|
||||||
|
|
||||||
export interface Human {
|
export const HumanSchema = z.object({
|
||||||
id: string | null;
|
id: z.string().nullable(),
|
||||||
email: string;
|
created_at: z.coerce.date().nullable(),
|
||||||
email_prefix: string;
|
email: z.string().email(),
|
||||||
created_at: string | null;
|
email_prefix: z.string(),
|
||||||
}
|
});
|
||||||
|
|
||||||
export type StreamStatus = "open" | "closed" | "unspecified";
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
|
|
||||||
export interface AckInfo {
|
export const NetworkSchema = z.object({
|
||||||
email: string;
|
id: z.string(),
|
||||||
acked_at: string;
|
name: z.string(),
|
||||||
}
|
admin_human: HumanSchema,
|
||||||
|
humans: z.array(HumanSchema),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
|
||||||
// --- Particle types ---
|
export type Network = z.infer<typeof NetworkSchema>;
|
||||||
|
|
||||||
export type ParticleType =
|
export const ListNetworksResponseSchema = z.array(NetworkSchema);
|
||||||
| "media"
|
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
||||||
| "text"
|
|
||||||
| "quest"
|
|
||||||
| "paper"
|
|
||||||
| "file"
|
|
||||||
| "folder";
|
|
||||||
|
|
||||||
export interface MediaParticleData {
|
// --- Network request/response types ---
|
||||||
object_id: string;
|
|
||||||
duration_ms: number;
|
|
||||||
mime_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextParticleData {
|
const CreateNetworkRequestSchema = z.object({
|
||||||
content: string;
|
name: z.string(),
|
||||||
}
|
});
|
||||||
|
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||||
|
|
||||||
export interface QuestParticleData {
|
const AddMembersRequestSchema = z.object({
|
||||||
title: string;
|
email_addresses: z.array(z.string().email()),
|
||||||
description: string;
|
});
|
||||||
status?: string;
|
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||||
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<T extends ParticleType>(
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Depot types ---
|
// --- Depot types ---
|
||||||
|
|
||||||
export interface PrepareUploadRequest {
|
const PrepareUploadRequestSchema = z.object({
|
||||||
network_id: string;
|
network_id: z.string(),
|
||||||
name: string;
|
name: z.string(),
|
||||||
content_type: string;
|
content_type: z.string(),
|
||||||
content_length: number;
|
content_length: z.number(),
|
||||||
|
});
|
||||||
|
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
|
||||||
|
|
||||||
|
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<typeof PrepareUploadResponseSchema>;
|
||||||
|
|
||||||
|
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<typeof DepotObjectSchema>;
|
||||||
|
|
||||||
|
// --- 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<typeof StreamParticleDataSchema>;
|
||||||
|
|
||||||
|
export const FolderParticleDataSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type FolderParticleData = z.infer<typeof FolderParticleDataSchema>;
|
||||||
|
|
||||||
|
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<typeof MediaParticleDataSchema>;
|
||||||
|
|
||||||
|
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<typeof FileParticleDataSchema>;
|
||||||
|
|
||||||
|
export const TextParticleDataSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
export type TextParticleData = z.infer<typeof TextParticleDataSchema>;
|
||||||
|
|
||||||
|
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<typeof QuestParticleDataSchema>;
|
||||||
|
|
||||||
|
export const PaperParticleDataSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
export type PaperParticleData = z.infer<typeof PaperParticleDataSchema>;
|
||||||
|
|
||||||
|
export interface ParticleDataMap {
|
||||||
|
stream: StreamParticleData;
|
||||||
|
folder: FolderParticleData;
|
||||||
|
media: MediaParticleData;
|
||||||
|
file: FileParticleData;
|
||||||
|
text: TextParticleData;
|
||||||
|
quest: QuestParticleData;
|
||||||
|
paper: PaperParticleData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrepareUploadResponse {
|
// --- Unified Particle types ---
|
||||||
object_id: string;
|
|
||||||
upload_url: string;
|
interface ParticleBase {
|
||||||
upload_headers: Record<string, string>;
|
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 {
|
export type ParticleType = Particle["type"];
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
visibility: "network_all" | "custom";
|
|
||||||
member_emails?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateStreamParticleRequest {
|
/** Container types can have children subcollections */
|
||||||
type: ParticleType;
|
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkSeenBatchRequest {
|
export function isContainerType(type: ParticleType): boolean {
|
||||||
particle_ids: string[];
|
return CONTAINER_TYPES.has(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth types ---
|
// --- Auth types ---
|
||||||
|
|
||||||
export interface RequestCodeRequest {
|
const RequestCodeRequestSchema = z.object({
|
||||||
email: string;
|
email: z.string().email(),
|
||||||
}
|
});
|
||||||
|
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||||
|
|
||||||
export interface SignInRequest {
|
const SignInRequestSchema = z.object({
|
||||||
email: string;
|
email: z.string().email(),
|
||||||
code: string;
|
code: z.string(),
|
||||||
}
|
});
|
||||||
|
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
|
||||||
|
|
||||||
export interface SignInResponse {
|
export const SignInResponseSchema = z.object({
|
||||||
human: Human;
|
human: HumanSchema,
|
||||||
token: string;
|
token: z.string(),
|
||||||
}
|
});
|
||||||
|
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||||
// --- Startup ---
|
|
||||||
|
|
||||||
export interface StartupResponse {
|
|
||||||
networks: NetworkWithStreams[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 <Progress />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<p className="text-destructive text-sm">Failed to load networks</p>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center max-w-sm mx-auto">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You don't have access to any networks yet. Please email us to get started.
|
||||||
|
</p>
|
||||||
|
<a href="mailto:[email protected]" className="text-primary underline">
|
||||||
|
team@flowylabs.ai
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<WindowControls />
|
||||||
|
<div className="flex-1" />
|
||||||
|
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="no-drag text-muted-foreground"
|
||||||
|
onClick={signOut}
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4">
|
||||||
|
<Select onValueChange={(value) => navigate(`/${value}`)}>
|
||||||
|
<SelectTrigger className="no-drag w-64">
|
||||||
|
<SelectValue placeholder="Select a network" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data?.map((network) => (
|
||||||
|
<SelectItem key={network.id} value={network.id}>
|
||||||
|
{network.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Folder view — {networkId}/{particleSegments.join("/")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">Loading particles...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">No particles yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-4">
|
||||||
|
{children.map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="rounded-lg border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{child.id}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{child.type}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-destructive text-sm">Failed to load particle</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the hook is stubbed, particle will be null — show a placeholder
|
||||||
|
if (!particle) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Particle: {particleSegments.join(" / ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (particle.type) {
|
||||||
|
case "stream":
|
||||||
|
return <StreamView networkId={networkId} particleSegments={particleSegments} />;
|
||||||
|
case "folder":
|
||||||
|
return <FolderView networkId={networkId} particleSegments={particleSegments} />;
|
||||||
|
default:
|
||||||
|
// For container types we haven't built a view for, fall back to list
|
||||||
|
if (isContainerType(particle.type)) {
|
||||||
|
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
|
||||||
|
}
|
||||||
|
// Leaf particle — placeholder
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{particle.type} particle: {particle.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Stream view — {networkId}/{particleSegments.join("/")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">No streams yet</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-3 p-4">
|
|
||||||
{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 (
|
|
||||||
<Card
|
|
||||||
key={stream.id}
|
|
||||||
size="sm"
|
|
||||||
className="hover:bg-accent/50 cursor-pointer overflow-hidden transition-colors pt-0!"
|
|
||||||
onClick={() => navigate(`/streams/${stream.id}`)}
|
|
||||||
>
|
|
||||||
{/* Preview hero area */}
|
|
||||||
<div className="relative aspect-[4/3] overflow-hidden">
|
|
||||||
{lastParticle ? (
|
|
||||||
<ParticlePreview particle={lastParticle} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-xs italic">
|
|
||||||
No messages yet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unseen badge overlay */}
|
|
||||||
{stream.unseen_count > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="absolute top-1.5 right-1.5 text-[10px]"
|
|
||||||
>
|
|
||||||
{stream.unseen_count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: avatar + stream info */}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
{senderEmail ? (
|
|
||||||
<Avatar size="sm">
|
|
||||||
<AvatarFallback className="text-[10px]">
|
|
||||||
{getInitials(senderEmail)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
) : (
|
|
||||||
<div className="size-6 shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium">{stream.name}</p>
|
|
||||||
<p className="text-muted-foreground truncate text-[11px]">
|
|
||||||
{senderPrefix && <span>{senderPrefix}</span>}
|
|
||||||
{senderPrefix && timeSource && <span> · </span>}
|
|
||||||
{timeSource && <span>{formatDistanceToNow(timeSource)}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("/");
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInitials(email: string): string {
|
||||||
|
const prefix = email.split("@")[0] ?? "";
|
||||||
|
return prefix.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <NetworkSelector />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [networkId, ...particleSegments] = segments;
|
||||||
|
|
||||||
|
// /:networkId with no particle segments → root particle list
|
||||||
|
if (particleSegments.length === 0) {
|
||||||
|
return <ParticleListView networkId={networkId} particleSegments={[]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /:networkId/:p1/:p2/... → resolve and render the container particle
|
||||||
|
return <ParticleViewResolver networkId={networkId} particleSegments={particleSegments} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
{/* Top bar — draggable for frameless window */}
|
|
||||||
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
|
||||||
<WindowControls />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={selectedNetworkId ?? undefined}
|
|
||||||
onValueChange={(value) => setSelectedNetwork(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="no-drag">
|
|
||||||
<SelectValue placeholder="Select a network" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{networks.map((network) => (
|
|
||||||
<SelectItem key={network.id} value={network.id}>
|
|
||||||
{network.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{selectedNetworkId && (
|
|
||||||
<CreateStreamDialog networkId={selectedNetworkId}>
|
|
||||||
<Button variant="outline" size="sm" className="no-drag">
|
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
New Stream
|
|
||||||
</Button>
|
|
||||||
</CreateStreamDialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user && (
|
|
||||||
<Muted className="text-xs">{user.email_prefix}</Muted>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="no-drag text-muted-foreground"
|
|
||||||
onClick={signOut}
|
|
||||||
>
|
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stream list */}
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<StreamList />
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +1,15 @@
|
|||||||
import { create } from "zustand";
|
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 {
|
interface AppState {
|
||||||
networks: NetworkWithStreams[];
|
|
||||||
selectedNetworkId: string | null;
|
selectedNetworkId: string | null;
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
fetchStartup: () => Promise<void>;
|
|
||||||
setSelectedNetwork: (id: string | null) => void;
|
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<AppState>((set, get) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
networks: [],
|
|
||||||
selectedNetworkId: null,
|
selectedNetworkId: null,
|
||||||
isLoading: false,
|
setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
+113
-9
@@ -814,7 +814,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
|
||||||
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
|
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"
|
version "4.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
||||||
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
|
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
|
||||||
@@ -2649,6 +2649,25 @@
|
|||||||
"@tailwindcss/oxide" "4.2.0"
|
"@tailwindcss/oxide" "4.2.0"
|
||||||
tailwindcss "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/[email protected]":
|
||||||
|
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":
|
"@tootallnate/once@2":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
||||||
@@ -2875,6 +2894,15 @@
|
|||||||
"@typescript-eslint/typescript-estree" "5.62.0"
|
"@typescript-eslint/typescript-estree" "5.62.0"
|
||||||
debug "^4.3.4"
|
debug "^4.3.4"
|
||||||
|
|
||||||
|
"@typescript-eslint/[email protected]":
|
||||||
|
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/[email protected]":
|
"@typescript-eslint/[email protected]":
|
||||||
version "5.62.0"
|
version "5.62.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
|
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/types" "5.62.0"
|
||||||
"@typescript-eslint/visitor-keys" "5.62.0"
|
"@typescript-eslint/visitor-keys" "5.62.0"
|
||||||
|
|
||||||
|
"@typescript-eslint/[email protected]":
|
||||||
|
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/[email protected]", "@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/[email protected]":
|
"@typescript-eslint/[email protected]":
|
||||||
version "5.62.0"
|
version "5.62.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
|
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"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
|
||||||
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
|
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
|
||||||
|
|
||||||
|
"@typescript-eslint/[email protected]", "@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/[email protected]":
|
"@typescript-eslint/[email protected]":
|
||||||
version "5.62.0"
|
version "5.62.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
|
||||||
@@ -2911,6 +2957,21 @@
|
|||||||
semver "^7.3.7"
|
semver "^7.3.7"
|
||||||
tsutils "^3.21.0"
|
tsutils "^3.21.0"
|
||||||
|
|
||||||
|
"@typescript-eslint/[email protected]":
|
||||||
|
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/[email protected]":
|
"@typescript-eslint/[email protected]":
|
||||||
version "5.62.0"
|
version "5.62.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
|
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
|
||||||
@@ -2925,6 +2986,16 @@
|
|||||||
eslint-scope "^5.1.1"
|
eslint-scope "^5.1.1"
|
||||||
semver "^7.3.7"
|
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/[email protected]":
|
"@typescript-eslint/[email protected]":
|
||||||
version "5.62.0"
|
version "5.62.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
|
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"
|
"@typescript-eslint/types" "5.62.0"
|
||||||
eslint-visitor-keys "^3.3.0"
|
eslint-visitor-keys "^3.3.0"
|
||||||
|
|
||||||
|
"@typescript-eslint/[email protected]":
|
||||||
|
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":
|
"@ungap/structured-clone@^1.2.0":
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
|
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"
|
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==
|
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:
|
eslint@^8.57.1:
|
||||||
version "8.57.1"
|
version "8.57.1"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
|
||||||
@@ -4653,7 +4737,7 @@ fd-slicer@~1.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pend "~1.2.0"
|
pend "~1.2.0"
|
||||||
|
|
||||||
fdir@^6.2.0:
|
fdir@^6.2.0, fdir@^6.5.0:
|
||||||
version "6.5.0"
|
version "6.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
|
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
|
||||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
||||||
@@ -6162,6 +6246,13 @@ minimatch@^10.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^5.0.2"
|
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:
|
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
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"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||||
|
|
||||||
picomatch@^4.0.2:
|
picomatch@^4.0.2, picomatch@^4.0.3:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
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"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
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"
|
version "7.7.4"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||||
@@ -7940,6 +8031,14 @@ tinyexec@^1.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
|
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
|
||||||
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
|
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:
|
tldts-core@^7.0.23:
|
||||||
version "7.0.23"
|
version "7.0.23"
|
||||||
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309"
|
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309"
|
||||||
@@ -8002,6 +8101,11 @@ trim-repeated@^1.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp "^1.0.2"
|
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:
|
ts-morph@^26.0.0:
|
||||||
version "26.0.0"
|
version "26.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73"
|
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"
|
possible-typed-array-names "^1.0.0"
|
||||||
reflect.getprototypeof "^1.0.6"
|
reflect.getprototypeof "^1.0.6"
|
||||||
|
|
||||||
typescript@~4.5.4:
|
typescript@^5.9.3:
|
||||||
version "4.5.5"
|
version "5.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||||
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
|
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||||
|
|
||||||
typescript@~5.4.5:
|
typescript@~5.4.5:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
|
||||||
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
|
||||||
|
|
||||||
"zod@^3.25 || ^4.0":
|
"zod@^3.25 || ^4.0", zod@^4.3.6:
|
||||||
version "4.3.6"
|
version "4.3.6"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
|
||||||
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
||||||
|
|||||||
Reference in New Issue
Block a user