feat: support file and image attachments (#98)
* upload & view attachments to particles * allow download of attachments
This commit was merged in pull request #98.
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: MediaParticle;
|
||||
streamPath: ParticlePath;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
@@ -18,11 +22,13 @@ interface MediaParticleViewProps {
|
||||
|
||||
export function MediaParticleView({
|
||||
particle,
|
||||
streamPath,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: MediaParticleViewProps) {
|
||||
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
@@ -70,6 +76,12 @@ export function MediaParticleView({
|
||||
if (duration > 0) onProgress?.(time / duration);
|
||||
};
|
||||
|
||||
const attachmentOverlay = attachments.length > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-16 z-10 px-4">
|
||||
<ParticleAttachments attachments={attachments} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||
@@ -99,6 +111,8 @@ export function MediaParticleView({
|
||||
centered
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachmentOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +136,8 @@ export function MediaParticleView({
|
||||
activeWordIndex={activeWordIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachmentOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Download, ExternalLink, FileIcon } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||
|
||||
interface ParticleAttachmentsProps {
|
||||
attachments: FileParticle[];
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ImageAttachment({ particle }: { particle: FileParticle }) {
|
||||
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
if (isLoading || !url) {
|
||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||
}
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = particle.properties.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={particle.properties.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function FileAttachment({ particle }: { particle: FileParticle }) {
|
||||
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (url) window.electronLink.openExternal(url);
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!url) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = particle.properties.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={handleOpen}
|
||||
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon className="size-4 shrink-0 text-white/60" />
|
||||
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
||||
{particle.properties.filename}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/40">
|
||||
{formatFileSize(particle.properties.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ParticleAttachments({ attachments }: ParticleAttachmentsProps) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{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>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -222,6 +222,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
<MediaParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
streamPath={path}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
@@ -232,6 +233,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
streamPath={path}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFirstLinkMetadata } from "@/hooks/use-link-metadata";
|
||||
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import { extractUrls } from "@/lib/link-metadata";
|
||||
import {
|
||||
LinkPreviewCard,
|
||||
LinkPreviewCardSkeleton,
|
||||
} from "@/components/link-preview-card";
|
||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
streamPath: ParticlePath;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
@@ -21,16 +26,20 @@ const CHARS_PER_MINUTE = 1000;
|
||||
const MIN_DURATION_S = 3;
|
||||
const MAX_DURATION_S = 15;
|
||||
const TICK_MS = 100;
|
||||
const LINK_EXTRA_DURATION_S = 3;
|
||||
const EXTRA_S_PER_LINK = 2;
|
||||
const EXTRA_S_PER_ATTACHMENT = 2;
|
||||
|
||||
// Below this threshold: immersive centered display
|
||||
// Above: contained left-aligned card
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function computeReadDuration(text: string, hasLink: boolean): number {
|
||||
function computeReadDuration(
|
||||
text: string,
|
||||
linkCount: number,
|
||||
attachmentCount: number,
|
||||
): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
const seconds = hasLink ? base + LINK_EXTRA_DURATION_S : base;
|
||||
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
|
||||
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
|
||||
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
|
||||
}
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
@@ -39,17 +48,39 @@ function getImmersiveTextStyle(length: number) {
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
}
|
||||
|
||||
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.url} className="shrink-0">
|
||||
{entry.isLoading ? (
|
||||
<LinkPreviewCardSkeleton />
|
||||
) : entry.metadata ? (
|
||||
<LinkPreviewCard metadata={entry.metadata} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextParticleView({
|
||||
particle,
|
||||
streamPath,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: TextParticleViewProps) {
|
||||
const content = particle.properties.content;
|
||||
const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(content);
|
||||
const hasLink = !!firstUrl;
|
||||
const immersive = content.length < IMMERSIVE_CHAR_LIMIT;
|
||||
const durationS = computeReadDuration(content, hasLink);
|
||||
const linkPreviews = useAllLinkMetadata(content);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
const urls = extractUrls(content);
|
||||
|
||||
const hasLinks = urls.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const hasEnrichments = hasLinks || hasAttachments;
|
||||
|
||||
const durationS = computeReadDuration(content, urls.length, attachments.length);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
// Reset elapsed when particle changes
|
||||
@@ -74,25 +105,22 @@ export function TextParticleView({
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
const linkPreview = (
|
||||
<>
|
||||
{isLoading && <LinkPreviewCardSkeleton />}
|
||||
{metadata && <LinkPreviewCard metadata={metadata} />}
|
||||
</>
|
||||
);
|
||||
// Content is just bare URLs with no surrounding text
|
||||
const contentTrimmed = content.trim();
|
||||
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
|
||||
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
|
||||
|
||||
// Content is just a bare URL with no surrounding text
|
||||
const linkOnly = hasLink && content.trim() === firstUrl;
|
||||
|
||||
if (linkOnly) {
|
||||
// Mode 1: bare URLs only — show link cards centered
|
||||
if (linksOnly && !hasAttachments) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||
{linkPreview}
|
||||
<LinkPreviews entries={linkPreviews} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (immersive && !hasLink) {
|
||||
// Mode 2: short text, no enrichments — immersive centered display
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments) {
|
||||
const style = getImmersiveTextStyle(content.length);
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||
@@ -109,15 +137,17 @@ export function TextParticleView({
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 3: card layout with enrichments
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||
<div className="flex max-h-full w-full items-center gap-6">
|
||||
<div className="flex-1 overflow-y-auto rounded-2xl max-w-lg mx-auto bg-white/10 p-5 backdrop-blur-md">
|
||||
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
{hasLink && <div className="shrink-0">{linkPreview}</div>}
|
||||
<div className="flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
||||
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
|
||||
{content}
|
||||
</p>
|
||||
|
||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||
|
||||
{hasAttachments && <ParticleAttachments attachments={attachments} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user