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
@@ -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 && (
<div className="absolute inset-x-0 bottom-16 z-10 px-4">
<ParticleAttachments attachments={attachments} />
<div className="absolute inset-x-0 top-12 z-10 px-4">
<ParticleAttachments attachments={attachments} variant="compact" />
</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 { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
@@ -9,6 +9,7 @@ type FileParticle = Extract<Particle, { type: "file" }>;
interface ParticleAttachmentsProps {
attachments: FileParticle[];
variant?: "inline" | "compact";
}
function formatFileSize(bytes: number): string {
@@ -17,7 +18,7 @@ function formatFileSize(bytes: number): string {
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);
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 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 (variant === "compact") {
return (
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment) => (
<CompactAttachmentItem key={attachment.id} particle={attachment} />
))}
</div>
);
}
return (
<ScrollArea className="w-full">
<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">
No particles in this stream yet
</p>
<ControlsIndicator type="reply" />
<ControlsIndicator type="reply" showEscape={true} />
<ComposeOverlay
networkId={networkId}
targetPath={path}
@@ -285,22 +285,24 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onParticleCreated={onLocalParticleCreated}
/>
{/* Bottom bar: metadata left, controls right */}
<div className="absolute inset-x-0 bottom-0 z-10 flex items-end justify-between px-2 pb-2">
{/* Left: seen indicator + exit countdown */}
<div className="flex items-center gap-2 text-xs text-white/70">
{currentParticle && (
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
)}
{exitRemainingMs !== null && (
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
)}
</div>
{/* Bottom-left: metadata */}
<div className="absolute bottom-0 left-0 z-10 flex items-center gap-2 px-2 pb-2 text-xs text-white/70">
{currentParticle && (
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
)}
{exitRemainingMs !== null && (
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
)}
</div>
{/* Right: recording controls */}
<ControlsIndicator type="reply" />
{/* Bottom-center: controls */}
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3">
<ControlsIndicator
showEscape={true}
type="reply"
/>
</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">
<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 && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
</BreadcrumbItem>
@@ -75,7 +75,7 @@ export function TranscriptOverlay({
return (
<div className={centered
? "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">
{activeChunk.map((word, i) => {