feat: list streams and story-mode catchup
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Project Rules
|
||||
|
||||
## Architecture
|
||||
- Electron typescript/react app is in `js/` folder
|
||||
- Orion is the api server which lives in the `go/` folder
|
||||
- `cpp/` points to our prototype of a C++ Qt widgets client
|
||||
|
||||
## Electron App
|
||||
### Quality
|
||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
|
||||
+13
-4
@@ -1,7 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { HashRouter, Routes, Route } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { LoginPage } from "@/features/auth/login-page";
|
||||
import { HomePage } from "@/pages/home-page";
|
||||
import { StreamsPage } from "@/pages/streams-page";
|
||||
import { StreamPlayerPage } from "@/pages/stream-player-page";
|
||||
|
||||
const App = () => {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
@@ -19,11 +21,18 @@ const App = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "authenticated") {
|
||||
return <HomePage />;
|
||||
if (status !== "authenticated") {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return <LoginPage />;
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<StreamsPage />} />
|
||||
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type {
|
||||
CreateStreamParticleRequest,
|
||||
CreateStreamRequest,
|
||||
Human,
|
||||
MarkSeenBatchRequest,
|
||||
PrepareUploadRequest,
|
||||
PrepareUploadResponse,
|
||||
RequestCodeRequest,
|
||||
SignInRequest,
|
||||
SignInResponse,
|
||||
StartupResponse,
|
||||
Stream,
|
||||
StreamParticle,
|
||||
} from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -67,6 +74,8 @@ class ApiClient {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||
await this.request<void>("POST", "/auth/request-code", data);
|
||||
}
|
||||
@@ -83,9 +92,81 @@ class ApiClient {
|
||||
await this.request<void>("POST", "/auth/sign-out");
|
||||
}
|
||||
|
||||
// --- Startup ---
|
||||
|
||||
async startup(): Promise<StartupResponse> {
|
||||
return this.request<StartupResponse>("GET", "/startup");
|
||||
}
|
||||
|
||||
// --- Streams ---
|
||||
|
||||
async createStream(
|
||||
networkId: string,
|
||||
data: CreateStreamRequest,
|
||||
): Promise<Stream> {
|
||||
return this.request<Stream>(
|
||||
"POST",
|
||||
`/networks/${networkId}/streams`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
async createStreamParticle(
|
||||
streamId: string,
|
||||
data: CreateStreamParticleRequest,
|
||||
): Promise<StreamParticle> {
|
||||
return this.request<StreamParticle>(
|
||||
"POST",
|
||||
`/streams/${streamId}/particles`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Particles ---
|
||||
|
||||
async markSeen(particleId: string): Promise<void> {
|
||||
await this.request<void>("POST", `/particles/${particleId}/seen`);
|
||||
}
|
||||
|
||||
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
|
||||
await this.request<void>("POST", "/particles/seen", data);
|
||||
}
|
||||
|
||||
async getParticleDownloadUrl(particleId: string): Promise<string> {
|
||||
const token = this.config.getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.config.baseUrl}/particles/${particleId}/download`,
|
||||
{ headers, redirect: "follow" },
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
this.config.onUnauthorized();
|
||||
throw new ApiError(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, "Failed to get download URL");
|
||||
}
|
||||
|
||||
return response.url;
|
||||
}
|
||||
|
||||
// --- Depot ---
|
||||
|
||||
async prepareUpload(
|
||||
data: PrepareUploadRequest,
|
||||
): Promise<PrepareUploadResponse> {
|
||||
return this.request<PrepareUploadResponse>("POST", "/depot/upload", data);
|
||||
}
|
||||
|
||||
async confirmUpload(objectId: string): Promise<void> {
|
||||
await this.request<void>("POST", `/depot/objects/${objectId}/confirm`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
|
||||
+151
-4
@@ -1,11 +1,156 @@
|
||||
// --- Core entities ---
|
||||
|
||||
export interface Human {
|
||||
id: string;
|
||||
id: string | null;
|
||||
email: string;
|
||||
email_prefix: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export type StreamStatus = "open" | "closed" | "unspecified";
|
||||
|
||||
export interface AckInfo {
|
||||
email: string;
|
||||
acked_at: string;
|
||||
}
|
||||
|
||||
// --- Particle types ---
|
||||
|
||||
export type ParticleType =
|
||||
| "media"
|
||||
| "text"
|
||||
| "quest"
|
||||
| "paper"
|
||||
| "file"
|
||||
| "folder";
|
||||
|
||||
export interface MediaParticleData {
|
||||
object_id: string;
|
||||
duration_ms: number;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface TextParticleData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface QuestParticleData {
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
assigned_to?: string;
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
export interface PaperParticleData {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface FileParticleData {
|
||||
object_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FolderParticleData {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ParticleDataMap {
|
||||
media: MediaParticleData;
|
||||
text: TextParticleData;
|
||||
quest: QuestParticleData;
|
||||
paper: PaperParticleData;
|
||||
file: FileParticleData;
|
||||
folder: FolderParticleData;
|
||||
}
|
||||
|
||||
export function getParticleData<T extends ParticleType>(
|
||||
particle: StreamParticle,
|
||||
type: T,
|
||||
): ParticleDataMap[T] {
|
||||
return particle.data as ParticleDataMap[T];
|
||||
}
|
||||
|
||||
export interface StreamParticle {
|
||||
id: string;
|
||||
type: ParticleType;
|
||||
data: unknown;
|
||||
created_by_email: string;
|
||||
seen: boolean;
|
||||
acks: AckInfo[];
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: StreamStatus;
|
||||
members?: string[];
|
||||
particles: StreamParticle[];
|
||||
unseen_count: number;
|
||||
}
|
||||
|
||||
export interface Network {
|
||||
id: string;
|
||||
name: string;
|
||||
admin_human: Human;
|
||||
humans: Human[];
|
||||
open_stream_count: number;
|
||||
open_stream_capacity: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NetworkWithStreams extends Network {
|
||||
streams: Stream[];
|
||||
}
|
||||
|
||||
// --- Depot types ---
|
||||
|
||||
export interface PrepareUploadRequest {
|
||||
file_name: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface PrepareUploadResponse {
|
||||
object: DepotObject;
|
||||
upload_url: string;
|
||||
}
|
||||
|
||||
export interface DepotObject {
|
||||
id: string;
|
||||
status: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Stream mutation types ---
|
||||
|
||||
export interface CreateStreamRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
visibility: "network_all" | "custom";
|
||||
member_emails?: string[];
|
||||
}
|
||||
|
||||
export interface CreateStreamParticleRequest {
|
||||
type: ParticleType;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface MarkSeenBatchRequest {
|
||||
particle_ids: string[];
|
||||
}
|
||||
|
||||
// --- Auth types ---
|
||||
|
||||
export interface RequestCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
@@ -20,6 +165,8 @@ export interface SignInResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// --- Startup ---
|
||||
|
||||
export interface StartupResponse {
|
||||
networks: unknown[];
|
||||
networks: NetworkWithStreams[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Electron network layer: inject CORS headers and return 200 for preflight.
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
{ urls: ['https://orion.dev.flowy.live/*'] },
|
||||
{ urls: ['https://orion.dev.flowy.live/*', 'https://storage.googleapis.com/*'] },
|
||||
(details, callback) => {
|
||||
const headers = { ...details.responseHeaders };
|
||||
headers['access-control-allow-origin'] = ['*'];
|
||||
|
||||
@@ -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 { apiClient } from "@/api/client";
|
||||
import type { StartupResponse } from "@/api/types";
|
||||
import type {
|
||||
NetworkWithStreams,
|
||||
Stream,
|
||||
StreamParticle,
|
||||
} from "@/api/types";
|
||||
|
||||
interface AppState {
|
||||
startupData: StartupResponse | null;
|
||||
networks: NetworkWithStreams[];
|
||||
selectedNetworkId: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
fetchStartup: () => Promise<void>;
|
||||
setSelectedNetwork: (id: string | null) => void;
|
||||
addStream: (networkId: string, stream: Stream) => void;
|
||||
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
|
||||
markParticlesSeen: (particleIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
startupData: null,
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
networks: [],
|
||||
selectedNetworkId: null,
|
||||
isLoading: false,
|
||||
|
||||
fetchStartup: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const data = await apiClient.startup();
|
||||
set({ startupData: data });
|
||||
const state = get();
|
||||
const shouldAutoSelect =
|
||||
!state.selectedNetworkId && data.networks.length > 0;
|
||||
set({
|
||||
networks: data.networks,
|
||||
...(shouldAutoSelect
|
||||
? { selectedNetworkId: data.networks[0].id }
|
||||
: {}),
|
||||
});
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedNetwork: (id) => {
|
||||
set({ selectedNetworkId: id });
|
||||
},
|
||||
|
||||
addStream: (networkId, stream) => {
|
||||
set({
|
||||
networks: get().networks.map((n) =>
|
||||
n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
addParticleToStream: (streamId, particle) => {
|
||||
set({
|
||||
networks: get().networks.map((n) => ({
|
||||
...n,
|
||||
streams: n.streams.map((s) =>
|
||||
s.id === streamId
|
||||
? { ...s, particles: [...s.particles, particle] }
|
||||
: s,
|
||||
),
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
markParticlesSeen: (particleIds) => {
|
||||
const idSet = new Set(particleIds);
|
||||
set({
|
||||
networks: get().networks.map((n) => ({
|
||||
...n,
|
||||
streams: n.streams.map((s) => {
|
||||
const unseenMarked = s.particles.filter(
|
||||
(p) => !p.seen && idSet.has(p.id),
|
||||
).length;
|
||||
if (unseenMarked === 0) return s;
|
||||
return {
|
||||
...s,
|
||||
unseen_count: Math.max(0, s.unseen_count - unseenMarked),
|
||||
particles: s.particles.map((p) =>
|
||||
idSet.has(p.id) ? { ...p, seen: true } : p,
|
||||
),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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"
|
||||
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
|
||||
|
||||
cookie@^1.0.2:
|
||||
cookie@^1.0.1, cookie@^1.0.2:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
|
||||
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
|
||||
@@ -6500,6 +6500,21 @@ react-remove-scroll@^2.6.3:
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-router-dom@^7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
|
||||
integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
|
||||
dependencies:
|
||||
react-router "7.13.0"
|
||||
|
||||
react-router@7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
|
||||
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
|
||||
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
||||
@@ -6874,6 +6889,11 @@ serve-static@^2.2.0:
|
||||
parseurl "^1.3.3"
|
||||
send "^1.2.0"
|
||||
|
||||
set-cookie-parser@^2.6.0:
|
||||
version "2.7.2"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
|
||||
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
|
||||
|
||||
set-function-length@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||
|
||||
Reference in New Issue
Block a user