feat: list streams and story-mode catchup

This commit is contained in:
talksik
2026-02-21 09:46:04 -08:00
parent b5f90709de
commit 0cd74c0a8a
33 changed files with 2304 additions and 41 deletions
+5
View File
@@ -1,5 +1,10 @@
# Project Rules
## Architecture
- Electron typescript/react app is in `js/` folder
- Orion is the api server which lives in the `go/` folder
- `cpp/` points to our prototype of a C++ Qt widgets client
## Electron App
### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices.
+1
View File
@@ -51,6 +51,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"shadcn": "^3.8.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0",
+13 -4
View File
@@ -1,7 +1,9 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
import { HomePage } from "@/pages/home-page";
import { StreamsPage } from "@/pages/streams-page";
import { StreamPlayerPage } from "@/pages/stream-player-page";
const App = () => {
const status = useAuthStore((s) => s.status);
@@ -19,11 +21,18 @@ const App = () => {
);
}
if (status === "authenticated") {
return <HomePage />;
if (status !== "authenticated") {
return <LoginPage />;
}
return <LoginPage />;
return (
<HashRouter>
<Routes>
<Route path="/" element={<StreamsPage />} />
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
</Routes>
</HashRouter>
);
};
export default App;
+81
View File
@@ -1,10 +1,17 @@
import { useSessionStore } from "@/stores/session-store";
import type {
CreateStreamParticleRequest,
CreateStreamRequest,
Human,
MarkSeenBatchRequest,
PrepareUploadRequest,
PrepareUploadResponse,
RequestCodeRequest,
SignInRequest,
SignInResponse,
StartupResponse,
Stream,
StreamParticle,
} from "./types";
export class ApiError extends Error {
@@ -67,6 +74,8 @@ class ApiClient {
return response.json() as Promise<T>;
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
await this.request<void>("POST", "/auth/request-code", data);
}
@@ -83,9 +92,81 @@ class ApiClient {
await this.request<void>("POST", "/auth/sign-out");
}
// --- Startup ---
async startup(): Promise<StartupResponse> {
return this.request<StartupResponse>("GET", "/startup");
}
// --- 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 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;
}
// --- Depot ---
async prepareUpload(
data: PrepareUploadRequest,
): Promise<PrepareUploadResponse> {
return this.request<PrepareUploadResponse>("POST", "/depot/upload", data);
}
async confirmUpload(objectId: string): Promise<void> {
await this.request<void>("POST", `/depot/objects/${objectId}/confirm`);
}
}
export const apiClient = new ApiClient({
+151 -4
View File
@@ -1,11 +1,156 @@
// --- Core entities ---
export interface Human {
id: string;
id: string | null;
email: string;
email_prefix: string;
created_at: string;
updated_at: string;
created_at: string | null;
}
export type StreamStatus = "open" | "closed" | "unspecified";
export interface AckInfo {
email: string;
acked_at: string;
}
// --- Particle types ---
export type ParticleType =
| "media"
| "text"
| "quest"
| "paper"
| "file"
| "folder";
export interface MediaParticleData {
object_id: string;
duration_ms: number;
mime_type: string;
}
export interface TextParticleData {
content: string;
}
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<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;
}
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 ---
export interface PrepareUploadRequest {
file_name: string;
content_type: string;
size_bytes: number;
}
export interface PrepareUploadResponse {
object: DepotObject;
upload_url: string;
}
export interface DepotObject {
id: string;
status: string;
content_type: string;
size_bytes: number;
created_at: string;
}
// --- Stream mutation types ---
export interface CreateStreamRequest {
name: string;
description: string;
visibility: "network_all" | "custom";
member_emails?: string[];
}
export interface CreateStreamParticleRequest {
type: ParticleType;
data: unknown;
}
export interface MarkSeenBatchRequest {
particle_ids: string[];
}
// --- Auth types ---
export interface RequestCodeRequest {
email: string;
}
@@ -20,6 +165,8 @@ export interface SignInResponse {
token: string;
}
// --- Startup ---
export interface StartupResponse {
networks: unknown[];
networks: NetworkWithStreams[];
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+94
View File
@@ -0,0 +1,94 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+155
View File
@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+261
View File
@@ -0,0 +1,261 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary size-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+186
View File
@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted rounded-md animate-pulse", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,61 @@
import type { StreamParticle } from "@/api/types";
import { getParticleData } from "@/api/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
quest: { icon: ScrollTextIcon, label: "Quest" },
paper: { icon: BookOpenIcon, label: "Paper" },
file: { icon: FileIcon, label: "File" },
};
interface FallbackParticleViewProps {
particle: StreamParticle;
}
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon,
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case "quest":
return getParticleData(particle, "quest").title;
case "paper":
return getParticleData(particle, "paper").title;
case "file":
return getParticleData(particle, "file").filename;
case "folder":
return getParticleData(particle, "folder").name;
default:
return null;
}
})();
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Card className="w-full max-w-sm">
<CardHeader className="flex flex-row items-center gap-3">
<Icon className="text-muted-foreground h-6 w-6 shrink-0" />
<div>
<CardTitle className="text-base">{meta.label}</CardTitle>
{title && <CardDescription>{title}</CardDescription>}
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {particle.created_by_email}
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import type { MediaParticleData, StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { usePlaybackStore } from "@/stores/playback-store";
import { Skeleton } from "@/components/ui/skeleton";
interface MediaParticleViewProps {
particle: StreamParticle;
onEnded: () => void;
}
export function MediaParticleView({
particle,
onEnded,
}: MediaParticleViewProps) {
const cachedUrl = usePlaybackStore(
(s) => s.downloadUrlCache[particle.id],
);
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (cachedUrl) {
setUrl(cachedUrl);
return;
}
let cancelled = false;
apiClient
.getParticleDownloadUrl(particle.id)
.then((downloadUrl) => {
if (cancelled) return;
cacheDownloadUrl(particle.id, downloadUrl);
setUrl(downloadUrl);
})
.catch(() => {
if (!cancelled) setError("Failed to load media");
});
return () => {
cancelled = true;
};
}, [particle.id, cachedUrl, cacheDownloadUrl]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
{error}
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
const data = particle.data as MediaParticleData;
const isAudio = data.mime_type?.startsWith("audio/");
if (isAudio) {
return (
<div className="flex h-full w-full items-center justify-center">
<audio src={url} autoPlay onEnded={onEnded} controls />
</div>
);
}
return (
<video
src={url}
autoPlay
playsInline
onEnded={onEnded}
className="h-full w-full object-contain"
/>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import type { StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { MediaParticleView } from "./media-particle-view";
import { TextParticleView } from "./text-particle-view";
import { FallbackParticleView } from "./fallback-particle-view";
interface ParticleRendererProps {
particle: StreamParticle;
onNext: () => void;
onPrev: () => void;
}
export function ParticleRenderer({
particle,
onNext,
onPrev,
}: ParticleRendererProps) {
const markParticlesSeen = useAppStore((s) => s.markParticlesSeen);
const markedRef = useRef<string | null>(null);
useEffect(() => {
if (!particle.seen && markedRef.current !== particle.id) {
markedRef.current = particle.id;
markParticlesSeen([particle.id]);
apiClient.markSeen(particle.id).catch(() => {});
}
}, [particle.id, particle.seen, markParticlesSeen]);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) onPrev();
else if (x > 0.7) onNext();
};
const renderContent = () => {
switch (particle.type) {
case "media":
return <MediaParticleView particle={particle} onEnded={onNext} />;
case "text":
return <TextParticleView particle={particle} />;
default:
return <FallbackParticleView particle={particle} />;
}
};
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handleClick}
>
{renderContent()}
</div>
);
}
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress";
interface PlaybackControlsProps {
total: number;
current: number;
onGoTo: (index: number) => void;
}
const DOT_THRESHOLD = 15;
export function PlaybackControls({
total,
current,
onGoTo,
}: PlaybackControlsProps) {
if (total === 0) return null;
if (total <= DOT_THRESHOLD) {
return (
<div className="flex items-center justify-center gap-1 py-2">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="p-1"
>
<div
className={cn(
"h-2.5 rounded-full transition-all",
i === current
? "bg-primary w-6"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2.5",
)}
/>
</button>
))}
</div>
);
}
const percent = ((current + 1) / total) * 100;
return (
<div className="px-4 py-2">
<Progress value={percent} className="h-1" />
</div>
);
}
@@ -0,0 +1,20 @@
import type { StreamParticle, TextParticleData } from "@/api/types";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TextParticleViewProps {
particle: StreamParticle;
}
export function TextParticleView({ particle }: TextParticleViewProps) {
const data = particle.data as TextParticleData;
return (
<ScrollArea className="h-full w-full">
<div className="flex min-h-full items-center justify-center p-8">
<p className="max-w-2xl text-center text-2xl leading-relaxed">
{data.content}
</p>
</div>
</ScrollArea>
);
}
@@ -0,0 +1,38 @@
import { useRecordingStore } from "@/stores/recording-store";
import { cn } from "@/lib/utils";
export function ReplyIndicator() {
const status = useRecordingStore((s) => s.status);
if (status === "uploading") {
return (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
Uploading...
</div>
);
}
if (status === "recording") {
return (
<div className="flex items-center gap-2 text-xs text-red-400">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
Recording... press Q to cancel
</div>
);
}
return (
<div className="text-muted-foreground text-xs">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
`
</kbd>{" "}
to reply
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
const PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const FALLBACK_MIME = "video/webm";
function getMediaMime(): string {
if (MediaRecorder.isTypeSupported(PREFERRED_MIME)) return PREFERRED_MIME;
return FALLBACK_MIME;
}
export function useRecorder(streamId: string | null) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const status = useRecordingStore((s) => s.status);
const setStatus = useRecordingStore((s) => s.setStatus);
const setError = useRecordingStore((s) => s.setError);
const resetRecording = useRecordingStore((s) => s.reset);
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
}, []);
const upload = useCallback(
async (blob: Blob, durationMs: number) => {
if (!streamId) return;
setStatus("uploading");
const mimeType = blob.type || FALLBACK_MIME;
const fileName = `recording-${Date.now()}.webm`;
const { object, upload_url } = await apiClient.prepareUpload({
file_name: fileName,
content_type: mimeType,
size_bytes: blob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: blob,
});
await apiClient.confirmUpload(object.id);
const particle = await apiClient.createStreamParticle(streamId, {
type: "media",
data: {
object_id: object.id,
duration_ms: durationMs,
mime_type: mimeType,
},
});
addParticleToStream(streamId, particle);
// Also add to playback store's particle list
const playbackState = usePlaybackStore.getState();
if (playbackState.streamId === streamId) {
usePlaybackStore.setState({
particles: [...playbackState.particles, particle],
});
}
resetRecording();
},
[streamId, setStatus, resetRecording, addParticleToStream],
);
const startRecording = useCallback(async () => {
if (status !== "idle") return;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
streamRef.current = mediaStream;
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime();
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopTracks();
if (blob.size > 0) {
upload(blob, durationMs).catch((err) => {
setError(err instanceof Error ? err.message : "Upload failed");
});
} else {
resetRecording();
}
};
recorder.start();
setStatus("recording");
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [status, setStatus, setError, stopTracks, upload, resetRecording]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
resetRecording();
}, [stopTracks, resetRecording]);
// Keyboard bindings: backtick to record, q to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "`" && !e.repeat) {
e.preventDefault();
startRecording();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "`") {
e.preventDefault();
stopRecording();
}
if (e.key === "q" && status === "recording") {
e.preventDefault();
cancelRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [startRecording, stopRecording, cancelRecording, status]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopTracks();
};
}, [stopTracks]);
return { status };
}
@@ -0,0 +1,112 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import type { CreateStreamRequest } from "@/api/types";
interface CreateStreamDialogProps {
networkId: string;
children: React.ReactNode;
}
export function CreateStreamDialog({
networkId,
children,
}: CreateStreamDialogProps) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [visibility, setVisibility] =
useState<CreateStreamRequest["visibility"]>("network_all");
const [isCreating, setIsCreating] = useState(false);
const addStream = useAppStore((s) => s.addStream);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setIsCreating(true);
try {
const stream = await apiClient.createStream(networkId, {
name: name.trim(),
description: description.trim(),
visibility,
});
addStream(networkId, stream);
setOpen(false);
setName("");
setDescription("");
setVisibility("network_all");
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Stream</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="stream-name">Name</Label>
<Input
id="stream-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Stream name"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="stream-description">Description</Label>
<Input
id="stream-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Visibility</Label>
<Select
value={visibility}
onValueChange={(v) =>
setVisibility(v as CreateStreamRequest["visibility"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="network_all">Everyone in network</SelectItem>
<SelectItem value="custom">Custom members</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={!name.trim() || isCreating}>
{isCreating ? "Creating..." : "Create Stream"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { useAppStore } from "@/stores/app-store";
import { flattenStreams } from "@/lib/stream-utils";
import { formatDistanceToNow } from "@/lib/time-utils";
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="flex flex-col">
{streams.map((stream) => {
const lastParticle =
stream.particles.length > 0
? stream.particles[stream.particles.length - 1]
: null;
return (
<button
key={stream.id}
onClick={() => navigate(`/streams/${stream.id}`)}
className="hover:bg-accent/50 flex items-center gap-3 border-b px-4 py-3 text-left transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{stream.name}
</span>
{stream.unseen_count > 0 && (
<Badge variant="default" className="shrink-0">
{stream.unseen_count}
</Badge>
)}
</div>
{stream.description && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{stream.description}
</p>
)}
{lastParticle && (
<div className="text-muted-foreground mt-1 text-xs">
{formatDistanceToNow(lastParticle.created_at)}
</div>
)}
</div>
</button>
);
})}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
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 0;
const last = stream.particles[stream.particles.length - 1];
return new Date(last.created_at).getTime();
}
+21
View File
@@ -0,0 +1,21 @@
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
export function formatDistanceToNow(isoString: string): string {
const seconds = Math.floor(
(Date.now() - new Date(isoString).getTime()) / 1000,
);
if (seconds < 5) return "just now";
if (seconds < MINUTE) return `${seconds}s ago`;
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
return `${Math.floor(seconds / YEAR)}y ago`;
}
+1 -1
View File
@@ -38,7 +38,7 @@ app.on('ready', () => {
// The server doesn't handle OPTIONS preflight, so we intercept at the
// Electron network layer: inject CORS headers and return 200 for preflight.
session.defaultSession.webRequest.onHeadersReceived(
{ urls: ['https://orion.dev.flowy.live/*'] },
{ urls: ['https://orion.dev.flowy.live/*', 'https://storage.googleapis.com/*'] },
(details, callback) => {
const headers = { ...details.responseHeaders };
headers['access-control-allow-origin'] = ['*'];
-26
View File
@@ -1,26 +0,0 @@
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { H1, Muted } from "@/components/ui/typography";
import { useAuthStore } from "@/stores/auth-store";
import { useAppStore } from "@/stores/app-store";
export function HomePage() {
const user = useAuthStore((s) => s.user);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const signOut = useAuthStore((s) => s.signOut);
const fetchStartup = useAppStore((s) => s.fetchStartup);
useEffect(() => {
fetchStartup();
}, [fetchStartup]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
<H1>Welcome, {user?.email_prefix}</H1>
<Muted>{user?.email}</Muted>
<Button variant="outline" onClick={signOut} disabled={isSigningOut}>
{isSigningOut ? "Signing out..." : "Sign out"}
</Button>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { ParticleRenderer } from "@/features/playback/particle-renderer";
import { PlaybackControls } from "@/features/playback/playback-controls";
import { ReplyIndicator } from "@/features/recording/reply-indicator";
import { useRecorder } from "@/features/recording/use-recorder";
export function StreamPlayerPage() {
const { streamId } = useParams<{ streamId: string }>();
const navigate = useNavigate();
const networks = useAppStore((s) => s.networks);
const particles = usePlaybackStore((s) => s.particles);
const currentIndex = usePlaybackStore((s) => s.currentIndex);
const status = usePlaybackStore((s) => s.status);
const initStream = usePlaybackStore((s) => s.initStream);
const next = usePlaybackStore((s) => s.next);
const prev = usePlaybackStore((s) => s.prev);
const goTo = usePlaybackStore((s) => s.goTo);
const reset = usePlaybackStore((s) => s.reset);
useRecorder(streamId ?? null);
// Find the stream across all networks
const stream = networks
.flatMap((n) => n.streams)
.find((s) => s.id === streamId);
useEffect(() => {
if (!stream) return;
const firstUnseenIndex = stream.particles.findIndex((p) => !p.seen);
const startIndex =
firstUnseenIndex >= 0
? firstUnseenIndex
: Math.max(0, stream.particles.length - 1);
initStream(stream.id, stream.particles, startIndex);
return () => {
reset();
};
}, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
prev();
} else if (e.key === "Escape") {
navigate("/");
}
},
[next, prev, navigate],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
if (!stream) {
return (
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Stream not found</p>
</div>
);
}
if (particles.length === 0) {
return (
<div className="flex h-screen flex-col">
<Header name={stream.name} onBack={() => navigate("/")} />
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">
No particles yet. Hold ` to record the first one.
</p>
</div>
<div className="flex justify-center border-t py-3">
<ReplyIndicator />
</div>
</div>
);
}
const currentParticle = particles[currentIndex];
return (
<div className="flex h-screen flex-col bg-black text-white">
{/* Top bar */}
<div className="relative z-10 flex items-center justify-between px-3 py-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/10"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-xs font-medium">{stream.name}</span>
<div className="w-9" /> {/* Spacer for centering */}
</div>
{/* Progress */}
<PlaybackControls total={particles.length} current={currentIndex} onGoTo={goTo} />
{/* Particle content */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<ParticleRenderer
particle={currentParticle}
onNext={next}
onPrev={prev}
/>
)}
</div>
{/* Bottom bar */}
<div className="relative z-10 flex items-center justify-between px-4 py-3">
<span className="text-muted-foreground text-xs">
{currentParticle?.created_by_email}
</span>
<ReplyIndicator />
</div>
{status === "ended" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-white">End of stream</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/")}
>
Back to streams
</Button>
</div>
</div>
)}
</div>
);
}
function Header({
name,
onBack,
}: {
name: string;
onBack: () => void;
}) {
return (
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">{name}</span>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect } from "react";
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";
export function StreamsPage() {
const fetchStartup = useAppStore((s) => s.fetchStartup);
const isLoading = useAppStore((s) => s.isLoading);
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);
useEffect(() => {
fetchStartup();
}, [fetchStartup]);
const selectedNetwork = selectedNetworkId
? networks.find((n) => n.id === selectedNetworkId)
: null;
return (
<div className="flex h-screen flex-col">
{/* Top bar */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<Select
value={selectedNetworkId ?? undefined}
onValueChange={(value) => setSelectedNetwork(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a network" />
</SelectTrigger>
<SelectContent>
{networks.map((network) => (
<SelectItem key={network.id} value={network.id}>
{network.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNetwork && (
<div className="flex items-center gap-2">
<Progress
value={
(selectedNetwork.open_stream_count /
selectedNetwork.open_stream_capacity) *
100
}
className="h-1.5 w-24"
/>
<Muted className="text-xs">
{selectedNetwork.open_stream_count}/
{selectedNetwork.open_stream_capacity} streams
</Muted>
</div>
)}
<div className="flex-1" />
{selectedNetworkId && (
<CreateStreamDialog networkId={selectedNetworkId}>
<Button variant="outline" size="sm">
<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="text-muted-foreground"
onClick={signOut}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
{/* Stream list */}
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
) : (
<ScrollArea className="flex-1">
<StreamList />
</ScrollArea>
)}
</div>
);
}
+71 -5
View File
@@ -1,24 +1,90 @@
import { create } from "zustand";
import { apiClient } from "@/api/client";
import type { StartupResponse } from "@/api/types";
import type {
NetworkWithStreams,
Stream,
StreamParticle,
} from "@/api/types";
interface AppState {
startupData: StartupResponse | null;
networks: NetworkWithStreams[];
selectedNetworkId: string | null;
isLoading: boolean;
fetchStartup: () => Promise<void>;
setSelectedNetwork: (id: string | null) => void;
addStream: (networkId: string, stream: Stream) => void;
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
markParticlesSeen: (particleIds: string[]) => void;
}
export const useAppStore = create<AppState>((set) => ({
startupData: null,
export const useAppStore = create<AppState>((set, get) => ({
networks: [],
selectedNetworkId: null,
isLoading: false,
fetchStartup: async () => {
set({ isLoading: true });
try {
const data = await apiClient.startup();
set({ startupData: data });
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,
),
})),
});
},
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,
),
};
}),
})),
});
},
}));
+80
View File
@@ -0,0 +1,80 @@
import { create } from "zustand";
import type { StreamParticle } from "@/api/types";
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
streamId: string | null;
particles: StreamParticle[];
currentIndex: number;
status: PlaybackStatus;
downloadUrlCache: Record<string, string>;
initStream: (
streamId: string,
particles: StreamParticle[],
startIndex: number,
) => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
cacheDownloadUrl: (particleId: string, url: string) => void;
reset: () => void;
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
downloadUrlCache: {},
initStream: (streamId, particles, startIndex) => {
set({
streamId,
particles,
currentIndex: startIndex,
status: particles.length > 0 ? "playing" : "ended",
downloadUrlCache: {},
});
},
next: () => {
const { currentIndex, particles } = get();
if (currentIndex < particles.length - 1) {
set({ currentIndex: currentIndex + 1 });
} else {
set({ status: "ended" });
}
},
prev: () => {
const { currentIndex } = get();
if (currentIndex > 0) {
set({ currentIndex: currentIndex - 1, status: "playing" });
}
},
goTo: (index) => {
const { particles } = get();
if (index >= 0 && index < particles.length) {
set({ currentIndex: index, status: "playing" });
}
},
cacheDownloadUrl: (particleId, url) => {
set({
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
});
},
reset: () => {
set({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
downloadUrlCache: {},
});
},
}));
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
type RecordingStatus = "idle" | "recording" | "uploading" | "error";
interface RecordingState {
status: RecordingStatus;
error: string | null;
setStatus: (status: RecordingStatus) => void;
setError: (error: string) => void;
reset: () => void;
}
export const useRecordingStore = create<RecordingState>((set) => ({
status: "idle",
error: null,
setStatus: (status) => set({ status, error: null }),
setError: (error) => set({ status: "error", error }),
reset: () => set({ status: "idle", error: null }),
}));
+21 -1
View File
@@ -3271,7 +3271,7 @@ cookie@^0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
cookie@^1.0.2:
cookie@^1.0.1, cookie@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
@@ -6500,6 +6500,21 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-router-dom@^7.13.0:
version "7.13.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
dependencies:
react-router "7.13.0"
react-router@7.13.0:
version "7.13.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
@@ -6874,6 +6889,11 @@ serve-static@^2.2.0:
parseurl "^1.3.3"
send "^1.2.0"
set-cookie-parser@^2.6.0:
version "2.7.2"
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"