improve attachments experience for media particles

This commit is contained in:
talksik
2026-03-30 11:27:33 -07:00
parent d6280439d0
commit c797d7253e
7 changed files with 169 additions and 76 deletions
+49 -33
View File
@@ -1,38 +1,58 @@
import { Video, Mic } from "lucide-react"; import { Video, Mic, Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { Button } from "@/components/ui/button";
interface ControlsIndicatorProps { interface ControlsIndicatorProps {
type: "reply" | "new"; type: "reply" | "new";
attachmentCount?: number;
onOpenAttachments?: () => void;
showEscape?: boolean;
} }
export default function ControlsIndicator({ type }: ControlsIndicatorProps) { export default function ControlsIndicator({
type,
attachmentCount = 0,
showEscape = false,
onOpenAttachments,
}: ControlsIndicatorProps) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return ( return (
<div className="flex items-center gap-2 text-xs rounded-full pl-2 pr-1 py-1 bg-black/30 backdrop-blur-sm"> <div className="flex items-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground"> {showEscape && (
Hold{" "} <span>
<kbd <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
className={cn( Esc
"bg-muted rounded px-1.5 py-0.5 font-mono", </kbd>{" "}
)} back
> </span>
` )}
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "} </kbd>{" "}
to {type === "reply" ? "reply " : "start "} to {type === "reply" ? "reply" : "start"}
· Press{" "} </span>
<kbd <span>
className={cn( <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
T T
</kbd>{" "} </kbd>{" "}
for text text
</div> </span>
<Button {attachmentCount > 0 && (
<button
type="button"
onClick={onOpenAttachments}
className="flex items-center gap-1 text-white/50 transition-colors hover:text-white/80"
>
<kbd className="self-end rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
A
</kbd>{" "}
<Paperclip className="size-3" />
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
</button>
)}
<button
type="button"
onClick={() => onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video") setRecordingMode(recordingMode === "video" ? "audio" : "video")
} }
@@ -41,24 +61,20 @@ export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
? "Switch to audio-only" ? "Switch to audio-only"
: "Switch to video" : "Switch to video"
} }
variant="secondary" className="flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-xs text-white/50 transition-colors hover:text-white/80"
className={cn(
"text-xs rounded-full",
"text-muted-foreground hover:text-white/90",
)}
> >
{recordingMode === "video" ? ( {recordingMode === "video" ? (
<> <>
<Video className="h-3.5 w-3.5" /> <Video className="size-3" />
<p>Video</p> Video
</> </>
) : ( ) : (
<> <>
<Mic className="h-3.5 w-3.5" /> <Mic className="size-3" />
<span>Audio</span> Audio
</> </>
)} )}
</Button> </button>
</div> </div>
); );
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export default function NetworkRoot() {
<ParticleListView path={path} /> <ParticleListView path={path} />
<AutoplayOverlay networkId={networkId!} /> <AutoplayOverlay networkId={networkId!} />
<ComposeOverlay networkId={networkId!} /> <ComposeOverlay networkId={networkId!} />
<div className="flex justify-end"> <div className="flex justify-center p-3">
<ControlsIndicator type={"new"} /> <ControlsIndicator type={"new"} />
</div> </div>
</div> </div>
@@ -0,0 +1,45 @@
import type { Particle } from "@/api/types";
import {
ImageAttachment,
FileAttachment,
} from "@/features/particles/particle-attachments";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "radix-ui";
type FileParticle = Extract<Particle, { type: "file" }>;
interface AttachmentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
attachments: FileParticle[];
}
export function AttachmentsDialog({
open,
onOpenChange,
attachments,
}: AttachmentsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<VisuallyHidden.Root>
<DialogTitle>Attachments</DialogTitle>
</VisuallyHidden.Root>
<div className="flex flex-wrap gap-3">
{attachments.map((attachment) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment key={attachment.id} particle={attachment} />
) : (
<FileAttachment key={attachment.id} particle={attachment} />
);
})}
</div>
</DialogContent>
</Dialog>
);
}
@@ -77,8 +77,8 @@ export function MediaParticleView({
}; };
const attachmentOverlay = attachments.length > 0 && ( const attachmentOverlay = attachments.length > 0 && (
<div className="absolute inset-x-0 bottom-16 z-10 px-4"> <div className="absolute inset-x-0 top-12 z-10 px-4">
<ParticleAttachments attachments={attachments} /> <ParticleAttachments attachments={attachments} variant="compact" />
</div> </div>
); );
@@ -1,4 +1,4 @@
import { Download, ExternalLink, FileIcon } from "lucide-react"; import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url"; import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -9,6 +9,7 @@ type FileParticle = Extract<Particle, { type: "file" }>;
interface ParticleAttachmentsProps { interface ParticleAttachmentsProps {
attachments: FileParticle[]; attachments: FileParticle[];
variant?: "inline" | "compact";
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -17,7 +18,7 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
function ImageAttachment({ particle }: { particle: FileParticle }) { export function ImageAttachment({ particle }: { particle: FileParticle }) {
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id); const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
if (isLoading || !url) { if (isLoading || !url) {
@@ -59,7 +60,7 @@ function ImageAttachment({ particle }: { particle: FileParticle }) {
); );
} }
function FileAttachment({ particle }: { particle: FileParticle }) { export function FileAttachment({ particle }: { particle: FileParticle }) {
const { data: url } = useDownloadUrl(particle.properties.object_id); const { data: url } = useDownloadUrl(particle.properties.object_id);
const handleOpen = (e: React.MouseEvent) => { const handleOpen = (e: React.MouseEvent) => {
@@ -117,9 +118,57 @@ function FileAttachment({ particle }: { particle: FileParticle }) {
); );
} }
export function ParticleAttachments({ attachments }: ParticleAttachmentsProps) { function CompactAttachmentItem({ particle }: { particle: FileParticle }) {
const isImage = particle.properties.mime_type.startsWith("image/");
const { data: url } = useDownloadUrl(particle.properties.object_id);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (url) window.electronLink.openExternal(url);
};
return (
<button
type="button"
onClick={handleClick}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
>
{isImage ? (
url ? (
<img
src={url}
alt={particle.properties.filename}
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<ImageIcon className="size-4 shrink-0 text-white/50" />
)
) : (
<FileIcon className="size-4 shrink-0 text-white/50" />
)}
<span className="min-w-0 truncate text-xs text-white/80">
{particle.properties.filename}
</span>
<span className="shrink-0 text-[10px] text-white/40">
{formatFileSize(particle.properties.size_bytes)}
</span>
</button>
);
}
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
if (variant === "compact") {
return (
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment) => (
<CompactAttachmentItem key={attachment.id} particle={attachment} />
))}
</div>
);
}
return ( return (
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1"> <div className="flex items-center gap-2 py-1">
+18 -35
View File
@@ -204,7 +204,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
No particles in this stream yet No particles in this stream yet
</p> </p>
<ControlsIndicator type="reply" /> <ControlsIndicator type="reply" showEscape={true} />
<ComposeOverlay <ComposeOverlay
networkId={networkId} networkId={networkId}
targetPath={path} targetPath={path}
@@ -285,22 +285,24 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onParticleCreated={onLocalParticleCreated} onParticleCreated={onLocalParticleCreated}
/> />
{/* Bottom bar: metadata left, controls right */} {/* Bottom-left: metadata */}
<div className="absolute inset-x-0 bottom-0 z-10 flex items-end justify-between px-2 pb-2"> <div className="absolute bottom-0 left-0 z-10 flex items-center gap-2 px-2 pb-2 text-xs text-white/70">
{/* Left: seen indicator + exit countdown */} {currentParticle && (
<div className="flex items-center gap-2 text-xs text-white/70"> <SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
{currentParticle && ( )}
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} /> {exitRemainingMs !== null && (
)} <span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
{exitRemainingMs !== null && ( Closing in {Math.ceil(exitRemainingMs / 1000)}s
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm"> </span>
Closing in {Math.ceil(exitRemainingMs / 1000)}s )}
</span> </div>
)}
</div>
{/* Right: recording controls */} {/* Bottom-center: controls */}
<ControlsIndicator type="reply" /> <div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3">
<ControlsIndicator
showEscape={true}
type="reply"
/>
</div> </div>
</div> </div>
); );
@@ -315,27 +317,8 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto"> <Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem className="text-xs">
<BreadcrumbLink
className="flex cursor-pointer items-center gap-1"
onClick={() => navigate("/")}
>
<Home className="size-3.5" />
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbLink
className="cursor-pointer"
onClick={() => navigate(`/${networkId}`)}
>
<NetworkBreadcrumbContent networkId={networkId} />
</BreadcrumbLink>
</BreadcrumbItem>
{streamParticle && ( {streamParticle && (
<> <>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs"> <BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage> <BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
@@ -75,7 +75,7 @@ export function TranscriptOverlay({
return ( return (
<div className={centered <div className={centered
? "absolute inset-0 flex items-center justify-center px-6" ? "absolute inset-0 flex items-center justify-center px-6"
: "absolute bottom-10 left-0 right-0 flex justify-center px-6" : "absolute bottom-6 left-0 right-0 flex justify-center px-6"
}> }>
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg"> <p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => { {activeChunk.map((word, i) => {