improve stream previews
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "eslint --ext .ts,.tsx .",
|
"lint": "eslint --ext .ts,.tsx .",
|
||||||
"compile": "npx tsc --noEmit 2>&1 | grep -E '^src/'"
|
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full aspect-square size-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
@@ -18,6 +18,22 @@ function formatTime(ms: number): string {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DurationPill({
|
||||||
|
currentTimeMs,
|
||||||
|
totalDurationMs,
|
||||||
|
}: {
|
||||||
|
currentTimeMs: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-3 right-3 rounded-full bg-white/10 px-2.5 py-1 backdrop-blur-sm">
|
||||||
|
<span className="font-mono text-xs text-white/80">
|
||||||
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaParticleView({
|
export function MediaParticleView({
|
||||||
particle,
|
particle,
|
||||||
onEnded,
|
onEnded,
|
||||||
@@ -35,7 +51,10 @@ export function MediaParticleView({
|
|||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const [currentTimeMs, setCurrentTimeMs] = useState(0);
|
const [currentTimeMs, setCurrentTimeMs] = useState(0);
|
||||||
|
|
||||||
const audioSource = useAudioSource(audioEl);
|
const data = particle.data as MediaParticleData;
|
||||||
|
const isAudio = data.mime_type?.startsWith("audio/");
|
||||||
|
|
||||||
|
const audioSource = useAudioSource(isAudio ? audioEl : null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedUrl) {
|
if (cachedUrl) {
|
||||||
@@ -60,8 +79,19 @@ export function MediaParticleView({
|
|||||||
};
|
};
|
||||||
}, [particle.id, cachedUrl, cacheDownloadUrl]);
|
}, [particle.id, cachedUrl, cacheDownloadUrl]);
|
||||||
|
|
||||||
|
// Start audio playback once the AudioContext source is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = videoRef.current ?? audioRef.current;
|
const el = audioRef.current;
|
||||||
|
if (!el || !audioSource) return;
|
||||||
|
|
||||||
|
if (!paused) {
|
||||||
|
el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [audioSource, paused]);
|
||||||
|
|
||||||
|
// Handle video pause/resume
|
||||||
|
useEffect(() => {
|
||||||
|
const el = videoRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (paused) {
|
if (paused) {
|
||||||
@@ -71,6 +101,16 @@ export function MediaParticleView({
|
|||||||
}
|
}
|
||||||
}, [paused]);
|
}, [paused]);
|
||||||
|
|
||||||
|
// Handle audio pause/resume (after initial play)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || !audioSource) return;
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
el.pause();
|
||||||
|
}
|
||||||
|
}, [paused, audioSource]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
||||||
@@ -83,9 +123,6 @@ export function MediaParticleView({
|
|||||||
return <Skeleton className="h-full w-full rounded-none" />;
|
return <Skeleton className="h-full w-full rounded-none" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = particle.data as MediaParticleData;
|
|
||||||
const isAudio = data.mime_type?.startsWith("audio/");
|
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||||
@@ -94,8 +131,8 @@ export function MediaParticleView({
|
|||||||
audioRef.current = el;
|
audioRef.current = el;
|
||||||
setAudioEl(el);
|
setAudioEl(el);
|
||||||
}}
|
}}
|
||||||
|
crossOrigin="anonymous"
|
||||||
src={url}
|
src={url}
|
||||||
autoPlay
|
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={(e) => {
|
onTimeUpdate={(e) => {
|
||||||
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
|
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
|
||||||
@@ -106,23 +143,31 @@ export function MediaParticleView({
|
|||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute top-3 right-3 rounded-full bg-black/30 px-2.5 py-1 backdrop-blur-sm">
|
<DurationPill
|
||||||
<span className="font-mono text-xs text-white/80">
|
currentTimeMs={currentTimeMs}
|
||||||
{formatTime(currentTimeMs)} / {formatTime(data.duration_ms)}
|
totalDurationMs={data.duration_ms}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<div className="relative h-full w-full">
|
||||||
ref={videoRef}
|
<video
|
||||||
src={url}
|
ref={videoRef}
|
||||||
autoPlay
|
src={url}
|
||||||
playsInline
|
autoPlay
|
||||||
onEnded={onEnded}
|
playsInline
|
||||||
className="h-full w-full object-cover"
|
onEnded={onEnded}
|
||||||
/>
|
onTimeUpdate={(e) => {
|
||||||
|
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
|
||||||
|
}}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<DurationPill
|
||||||
|
currentTimeMs={currentTimeMs}
|
||||||
|
totalDurationMs={data.duration_ms}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { StreamParticle } from "@/api/types";
|
import type { StreamParticle } from "@/api/types";
|
||||||
import { getParticleData } from "@/api/types";
|
import { getParticleData } from "@/api/types";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
|
||||||
Video,
|
Video,
|
||||||
Mic,
|
Mic,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@@ -9,85 +11,183 @@ import {
|
|||||||
FileIcon,
|
FileIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ParticlePreviewProps {
|
interface ParticlePreviewProps {
|
||||||
particle: StreamParticle;
|
particle: StreamParticle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dynamic text sizing for card previews — inspired by TextParticleView. */
|
||||||
|
function getPreviewTextStyle(length: number) {
|
||||||
|
if (length < 30) return "text-lg font-semibold";
|
||||||
|
if (length < 80) return "text-base font-medium";
|
||||||
|
if (length < 200) return "text-sm font-normal";
|
||||||
|
return "text-xs font-normal";
|
||||||
|
}
|
||||||
|
|
||||||
export function ParticlePreview({ particle }: ParticlePreviewProps) {
|
export function ParticlePreview({ particle }: ParticlePreviewProps) {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "text": {
|
case "text":
|
||||||
const data = getParticleData(particle, "text");
|
return <TextPreview particle={particle} />;
|
||||||
return (
|
case "media":
|
||||||
<div className="flex items-start gap-2">
|
return <MediaPreview particle={particle} />;
|
||||||
<MessageSquare className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
|
case "quest":
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
return <QuestPreview particle={particle} />;
|
||||||
{data.content}
|
case "paper":
|
||||||
</p>
|
return <PaperPreview particle={particle} />;
|
||||||
</div>
|
case "file":
|
||||||
);
|
return <FilePreview particle={particle} />;
|
||||||
}
|
case "folder":
|
||||||
case "media": {
|
return <FolderPreview particle={particle} />;
|
||||||
const data = getParticleData(particle, "media");
|
|
||||||
const isVideo = data.mime_type.startsWith("video");
|
|
||||||
const Icon = isVideo ? Video : Mic;
|
|
||||||
const label = isVideo ? "Video" : "Audio";
|
|
||||||
const seconds = Math.round(data.duration_ms / 1000);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{label} · {seconds}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "quest": {
|
|
||||||
const data = getParticleData(particle, "quest");
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ScrollText className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{data.title}
|
|
||||||
{data.status && ` · ${data.status}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "paper": {
|
|
||||||
const data = getParticleData(particle, "paper");
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BookOpen className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{data.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "file": {
|
|
||||||
const data = getParticleData(particle, "file");
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileIcon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{data.filename}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "folder": {
|
|
||||||
const data = getParticleData(particle, "folder");
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FolderIcon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{data.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return <EmptyPreview />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TextPreview({ particle }: { particle: StreamParticle }) {
|
||||||
|
const data = getParticleData(particle, "text");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-violet-500/15 to-indigo-500/15 p-4">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-4 text-center leading-relaxed",
|
||||||
|
getPreviewTextStyle(data.content.length),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
return <VideoThumbnail particleId={particle.id} duration={durationLabel} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
||||||
|
<Mic className="h-8 w-8 text-white/60" />
|
||||||
|
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoThumbnail({
|
||||||
|
particleId,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
particleId: string;
|
||||||
|
duration: string;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.getParticleDownloadUrl(particleId)
|
||||||
|
.then((downloadUrl) => {
|
||||||
|
if (!cancelled) setUrl(downloadUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [particleId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
||||||
|
<Video className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return <Skeleton className="h-full w-full rounded-none" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full bg-black">
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestPreview({ particle }: { particle: StreamParticle }) {
|
||||||
|
const data = getParticleData(particle, "quest");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
||||||
|
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
|
||||||
|
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||||
|
{data.title}
|
||||||
|
</p>
|
||||||
|
{data.status && (
|
||||||
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||||
|
{data.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaperPreview({ particle }: { particle: StreamParticle }) {
|
||||||
|
const data = getParticleData(particle, "paper");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
|
||||||
|
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
|
||||||
|
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||||
|
{data.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreview({ particle }: { particle: StreamParticle }) {
|
||||||
|
const data = getParticleData(particle, "file");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
|
||||||
|
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
|
||||||
|
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||||
|
{data.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderPreview({ particle }: { particle: StreamParticle }) {
|
||||||
|
const data = getParticleData(particle, "folder");
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
|
||||||
|
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
|
||||||
|
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||||
|
{data.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyPreview() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-xs italic">No messages yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { flattenStreams } from "@/lib/stream-utils";
|
import { flattenStreams } from "@/lib/stream-utils";
|
||||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
import { formatDistanceToNow } from "@/lib/time-utils";
|
||||||
import { ParticlePreview } from "./particle-preview";
|
import { ParticlePreview } from "./particle-preview";
|
||||||
|
|
||||||
|
function getInitials(email: string): string {
|
||||||
|
const prefix = email.split("@")[0] ?? "";
|
||||||
|
return prefix.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function StreamList() {
|
export function StreamList() {
|
||||||
const networks = useAppStore((s) => s.networks);
|
const networks = useAppStore((s) => s.networks);
|
||||||
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
|
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
|
||||||
@@ -30,48 +36,60 @@ export function StreamList() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const timeSource = lastParticle?.created_at ?? stream.updated_at;
|
const timeSource = lastParticle?.created_at ?? stream.updated_at;
|
||||||
|
const senderEmail = lastParticle?.created_by_email;
|
||||||
|
const senderPrefix = senderEmail?.split("@")[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={stream.id}
|
key={stream.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
className="hover:bg-accent/50 cursor-pointer overflow-hidden transition-colors"
|
||||||
onClick={() => navigate(`/streams/${stream.id}`)}
|
onClick={() => navigate(`/streams/${stream.id}`)}
|
||||||
>
|
>
|
||||||
<CardContent className="min-h-[3rem]">
|
{/* Preview hero area */}
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden">
|
||||||
{lastParticle ? (
|
{lastParticle ? (
|
||||||
<ParticlePreview particle={lastParticle} />
|
<ParticlePreview particle={lastParticle} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-xs italic">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
No messages yet
|
<p className="text-muted-foreground text-xs italic">
|
||||||
</p>
|
No messages yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
{/* Unseen badge overlay */}
|
||||||
<div className="flex items-center gap-2">
|
{stream.unseen_count > 0 && (
|
||||||
<span className="min-w-0 truncate text-sm font-medium">
|
<Badge
|
||||||
{stream.name}
|
variant="default"
|
||||||
</span>
|
className="absolute top-1.5 right-1.5 text-[10px]"
|
||||||
{stream.unseen_count > 0 && (
|
>
|
||||||
<Badge variant="default" className="shrink-0">
|
{stream.unseen_count}
|
||||||
{stream.unseen_count}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: avatar + stream info */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
{senderEmail ? (
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getInitials(senderEmail)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<div className="size-6 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{stream.name}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-[11px]">
|
||||||
|
{senderPrefix && <span>{senderPrefix}</span>}
|
||||||
|
{senderPrefix && timeSource && <span> · </span>}
|
||||||
|
{timeSource && <span>{formatDistanceToNow(timeSource)}</span>}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
|
||||||
{lastParticle && (
|
|
||||||
<>
|
|
||||||
{lastParticle.created_by_email.split("@")[0]}
|
|
||||||
{" · "}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{timeSource && formatDistanceToNow(timeSource)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useState } from "react";
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useRecordingStore } from "@/stores/recording-store";
|
import { useRecordingStore } from "@/stores/recording-store";
|
||||||
@@ -236,11 +237,21 @@ export function StreamPlayerPage() {
|
|||||||
|
|
||||||
{/* Bottom overlay: stream info + reply */}
|
{/* Bottom overlay: stream info + reply */}
|
||||||
<div className="absolute right-0 bottom-0 left-0 z-10 flex justify-center px-3 pb-3">
|
<div className="absolute right-0 bottom-0 left-0 z-10 flex justify-center px-3 pb-3">
|
||||||
<div className="flex w-full items-center justify-between rounded-full bg-black/30 px-4 py-2 backdrop-blur-sm">
|
<div className="flex w-full items-center gap-2.5 rounded-full bg-black/30 px-3 py-2 backdrop-blur-sm">
|
||||||
<span className="text-xs font-medium text-white/70">
|
{currentParticle && (
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="bg-white/20 text-[10px] text-white">
|
||||||
|
{currentParticle.created_by_email
|
||||||
|
.split("@")[0]
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-white/70">
|
||||||
{stream.name}
|
{stream.name}
|
||||||
<span className="text-white/40"> > </span>
|
<span className="text-white/40"> · </span>
|
||||||
{currentParticle?.created_by_email}
|
{currentParticle?.created_by_email.split("@")[0]}
|
||||||
</span>
|
</span>
|
||||||
<ReplyIndicator />
|
<ReplyIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user