diff --git a/js/src/features/layoutwithpath.tsx b/js/src/features/layoutwithpath.tsx index a227476..c8c7ea7 100644 --- a/js/src/features/layoutwithpath.tsx +++ b/js/src/features/layoutwithpath.tsx @@ -13,7 +13,6 @@ import { import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useNetworks } from "@/hooks/use-networks"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; -import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { const { data: networks } = useNetworks(); @@ -119,8 +118,6 @@ function TopBar() { } export default function LayoutWithPath() { - useComposeKeyboard(); - return (
diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index f28bd1d..ce33d8e 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,6 +1,7 @@ import { useParams } from "react-router-dom"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; +import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; /** * Route-level component for /:networkId (index). @@ -9,6 +10,7 @@ import { ParticleListView } from "@/features/particles/particle-list-view"; export default function NetworkRoot() { const { networkId } = useParams(); const path = particlePath(networkId!, []); + useComposeKeyboard(); return ( diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx index 4100434..dc0791a 100644 --- a/js/src/features/particles/folder-view.tsx +++ b/js/src/features/particles/folder-view.tsx @@ -1,6 +1,7 @@ import { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; import type { ParticlePath } from "@/lib/particle-path"; +import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; interface FolderViewProps { folderParticle: Particle; @@ -8,6 +9,7 @@ interface FolderViewProps { } export function FolderView({ path, folderParticle }: FolderViewProps) { + useComposeKeyboard(); const { children, error, isLoading } = useLiveParticleChildren(path); return ( diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index d373e81..5ee8c98 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; import { useNavigate } from "react-router-dom"; import { Radio } from "lucide-react"; import { useLiveParticleChildren } from "@/hooks/use-particle"; @@ -54,6 +55,7 @@ interface ParticleListViewProps { * List of stream particles for a container (network root, folder, etc.). */ export function ParticleListView({ path }: ParticleListViewProps) { + useComposeKeyboard(); const { children, isLoading } = useLiveParticleChildren(path); const { networkId } = parseParticlePath(path); const navigate = useNavigate(); diff --git a/js/src/features/streams/particle-preview.tsx b/js/src/features/particles/particle-preview.tsx similarity index 68% rename from js/src/features/streams/particle-preview.tsx rename to js/src/features/particles/particle-preview.tsx index 3f7eeb8..7b7d155 100644 --- a/js/src/features/streams/particle-preview.tsx +++ b/js/src/features/particles/particle-preview.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; -import type { StreamParticle } from "@/api/types"; -import { getParticleData } from "@/api/types"; +import type { Particle } from "@/api/types"; import { apiClient } from "@/api/client"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -11,21 +10,8 @@ import { FileIcon, FolderIcon, } from "lucide-react"; -import { cn } from "@/lib/utils"; -interface ParticlePreviewProps { - particle: StreamParticle; -} - -/** Dynamic text sizing for card previews — inspired by TextParticleView. */ -function getPreviewTextStyle(length: number) { - if (length < 30) return "text-xl font-semibold"; - if (length < 80) return "text-lg font-medium"; - if (length < 200) return "text-base font-normal"; - return "text-sm font-normal"; -} - -export function ParticlePreview({ particle }: ParticlePreviewProps) { +export function ParticlePreview({ particle }: { particle: Particle }) { switch (particle.type) { case "text": return ; @@ -44,27 +30,24 @@ export function ParticlePreview({ particle }: ParticlePreviewProps) { } } -function TextPreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "text"); - // only show x first chars for preview, to avoid overflow and also to determine text size - const truncated = data.content.length > 30 ? data.content.slice(0, 30) + "..." : data.content; +function TextPreview({ particle }: { particle: Extract }) { + const truncated = + particle.properties.content.length > 30 + ? particle.properties.content.slice(0, 30) + "..." + : particle.properties.content; return (
-

+

{truncated}

); } -function MediaPreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "media"); - const isVideo = data.mime_type.startsWith("video"); - const durationSec = Math.round(data.duration_ms / 1000); +function MediaPreview({ particle }: { particle: Extract }) { + const { mime_type, duration_ms } = particle.properties; + const isVideo = mime_type.startsWith("video"); + const durationSec = Math.round(duration_ms / 1000); const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`; if (isVideo) { @@ -132,54 +115,51 @@ function VideoThumbnail({ ); } -function QuestPreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "quest"); +function QuestPreview({ particle }: { particle: Extract }) { + const { title, status } = particle.properties; return (

- {data.title} + {title}

- {data.status && ( + {status && ( - {data.status} + {status} )}
); } -function PaperPreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "paper"); +function PaperPreview({ particle }: { particle: Extract }) { return (

- {data.title} + {particle.properties.title}

); } -function FilePreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "file"); +function FilePreview({ particle }: { particle: Extract }) { return (

- {data.filename} + {particle.properties.filename}

); } -function FolderPreview({ particle }: { particle: StreamParticle }) { - const data = getParticleData(particle, "folder"); +function FolderPreview({ particle }: { particle: Extract }) { return (

- {data.name} + {particle.properties.name}

); diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 3bb7514..f682bd4 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -1,6 +1,15 @@ -import { Particle } from "@/api/types"; +import { useEffect, useCallback } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; import type { ParticlePath } from "@/lib/particle-path"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useComposeStore } from "@/stores/compose-store"; +import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; +import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator"; +import { ParticleRenderer } from "@/features/playback/particle-renderer"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import ControlsIndicator from "@/features/compose/controls-indicator"; interface StreamViewProps { streamParticle: Particle; @@ -8,29 +17,146 @@ interface StreamViewProps { } export function StreamView({ path, streamParticle }: StreamViewProps) { - const { children, error, isLoading } = useLiveParticleChildren(path); + const { networkId } = useParams(); + const navigate = useNavigate(); + const { children } = useLiveParticleChildren(path); + + const status = usePlaybackStore((s) => s.status); + const currentIndex = usePlaybackStore((s) => s.currentIndex); + const particles = usePlaybackStore((s) => s.particles); + const initStream = usePlaybackStore((s) => s.initStream); + const goTo = usePlaybackStore((s) => s.goTo); + const next = usePlaybackStore((s) => s.next); + const prev = usePlaybackStore((s) => s.prev); + const pause = usePlaybackStore((s) => s.pause); + const resume = usePlaybackStore((s) => s.resume); + const reset = usePlaybackStore((s) => s.reset); + + // Compose keyboard (backtick, t, q, escape-during-compose) + useComposeKeyboard(); + + // Init playback when children change + useEffect(() => { + if (children.length > 0) { + initStream(streamParticle.id, children, 0); + } + return () => reset(); + }, [children, streamParticle.id, initStream, reset]); + + // Pause/resume playback when compose overlay opens/closes + useEffect(() => { + return useComposeStore.subscribe((state) => { + if (state.step !== "idle") { + pause(); + } else { + resume(); + } + }); + }, [pause, resume]); + + // Playback keyboard: arrows, escape + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const composeStep = useComposeStore.getState().step; + if (composeStep !== "idle") return; + + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + next(); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + prev(); + break; + case "Escape": + e.preventDefault(); + navigate(`/${networkId}`); + break; + } + }, + [next, prev, navigate, networkId], + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const currentParticle = particles[currentIndex] ?? null; + + // Stream name from properties (narrowed to stream type) + const streamName = + streamParticle.type === "stream" + ? streamParticle.properties.name + : ""; + + // Author info from current particle + const authorEmail = currentParticle?.created_by_email ?? ""; + const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? ""; + + if (children.length === 0) { + return ( +
+

+ No particles in this stream yet +

+ +
+ ); + } return ( -
-

- Stream view — {streamParticle.id} -

+
+ {/* Progress indicator */} +
+ +
- {isLoading &&

Loading stream data...

} - {error &&

Failed to load stream data

} - - {!isLoading && !error && ( -
-

Stream Children:

-
    - {children.map((child) => ( -
  • - {child.id} ({child.type}) -
  • - ))} -
+ {/* Author overlay */} + {currentParticle && ( +
+ + + {authorInitials} + + + + {authorEmail.split("@")[0]} +
)} + + {/* Main playback area */} +
+ {currentParticle && status !== "ended" ? ( + + ) : ( +
+

End of stream

+
+ )} +
+ + {/* Stream name overlay */} +
+ {streamName} +
); } diff --git a/js/src/features/playback/ack-button.tsx b/js/src/features/playback/ack-button.tsx deleted file mode 100644 index ddc79ce..0000000 --- a/js/src/features/playback/ack-button.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Heart } from "lucide-react"; -import { useCallback } from "react"; -import type { AckInfo } from "@/api/types"; -import { apiClient } from "@/api/client"; -import { useAuthStore } from "@/stores/auth-store"; -import { useAppStore } from "@/stores/app-store"; -import { cn } from "@/lib/utils"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -interface AckButtonProps { - particleId: string; - acks: AckInfo[]; -} - -function getInitials(email: string): string { - const prefix = email.split("@")[0]; - const parts = prefix.split(/[._-]/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return prefix.slice(0, 2).toUpperCase(); -} - -export function AckButton({ particleId, acks }: AckButtonProps) { - const currentEmail = useAuthStore((s) => s.user?.email); - const ackParticle = useAppStore((s) => s.ackParticle); - const hasAcked = acks.some((a) => a.email === currentEmail); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (hasAcked || !currentEmail) return; - ackParticle(particleId, currentEmail); - apiClient.ackParticle(particleId).catch(() => {}); - }, - [hasAcked, currentEmail, particleId, ackParticle], - ); - - const displayedAcks = acks.slice(0, 3); - - return ( -
- - - {displayedAcks.length > 0 && ( -
- {displayedAcks.map((ack) => ( - - -
- {getInitials(ack.email)} -
-
- -

{ack.email}

-
-
- ))} -
- )} -
- ); -} diff --git a/js/src/features/playback/fallback-particle-view.tsx b/js/src/features/playback/fallback-particle-view.tsx index 234af19..0ef8654 100644 --- a/js/src/features/playback/fallback-particle-view.tsx +++ b/js/src/features/playback/fallback-particle-view.tsx @@ -1,5 +1,4 @@ -import type { StreamParticle } from "@/api/types"; -import { getParticleData } from "@/api/types"; +import type { Particle } from "@/api/types"; import { Card, CardContent, @@ -16,7 +15,7 @@ const TYPE_META: Record = { }; interface FallbackParticleViewProps { - particle: StreamParticle; + particle: Particle; } export function FallbackParticleView({ particle }: FallbackParticleViewProps) { @@ -28,13 +27,13 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) { const title = (() => { switch (particle.type) { case "quest": - return getParticleData(particle, "quest").title; + return particle.properties.title; case "paper": - return getParticleData(particle, "paper").title; + return particle.properties.title; case "file": - return getParticleData(particle, "file").filename; + return particle.properties.filename; case "folder": - return getParticleData(particle, "folder").name; + return particle.properties.name; default: return null; } diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx index 26efe40..a7d92db 100644 --- a/js/src/features/playback/media-particle-view.tsx +++ b/js/src/features/playback/media-particle-view.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; -import type { MediaParticleData, StreamParticle } from "@/api/types"; +import type { Particle } from "@/api/types"; import { apiClient } from "@/api/client"; import { usePlaybackStore } from "@/stores/playback-store"; import { Skeleton } from "@/components/ui/skeleton"; -import { AudioLevelBars } from "@/components/audio/audio-level-bars"; -import { useAudioSource } from "@/components/audio/use-audio-source"; + +type MediaParticle = Extract; interface MediaParticleViewProps { - particle: StreamParticle; + particle: MediaParticle; } function formatTime(ms: number): string { @@ -48,17 +48,14 @@ export function MediaParticleView({ const audioRef = useRef(null); const [currentTimeMs, setCurrentTimeMs] = useState(0); - const data = particle.data as MediaParticleData; - const isAudio = data.mime_type?.startsWith("audio/"); + const isAudio = particle.properties.mime_type?.startsWith("audio/"); useEffect(() => { - if (cachedUrl) { - return; - } + if (cachedUrl) return; let cancelled = false; apiClient - .getParticleDownloadUrl(particle.id) + .getParticleDownloadUrl(particle.properties.object_id) .then((downloadUrl) => { if (cancelled) return; cacheDownloadUrl(particle.id, downloadUrl); @@ -70,17 +67,10 @@ export function MediaParticleView({ return () => { cancelled = true; }; - }, [particle.id, cacheDownloadUrl]); + }, [particle.id, particle.properties.object_id, cacheDownloadUrl]); - // Handle pause/resume useEffect(() => { - var el: HTMLVideoElement | HTMLAudioElement | null = null; - if (isAudio) { - el = audioRef.current; - } else { - el = videoRef.current; - } - + const el = isAudio ? audioRef.current : videoRef.current; if (!el) return; if (paused) { @@ -120,7 +110,7 @@ export function MediaParticleView({
); @@ -141,7 +131,7 @@ export function MediaParticleView({ />
); diff --git a/js/src/features/playback/particle-renderer.tsx b/js/src/features/playback/particle-renderer.tsx index 88d8107..ca3b265 100644 --- a/js/src/features/playback/particle-renderer.tsx +++ b/js/src/features/playback/particle-renderer.tsx @@ -1,15 +1,11 @@ -import { useEffect, useRef } from "react"; -import type { StreamParticle } from "@/api/types"; -import { apiClient } from "@/api/client"; -import { useAppStore } from "@/stores/app-store"; +import type { Particle } from "@/api/types"; import { usePlaybackStore } from "@/stores/playback-store"; import { MediaParticleView } from "./media-particle-view"; import { TextParticleView } from "./text-particle-view"; import { FallbackParticleView } from "./fallback-particle-view"; -import { AckButton } from "./ack-button"; interface ParticleRendererProps { - particle: StreamParticle; + particle: Particle; } export function ParticleRenderer({ @@ -18,17 +14,6 @@ export function ParticleRenderer({ const next = usePlaybackStore((s) => s.next); const prev = usePlaybackStore((s) => s.prev); - const markParticlesSeen = useAppStore((s) => s.markParticlesSeen); - const markedRef = useRef(null); - - useEffect(() => { - if (!particle.seen && markedRef.current !== particle.id) { - markedRef.current = particle.id; - markParticlesSeen([particle.id]); - apiClient.markSeen(particle.id); - } - }, [particle.id, particle.seen, markParticlesSeen]); - const handleClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; @@ -42,17 +27,13 @@ export function ParticleRenderer({ onClick={handleClick} > -
- -
); } -function ParticleContent({ particle }: { particle: StreamParticle }) { +function ParticleContent({ particle }: { particle: Particle }) { switch (particle.type) { case "media": - {/* NOTE: it's more robust to re-mount the MediaParticleView when the particle changes, to ensure playback state is well-behaved */ } return ; case "text": return ; diff --git a/js/src/features/playback/text-particle-view.tsx b/js/src/features/playback/text-particle-view.tsx index 51245ea..f55eb8c 100644 --- a/js/src/features/playback/text-particle-view.tsx +++ b/js/src/features/playback/text-particle-view.tsx @@ -1,8 +1,10 @@ -import type { StreamParticle, TextParticleData } from "@/api/types"; +import type { Particle } from "@/api/types"; import { cn } from "@/lib/utils"; +type TextParticle = Extract; + interface TextParticleViewProps { - particle: StreamParticle; + particle: TextParticle; } function getTextStyle(length: number) { @@ -13,8 +15,7 @@ function getTextStyle(length: number) { } export function TextParticleView({ particle }: TextParticleViewProps) { - const data = particle.data as TextParticleData; - const style = getTextStyle(data.content.length); + const style = getTextStyle(particle.properties.content.length); return (
@@ -25,7 +26,7 @@ export function TextParticleView({ particle }: TextParticleViewProps) { style.weight, )} > - {data.content} + {particle.properties.content}

); diff --git a/js/src/features/streams/create-stream-dialog.tsx b/js/src/features/streams/create-stream-dialog.tsx deleted file mode 100644 index 1b19971..0000000 --- a/js/src/features/streams/create-stream-dialog.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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("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 ( - - {children} - - - New Stream - -
-
- - setName(e.target.value)} - placeholder="Stream name" - autoFocus - /> -
-
- - setDescription(e.target.value)} - placeholder="Optional description" - /> -
-
- - -
- -
-
-
- ); -}