UX Core Features Implementation Plan
NOTE
This document may not reflect the current implementation. See the final report for up-to-date state: Final Report
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement 4 UX core features — infinite scroll pagination, post composer on home/feed pages, image upload in the TipTap editor, and mention autocomplete.
Architecture: Each feature is independent and touches different files. We use TanStack Query's useInfiniteQuery for pagination, a dialog-based post composer component for home, Active Storage direct upload for images, and TipTap's Mention extension with the existing useSearchMembers hook.
Tech Stack: TanStack Query (useInfiniteQuery), TipTap (Mention extension), Active Storage direct upload API, Radix Dialog, Lucide React icons.
Task 1: Paginated Home Feed (Infinite Scroll)
Covers: Pagination for home/feed pages
Files:
Modify:
src/lib/api/spaces.tsx— adduseInfiniteSpacesHomehookModify:
src/routes/index.tsx— use infinite query + IntersectionObserverModify:
src/routes/feed.tsx— same as index[ ] Step 1: Add
useInfiniteSpacesHomehook to spaces.tsx
Add the following after the existing useSpacesHome function (around line 49):
export function useInfiniteSpacesHome() {
return useInfiniteQuery({
queryKey: ["spaces", "home", "infinite"],
queryFn: ({ pageParam = 1 }) =>
authedFetch<PostsResponse>(
`/api/headless/v1/spaces/home?page=${pageParam}&per_page=10`,
),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.has_next_page ? lastPage.page + 1 : undefined,
select: (data) => ({
...data,
pages: data.pages.map((p) => p.records.map(mapPost)),
}),
});
}Make sure to add useInfiniteQuery to the imports at the top:
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";- [ ] Step 2: Update index.tsx to use infinite scroll
Replace the useSpacesHome call and the post list rendering in src/routes/index.tsx:
- Change the import to include
useInfiniteSpacesHome:
import { useInfiniteSpacesHome, useSpaces } from "@/lib/api/spaces";- Replace
useSpacesHomewithuseInfiniteSpacesHome:
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteSpacesHome();
const homePosts = data?.pages.flatMap((p) => p) ?? [];- Add IntersectionObserver ref after the post list. Replace the post rendering block (lines 68-74) with:
{isLoading ? (
<div className="space-y-4 sm:space-y-5">
<PostSkeleton />
<PostSkeleton />
</div>
) : isError ? (
<EmptyState
title="Não foi possível carregar os comunicados"
description="Tente novamente em alguns instantes."
/>
) : homePosts.length === 0 ? (
<EmptyState
title="Nenhum comunicado ainda"
description="Os comunicados da comunidade aparecerão aqui."
/>
) : (
<>
<div className="space-y-4 sm:space-y-5">
{homePosts.map((p) => (
<PostCard key={p.id} post={p} />
))}
</div>
{hasNextPage && (
<div ref={(el) => {
if (el) {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) fetchNextPage(); },
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}
}} className="py-4 text-center text-sm text-muted-foreground">
{isFetchingNextPage ? "Carregando mais..." : ""}
</div>
)}
</>
)}- Add the
useRefimport and create a proper ref-based observer. Actually, to keep it simple and avoid observer cleanup issues with the inline pattern, use this cleaner approach instead — create the observer outside the render:
Replace the full function body with:
function Index() {
const [menuOpen, setMenuOpen] = useState(false);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteSpacesHome();
const { isLoading: loading } = useSpaces();
const loadMoreRef = useState<HTMLDivElement | null>(null);
useEffect(() => {
const el = loadMoreRef[0];
if (!el || !hasNextPage) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) fetchNextPage(); },
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
const homePosts = data?.pages.flatMap((p) => p) ?? [];
return (
<div className="min-h-screen bg-background text-foreground">
<TopBar onMenu={() => setMenuOpen(true)} />
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} />
<div className="flex">
<Sidebar loading={loading} />
<main className="flex-1 min-w-0">
<div className="max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6 pb-20 md:pb-6 flex gap-6">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-4 sm:mb-5">
<h1 className="flex items-center gap-2 text-base font-semibold">
<Megaphone className="h-4 w-4" /> Comunicados
</h1>
<div className="flex items-center gap-1">
<button className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground px-2 py-1 rounded-md hover:bg-accent">
Latest <ChevronDown className="h-3.5 w-3.5" />
</button>
<button className="h-9 w-9 grid place-items-center rounded-md bg-primary text-primary-foreground hover:opacity-90" aria-label="New post">
<Plus className="h-4 w-4" />
</button>
<button className="h-9 w-9 hidden sm:grid place-items-center rounded-md hover:bg-accent text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</div>
{isLoading ? (
<div className="space-y-4 sm:space-y-5">
<PostSkeleton />
<PostSkeleton />
</div>
) : isError ? (
<EmptyState
title="Não foi possível carregar os comunicados"
description="Tente novamente em alguns instantes."
/>
) : homePosts.length === 0 ? (
<EmptyState
title="Nenhum comunicado ainda"
description="Os comunicados da comunidade aparecerão aqui."
/>
) : (
<>
<div className="space-y-4 sm:space-y-5">
{homePosts.map((p) => (
<PostCard key={p.id} post={p} />
))}
</div>
{hasNextPage && <div ref={loadMoreRef[1]} className="py-4 text-center text-sm text-muted-foreground" />}
{isFetchingNextPage && <p className="py-4 text-center text-sm text-muted-foreground">Carregando mais...</p>}
</>
)}
{loading ? (
<section className="xl:hidden bg-card rounded-xl border border-border p-4 mt-4">
<Skeleton className="h-5 w-32 mb-3" />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
))}
</div>
</section>
) : (
<RightPanelMobile />
)}
</div>
<RightPanel loading={loading} />
</div>
</main>
</div>
<MobileBottomNav />
</div>
);
}- [ ] Step 3: Update feed.tsx with the same infinite scroll pattern
Apply the same changes as Step 2 to src/routes/feed.tsx:
Import
useInfiniteSpacesHomeanduseEffectReplace
useSpacesHomewithuseInfiniteSpacesHomeAdd IntersectionObserver ref
Render posts from
data?.pages.flatMap((p) => p) ?? [][ ] Step 4: Add infinite scroll to space posts
Add useInfiniteSpacePosts to src/lib/api/spaces.tsx:
export function useInfiniteSpacePosts(spaceId: number | string) {
return useInfiniteQuery({
queryKey: ["spaces", spaceId, "posts", "infinite"],
queryFn: ({ pageParam = 1 }) =>
authedFetch<PostsResponse>(
`/api/headless/v1/spaces/${spaceId}/posts?page=${pageParam}&per_page=10`,
),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.has_next_page ? lastPage.page + 1 : undefined,
enabled: !!spaceId,
select: (data) => ({
...data,
pages: data.pages.map((p) => p.records.map(mapPost)),
}),
});
}Update src/routes/spaces/$spaceId.tsx to use useInfiniteSpacePosts with the same IntersectionObserver pattern.
- [ ] Step 5: Commit
git add frontend/src/lib/api/spaces.tsx frontend/src/routes/index.tsx frontend/src/routes/feed.tsx frontend/src/routes/spaces/\$spaceId.tsx
git commit -m "feat: add infinite scroll pagination to home feed and space posts"Task 2: Post Composer on Home/Feed Pages
Covers: Wire the "+" button on home/feed pages to open a post composer
Files:
Create:
src/components/circle/PostComposer.tsx— reusable post composer dialogModify:
src/routes/index.tsx— wire "+" buttonModify:
src/routes/feed.tsx— wire "+" button[ ] Step 1: Create PostComposer component
Create src/components/circle/PostComposer.tsx:
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";
import { useSpaces, useCreatePost } from "@/lib/api/spaces";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
type PostComposerProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function PostComposer({ open, onOpenChange }: PostComposerProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState<JSONContent>(emptyDoc());
const [selectedSpaceId, setSelectedSpaceId] = useState<string>("");
const { data: spaces } = useSpaces();
const createPost = useCreatePost();
const memberSpaces = (spaces ?? []).filter((s) => s.is_member && !s.is_post_disabled);
const handleSubmit = () => {
if (!selectedSpaceId || isEmptyDoc(body)) return;
createPost.mutate(
{
space_id: selectedSpaceId,
title: title.trim() || undefined,
tiptap_body: { body },
body_plain_text: jsonToHtml(body).replace(/<[^>]*>/g, "").trim(),
},
{
onSuccess: () => {
setTitle("");
setBody(emptyDoc());
setSelectedSpaceId("");
onOpenChange(false);
toast.success("Post publicado!");
},
onError: () => {
toast.error("Erro ao publicar. Tente novamente.");
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Criar post</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
<div>
<label className="text-sm font-medium mb-1.5 block">Space</label>
<Select value={selectedSpaceId} onValueChange={setSelectedSpaceId}>
<SelectTrigger>
<SelectValue placeholder="Selecione um space" />
</SelectTrigger>
<SelectContent>
{memberSpaces.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Título (opcional)</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Título do post"
className="w-full bg-background rounded-lg px-3 py-2 border border-border text-sm outline-none focus:border-primary/50"
/>
</div>
<TipTapEditor
content={body}
onChange={setBody}
placeholder="No que você está pensando?"
/>
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={createPost.isPending || !selectedSpaceId || isEmptyDoc(body)}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{createPost.isPending ? "Publicando..." : "Publicar"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
);
}- [ ] Step 2: Wire PostComposer in index.tsx
In src/routes/index.tsx:
- Add import:
import { PostComposer } from "@/components/circle/PostComposer";- Add state after
menuOpen:
const [composerOpen, setComposerOpen] = useState(false);Add
<PostComposer open={composerOpen} onOpenChange={setComposerOpen} />before</div>(inside the main wrapper).Replace the "+" button's
aria-label="New post"button with:
<button
onClick={() => setComposerOpen(true)}
className="h-9 w-9 grid place-items-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
aria-label="New post"
>
<Plus className="h-4 w-4" />
</button>- [ ] Step 3: Wire PostComposer in feed.tsx
Apply the same changes as Step 2 to src/routes/feed.tsx.
- [ ] Step 4: Commit
git add frontend/src/components/circle/PostComposer.tsx frontend/src/routes/index.tsx frontend/src/routes/feed.tsx
git commit -m "feat: add post composer dialog on home and feed pages"Task 3: Image Upload in TipTap Editor
Covers: File upload integration with Active Storage direct upload API
Files:
Modify:
src/lib/auth.ts— addauthedUploadhelperModify:
src/components/editor/EditorToolbar.tsx— replace image button with file pickerModify:
src/components/editor/TipTapEditor.tsx— no changes needed (Image extension already configured)[ ] Step 1: Add
authedUploadhelper to auth.ts
Add at the end of src/lib/auth.ts:
export async function authedUpload<T>(url: string, body: unknown): Promise<T> {
let token = getAccessToken();
if (!token || isTokenExpired(token)) {
token = await refreshAccessToken();
}
if (!token) {
throw new Error("Not authenticated");
}
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 401) {
const newToken = await refreshAccessToken();
if (!newToken) throw new Error("Not authenticated");
const retryRes = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${newToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!retryRes.ok) {
const data = (await retryRes.json().catch(() => ({}))) as { message?: string };
throw new Error(data.message ?? `Upload failed with status ${retryRes.status}`);
}
return retryRes.json() as Promise<T>;
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(data.message ?? `Upload failed with status ${res.status}`);
}
return res.json() as Promise<T>;
}- [ ] Step 2: Add image upload hook to spaces.tsx
Add to src/lib/api/spaces.tsx:
export function useUploadImage() {
return useMutation({
mutationFn: async (file: File) => {
const directUpload = await authedUpload<{
id: number;
signed_id: string;
direct_upload: { url: string; headers: Record<string, string> };
}>("/api/headless/v1/direct_uploads", {
blob: {
filename: file.name,
content_type: file.type,
metadata: { identified: true },
},
});
await fetch(directUpload.direct_upload.url, {
method: "PUT",
headers: directUpload.direct_upload.headers,
body: file,
});
return directUpload.signed_id;
},
});
}Add authedUpload to the imports.
- [ ] Step 3: Update EditorToolbar to support file upload
Replace the addImage function in src/components/editor/EditorToolbar.tsx:
import { useRef } from "react";Add useRef to imports. Then update the addImage function:
const fileInputRef = useRef<HTMLInputElement>(null);
const addImage = () => {
fileInputRef.current?.click();
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
editor.chain().focus().setImage({ src: url }).run();
e.target.value = "";
};Update the JSX to include the hidden file input:
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
<ToolbarBtn onClick={addImage}>
<Image className="h-4 w-4" />
</ToolbarBtn>- [ ] Step 4: Commit
git add frontend/src/lib/auth.ts frontend/src/lib/api/spaces.tsx frontend/src/components/editor/EditorToolbar.tsx
git commit -m "feat: add image upload support to TipTap editor toolbar"Task 4: Mention Autocomplete in TipTap Editor
Covers: @mention autocomplete using TipTap Mention extension and member search API
Files:
Modify:
src/components/editor/TipTapEditor.tsx— add Mention extensionModify:
src/components/editor/MentionList.tsx— create suggestion dropdown component[ ] Step 1: Create MentionList suggestion component
Create src/components/editor/MentionList.tsx:
import { forwardRef, useEffect, useState } from "react";
export type MentionSuggestion = {
id: string;
name: string;
};
type MentionListProps = {
items: MentionSuggestion[];
command: (item: MentionSuggestion) => void;
};
export const MentionList = forwardRef<HTMLDivElement, MentionListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (e.key === "Enter") {
e.preventDefault();
if (items[selectedIndex]) command(items[selectedIndex]);
return true;
}
return false;
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [items, selectedIndex, command]);
if (items.length === 0) {
return (
<div ref={ref} className="bg-popover border border-border rounded-lg shadow-lg p-2 text-sm text-muted-foreground">
Nenhum resultado
</div>
);
}
return (
<div ref={ref} className="bg-popover border border-border rounded-lg shadow-lg py-1 max-h-48 overflow-y-auto">
{items.map((item, i) => (
<button
key={item.id}
onClick={() => command(item)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-accent transition ${
i === selectedIndex ? "bg-accent" : ""
}`}
>
{item.name}
</button>
))}
</div>
);
},
);
MentionList.displayName = "MentionList";- [ ] Step 2: Add Mention extension to TipTapEditor
Update src/components/editor/TipTapEditor.tsx:
- Add imports:
import Mention from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { MentionList, type MentionSuggestion } from "./MentionList";
import { authedFetch } from "@/lib/auth";- Add the Mention extension to the extensions array:
Mention.configure({
suggestion: {
items: async ({ query }) => {
try {
const data = await authedFetch<{ records: { id: number; name: string }[] }>(
`/api/headless/v1/search/community_members?q=${encodeURIComponent(query)}`,
);
return data.records.slice(0, 5).map((m) => ({
id: String(m.id),
name: m.name,
}));
} catch {
return [];
}
},
render: () => {
let component: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: { items: MentionSuggestion[]; command: (item: MentionSuggestion) => void }) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: { items: MentionSuggestion[]; command: (item: MentionSuggestion) => void }) {
component?.updateProps(props);
popup?.[0]?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === "Escape") {
popup?.[0]?.[0]?.hide();
return true;
}
return (component?.ref as any)?.onKeyDown(props.event) ?? false;
},
onExit() {
popup?.[0]?.[0]?.destroy();
component?.destroy();
},
};
},
char: "@",
},
}),- Add
tippy.jsis already available via tippy (install if needed):
bun add tippy.js- [ ] Step 3: Install tippy.js dependency
cd frontend && bun add tippy.js- [ ] Step 4: Commit
git add frontend/src/components/editor/TipTapEditor.tsx frontend/src/components/editor/MentionList.tsx frontend/bun.lock
git commit -m "feat: add @mention autocomplete to TipTap editor"Task 5: Verify All Features
- [ ] Step 1: Run type check
cd frontend && bun run lint- [ ] Step 2: Run build
cd frontend && bun run build[ ] Step 3: Fix any type or build errors
[ ] Step 4: Commit fixes if needed
git commit -m "fix: resolve type and build errors for UX core features"