feat: list streams and story-mode catchup
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Project Rules
|
# 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
|
## Electron App
|
||||||
### Quality
|
### Quality
|
||||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
|
|||||||
+13
-4
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { HashRouter, Routes, Route } from "react-router-dom";
|
||||||
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 { HomePage } from "@/pages/home-page";
|
import { StreamsPage } from "@/pages/streams-page";
|
||||||
|
import { StreamPlayerPage } from "@/pages/stream-player-page";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
@@ -19,11 +21,18 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "authenticated") {
|
if (status !== "authenticated") {
|
||||||
return <HomePage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <LoginPage />;
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<StreamsPage />} />
|
||||||
|
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type {
|
import type {
|
||||||
|
CreateStreamParticleRequest,
|
||||||
|
CreateStreamRequest,
|
||||||
Human,
|
Human,
|
||||||
|
MarkSeenBatchRequest,
|
||||||
|
PrepareUploadRequest,
|
||||||
|
PrepareUploadResponse,
|
||||||
RequestCodeRequest,
|
RequestCodeRequest,
|
||||||
SignInRequest,
|
SignInRequest,
|
||||||
SignInResponse,
|
SignInResponse,
|
||||||
StartupResponse,
|
StartupResponse,
|
||||||
|
Stream,
|
||||||
|
StreamParticle,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -67,6 +74,8 @@ class ApiClient {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||||
await this.request<void>("POST", "/auth/request-code", data);
|
await this.request<void>("POST", "/auth/request-code", data);
|
||||||
}
|
}
|
||||||
@@ -83,9 +92,81 @@ class ApiClient {
|
|||||||
await this.request<void>("POST", "/auth/sign-out");
|
await this.request<void>("POST", "/auth/sign-out");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Startup ---
|
||||||
|
|
||||||
async startup(): Promise<StartupResponse> {
|
async startup(): Promise<StartupResponse> {
|
||||||
return this.request<StartupResponse>("GET", "/startup");
|
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({
|
export const apiClient = new ApiClient({
|
||||||
|
|||||||
+151
-4
@@ -1,11 +1,156 @@
|
|||||||
|
// --- Core entities ---
|
||||||
|
|
||||||
export interface Human {
|
export interface Human {
|
||||||
id: string;
|
id: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
email_prefix: string;
|
email_prefix: string;
|
||||||
created_at: string;
|
created_at: string | null;
|
||||||
updated_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface RequestCodeRequest {
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
@@ -20,6 +165,8 @@ export interface SignInResponse {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Startup ---
|
||||||
|
|
||||||
export interface StartupResponse {
|
export interface StartupResponse {
|
||||||
networks: unknown[];
|
networks: NetworkWithStreams[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -38,7 +38,7 @@ app.on('ready', () => {
|
|||||||
// The server doesn't handle OPTIONS preflight, so we intercept at the
|
// The server doesn't handle OPTIONS preflight, so we intercept at the
|
||||||
// Electron network layer: inject CORS headers and return 200 for preflight.
|
// Electron network layer: inject CORS headers and return 200 for preflight.
|
||||||
session.defaultSession.webRequest.onHeadersReceived(
|
session.defaultSession.webRequest.onHeadersReceived(
|
||||||
{ urls: ['https://orion.dev.flowy.live/*'] },
|
{ urls: ['https://orion.dev.flowy.live/*', 'https://storage.googleapis.com/*'] },
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
const headers = { ...details.responseHeaders };
|
const headers = { ...details.responseHeaders };
|
||||||
headers['access-control-allow-origin'] = ['*'];
|
headers['access-control-allow-origin'] = ['*'];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,90 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import type { StartupResponse } from "@/api/types";
|
import type {
|
||||||
|
NetworkWithStreams,
|
||||||
|
Stream,
|
||||||
|
StreamParticle,
|
||||||
|
} from "@/api/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
startupData: StartupResponse | null;
|
networks: NetworkWithStreams[];
|
||||||
|
selectedNetworkId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
fetchStartup: () => Promise<void>;
|
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) => ({
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
startupData: null,
|
networks: [],
|
||||||
|
selectedNetworkId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
||||||
fetchStartup: async () => {
|
fetchStartup: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.startup();
|
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 {
|
} finally {
|
||||||
set({ isLoading: false });
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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
@@ -3271,7 +3271,7 @@ cookie@^0.7.1:
|
|||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
|
||||||
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
|
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
|
||||||
|
|
||||||
cookie@^1.0.2:
|
cookie@^1.0.1, cookie@^1.0.2:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
|
||||||
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
|
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
|
||||||
@@ -6500,6 +6500,21 @@ react-remove-scroll@^2.6.3:
|
|||||||
use-callback-ref "^1.3.3"
|
use-callback-ref "^1.3.3"
|
||||||
use-sidecar "^1.1.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"
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
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:
|
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||||
version "2.2.3"
|
version "2.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
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"
|
parseurl "^1.3.3"
|
||||||
send "^1.2.0"
|
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:
|
set-function-length@^1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||||
|
|||||||
Reference in New Issue
Block a user