diff --git a/CLAUDE.md b/CLAUDE.md index 3cb51b1..96948a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,8 @@ - Orion is the api server which lives in the `go/` folder - `cpp/` points to our prototype of a C++ Qt widgets client +Whenever implementing anything, make sure to take into account best practices without over-engineering. + ## Electron App ### Quality We care about overall architectural quality and keeping consistent patterns according to best practices. @@ -16,3 +18,8 @@ As an example, we have as high of a bar as a product team like Linear, which out ### Design system Whenever possible, we should use the design system components. If we need to add a new component from the available ones in [shadcn](https://ui.shadcn.com/docs/components), we should add it to the design system (using `shadcn add _`) and use it in the app. + +### Implementation completeness +When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through. + +IF you find that the API is poorly designed, please suggest changes to improve the client experience. diff --git a/js/src/App.tsx b/js/src/App.tsx index cfcb70d..c372cd3 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,10 +1,40 @@ import { useEffect } from "react"; import { HashRouter, Routes, Route } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useAppStore } from "@/stores/app-store"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; import { StreamsPage } from "@/pages/streams-page"; import { StreamPlayerPage } from "@/pages/stream-player-page"; +function AuthenticatedApp() { + const fetchStartup = useAppStore((s) => s.fetchStartup); + const isLoading = useAppStore((s) => s.isLoading); + + useEffect(() => { + fetchStartup(); + }, [fetchStartup]); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + return ( + + + + } /> + } /> + + + + ); +} + const App = () => { const status = useAuthStore((s) => s.status); const restoreSession = useAuthStore((s) => s.restoreSession); @@ -25,14 +55,7 @@ const App = () => { return ; } - return ( - - - } /> - } /> - - - ); + return ; }; export default App; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index d963922..f08f0d8 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -128,6 +128,10 @@ class ApiClient { await this.request("POST", `/particles/${particleId}/seen`); } + async ackParticle(particleId: string): Promise { + await this.request("POST", `/particles/${particleId}/ack`); + } + async markSeenBatch(data: MarkSeenBatchRequest): Promise { await this.request("POST", "/particles/seen", data); } diff --git a/js/src/components/ui/tooltip.tsx b/js/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..c4b30c9 --- /dev/null +++ b/js/src/components/ui/tooltip.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/js/src/features/playback/ack-button.tsx b/js/src/features/playback/ack-button.tsx new file mode 100644 index 0000000..ddc79ce --- /dev/null +++ b/js/src/features/playback/ack-button.tsx @@ -0,0 +1,84 @@ +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/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx index 9ed7fdd..6193c32 100644 --- a/js/src/features/playback/media-particle-view.tsx +++ b/js/src/features/playback/media-particle-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { MediaParticleData, StreamParticle } from "@/api/types"; import { apiClient } from "@/api/client"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -17,9 +17,13 @@ export function MediaParticleView({ (s) => s.downloadUrlCache[particle.id], ); const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl); + const paused = usePlaybackStore((s) => s.paused); const [url, setUrl] = useState(cachedUrl ?? null); const [error, setError] = useState(null); + const videoRef = useRef(null); + const audioRef = useRef(null); + useEffect(() => { if (cachedUrl) { setUrl(cachedUrl); @@ -43,6 +47,17 @@ export function MediaParticleView({ }; }, [particle.id, cachedUrl, cacheDownloadUrl]); + useEffect(() => { + const el = videoRef.current ?? audioRef.current; + if (!el) return; + + if (paused) { + el.pause(); + } else { + el.play().catch(() => {}); + } + }, [paused]); + if (error) { return (
@@ -61,18 +76,19 @@ export function MediaParticleView({ if (isAudio) { return (
-
); } return (
); } diff --git a/js/src/features/playback/playback-controls.tsx b/js/src/features/playback/playback-controls.tsx index d9fb708..213a3a3 100644 --- a/js/src/features/playback/playback-controls.tsx +++ b/js/src/features/playback/playback-controls.tsx @@ -1,5 +1,4 @@ import { cn } from "@/lib/utils"; -import { Progress } from "@/components/ui/progress"; interface PlaybackControlsProps { total: number; @@ -7,8 +6,6 @@ interface PlaybackControlsProps { onGoTo: (index: number) => void; } -const DOT_THRESHOLD = 15; - export function PlaybackControls({ total, current, @@ -16,37 +13,27 @@ export function PlaybackControls({ }: PlaybackControlsProps) { if (total === 0) return null; - if (total <= DOT_THRESHOLD) { - return ( -
- {Array.from({ length: total }, (_, i) => ( - - ))} -
- ); - } - - const percent = ((current + 1) / total) * 100; - return ( -
- +
+ {Array.from({ length: total }, (_, i) => ( + + ))}
); } diff --git a/js/src/features/playback/text-particle-view.tsx b/js/src/features/playback/text-particle-view.tsx index 46ee556..51245ea 100644 --- a/js/src/features/playback/text-particle-view.tsx +++ b/js/src/features/playback/text-particle-view.tsx @@ -1,20 +1,32 @@ import type { StreamParticle, TextParticleData } from "@/api/types"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; interface TextParticleViewProps { particle: StreamParticle; } +function getTextStyle(length: number) { + if (length < 50) return { size: "text-5xl", weight: "font-semibold" }; + if (length < 150) return { size: "text-3xl", weight: "font-semibold" }; + if (length < 300) return { size: "text-2xl", weight: "font-normal" }; + return { size: "text-lg", weight: "font-normal" }; +} + export function TextParticleView({ particle }: TextParticleViewProps) { const data = particle.data as TextParticleData; + const style = getTextStyle(data.content.length); return ( - -
-

- {data.content} -

-
-
+
+

+ {data.content} +

+
); } diff --git a/js/src/features/recording/recording-overlay.tsx b/js/src/features/recording/recording-overlay.tsx new file mode 100644 index 0000000..79829d8 --- /dev/null +++ b/js/src/features/recording/recording-overlay.tsx @@ -0,0 +1,279 @@ +import { useEffect, useRef, useState } from "react"; +import { useRecordingStore } from "@/stores/recording-store"; + +interface RecordingOverlayProps { + onClose: () => void; +} + +function AudioLevelBars({ mediaStream }: { mediaStream: MediaStream }) { + const audioRef = useRef<{ analyser: AnalyserNode; ctx: AudioContext } | null>( + null, + ); + const [levels, setLevels] = useState([0, 0, 0]); + const rafRef = useRef(0); + + useEffect(() => { + const ctx = new AudioContext(); + const source = ctx.createMediaStreamSource(mediaStream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + audioRef.current = { analyser, ctx }; + + const dataArray = new Uint8Array(analyser.fftSize); + + function tick() { + analyser.getByteTimeDomainData(dataArray); + + // Compute RMS of waveform (128 = silence baseline) + let sumSquares = 0; + for (let i = 0; i < dataArray.length; i++) { + const normalized = (dataArray[i] - 128) / 128; + sumSquares += normalized * normalized; + } + const rms = Math.sqrt(sumSquares / dataArray.length); + + // VU meter: 3 bars with staggered thresholds + const bar0 = Math.min(1, rms * 3); + const bar1 = Math.max(0, Math.min(1, (rms - 0.1) * 3)); + const bar2 = Math.max(0, Math.min(1, (rms - 0.25) * 3)); + setLevels([bar0, bar1, bar2]); + + rafRef.current = requestAnimationFrame(tick); + } + + rafRef.current = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(rafRef.current); + ctx.close(); + }; + }, [mediaStream]); + + return ( +
+ {levels.map((level, i) => ( +
+ ))} +
+ ); +} + +function RecordingTimer() { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setElapsed((prev) => prev + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + const display = `${minutes}:${seconds.toString().padStart(2, "0")}`; + + return ( +
+ + {display} +
+ ); +} + +function ReviewPlayback({ + blob, + isVideo, +}: { + blob: Blob; + isVideo: boolean; +}) { + const urlRef = useRef(null); + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + const url = URL.createObjectURL(blob); + urlRef.current = url; + setObjectUrl(url); + + return () => { + URL.revokeObjectURL(url); + urlRef.current = null; + }; + }, [blob]); + + if (!objectUrl) return null; + + if (isVideo) { + return ( +