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 { cn } from "@/lib/utils";
import { Video, Mic, Paperclip } from "lucide-react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { Button } from "@/components/ui/button";
interface ControlsIndicatorProps {
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 setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
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="text-muted-foreground">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
`
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
back
</span>
)}
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to {type === "reply" ? "reply " : "start "}
· Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
to {type === "reply" ? "reply" : "start"}
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
for text
</div>
<Button
text
</span>
{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={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
@@ -41,24 +61,20 @@ export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
? "Switch to audio-only"
: "Switch to video"
}
variant="secondary"
className={cn(
"text-xs rounded-full",
"text-muted-foreground hover:text-white/90",
)}
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"
>
{recordingMode === "video" ? (
<>
<Video className="h-3.5 w-3.5" />
<p>Video</p>
<Video className="size-3" />
Video
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
<span>Audio</span>
<Mic className="size-3" />
Audio
</>
)}
</Button>
</button>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default function NetworkRoot() {
<ParticleListView path={path} />
<AutoplayOverlay networkId={networkId!} />
<ComposeOverlay networkId={networkId!} />
<div className="flex justify-end">
<div className="flex justify-center p-3">
<ControlsIndicator type={"new"} />
</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 && (
<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) => {