refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
streamPath: ParticlePath;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
// Characters per minute (~1000 cpm ≈ 200 wpm at ~5 chars/word)
|
||||
const CHARS_PER_MINUTE = 1000;
|
||||
const MIN_DURATION_S = 3;
|
||||
const MAX_DURATION_S = 15;
|
||||
const TICK_MS = 100;
|
||||
const EXTRA_S_PER_LINK = 2;
|
||||
const EXTRA_S_PER_ATTACHMENT = 2;
|
||||
|
||||
// Below this threshold: immersive centered display
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function computeReadDuration(
|
||||
text: string,
|
||||
linkCount: number,
|
||||
attachmentCount: number,
|
||||
): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
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) {
|
||||
if (length < 30) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 70) return { size: "text-3xl", weight: "font-semibold" };
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
}
|
||||
|
||||
function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
|
||||
}
|
||||
|
||||
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
|
||||
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
|
||||
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic text-white">{children}</em>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isBlock = className?.startsWith("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn(className, "text-sm")} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-white/10" />,
|
||||
};
|
||||
|
||||
function MarkdownContent({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn("break-words", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 linkPreviews = useAllLinkMetadata(content);
|
||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||
const urls = extractUrls(content);
|
||||
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const isCreator = !!userId && userId === particle.created_by_human_id;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
}, [particle.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedRef.current += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||
onProgress?.(ratio);
|
||||
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
// 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() === "";
|
||||
|
||||
const editButton = isCreator && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Edit"
|
||||
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
);
|
||||
|
||||
const editedLabel = particle.properties.edited_at && (
|
||||
<span className="text-xs text-white/40">
|
||||
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const editOverlay = isEditing && (
|
||||
<TextEditOverlay
|
||||
particle={particle}
|
||||
streamPath={streamPath}
|
||||
onClose={() => setIsEditing(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Mode 1: bare URLs only — show link cards centered
|
||||
if (linksOnly && !hasAttachments) {
|
||||
return (
|
||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<LinkPreviews entries={linkPreviews} />
|
||||
{editedLabel && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||
{editedLabel}
|
||||
</div>
|
||||
)}
|
||||
{editButton}
|
||||
{editOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 2: short plain text, no enrichments — immersive centered display
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
||||
const style = getImmersiveTextStyle(content.length);
|
||||
return (
|
||||
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
{editedLabel}
|
||||
{editButton}
|
||||
{editOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 3: card layout
|
||||
return (
|
||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
|
||||
"[&::-webkit-scrollbar]:w-2",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-white/30",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
|
||||
)}
|
||||
>
|
||||
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
|
||||
|
||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||
|
||||
{hasAttachments && <ParticleAttachments attachments={attachments} />}
|
||||
|
||||
{editedLabel}
|
||||
</div>
|
||||
{editButton}
|
||||
{editOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user