From 416ee58087404f30d5e3acc96d440cdb5f7af64f Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:49:34 +0700 Subject: [PATCH] feat: private boost feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Boost tab: a private feed for notes that keep you going. Compose text, attach an image or video (button, drag-and-drop, or Alt+U), mark posts public/private, post with Ctrl+Enter, and toggle "This helped me". YouTube and Spotify links render as embeds and other links become clickable. Starts empty — no seeded content. Known limit: attached media is shown for the current session only; after a reload the post keeps its text. ## Testing - Lint, typecheck, unit tests and build pass. - In the browser: post text, post with media, like toggle, delete own post, shortcuts. --- src/components/boost/BoostFeed.tsx | 403 ++++++++++++++++++++++++++- src/components/boost/PostContent.tsx | 136 +++++++++ 2 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 src/components/boost/PostContent.tsx diff --git a/src/components/boost/BoostFeed.tsx b/src/components/boost/BoostFeed.tsx index 96b932a..f81d1e8 100644 --- a/src/components/boost/BoostFeed.tsx +++ b/src/components/boost/BoostFeed.tsx @@ -1,5 +1,406 @@ "use client"; +import { useEffect, useRef, useState, type DragEvent, type FormEvent } from "react"; +import { Globe, Handshake, Loader2, Lock, Paperclip, Send, Sparkles, Trash2, X } from "../icons"; +import { useHotkey } from "../hotkeys/HotkeysProvider"; +import { getDisplayName } from "../lib/profile"; +import { getLocalUserId, localDb, type Row } from "../lib/local-db"; +import { PostContent } from "./PostContent"; + +type BoostPost = Row & { + user_id: string; + display_name: string | null; + content: string | null; + is_public: boolean; + post_type: "text" | "photo" | "video"; + video_url: string | null; + image_url: string | null; + workflow_generated: boolean; +}; + +type LikeRow = Row & { post_id: string; user_id: string }; +type LikeState = { count: number; isLiked: boolean }; +type MediaKind = "image" | "video"; + +const PAGE_SIZE = 50; +const MAX_VIDEO_BYTES = 100 * 1024 * 1024; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const ACCEPTED_MEDIA = "video/mp4,video/webm,video/quicktime,image/jpeg,image/png,image/gif,image/webp"; +const NO_LIKES: LikeState = { count: 0, isLiked: false }; +const MINUTE_S = 60; +const HOUR_S = 3600; +const DAY_S = 86400; +const WEEK_S = 604800; + +function formatPostDate(createdAt: string): string { + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return ""; + const seconds = (date.getTime() - Date.now()) / 1000; + const abs = Math.abs(seconds); + const relative = new Intl.RelativeTimeFormat(navigator.language, { numeric: "auto" }); + if (abs < MINUTE_S) return relative.format(Math.round(seconds), "second"); + if (abs < HOUR_S) return relative.format(Math.round(seconds / MINUTE_S), "minute"); + if (abs < DAY_S) return relative.format(Math.round(seconds / HOUR_S), "hour"); + if (abs < WEEK_S) return relative.format(Math.round(seconds / DAY_S), "day"); + return new Intl.DateTimeFormat(navigator.language, { dateStyle: "medium" }).format(date); +} + +async function loadLikes(postIds: readonly string[], userId: string): Promise> { + const { data } = await localDb.from("boost_post_likes").select("*").in("post_id", postIds); + const likes = new Map(postIds.map((id) => [id, { count: 0, isLiked: false }])); + (data ?? []).forEach((like) => { + const state = likes.get(like.post_id); + if (!state) return; + likes.set(like.post_id, { count: state.count + 1, isLiked: state.isLiked || like.user_id === userId }); + }); + return likes; +} + +function isPortraitVideo(video: HTMLVideoElement): boolean { + return video.videoHeight > video.videoWidth; +} + +function UploadedVideo({ src }: { src: string }) { + const [portrait, setPortrait] = useState(false); + return ( +
+
+ ); +} + +function UploadedPhoto({ src }: { src: string }) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- user uploads are object URLs */} + Boost post +
+ ); +} + export function BoostFeed() { - return null; + const [posts, setPosts] = useState([]); + const [likes, setLikes] = useState(new Map()); + const [loading, setLoading] = useState(true); + const [content, setContent] = useState(""); + const [isPublic, setIsPublic] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [userId, setUserId] = useState(null); + const [mediaFile, setMediaFile] = useState(null); + const [mediaKind, setMediaKind] = useState(null); + const [mediaPreview, setMediaPreview] = useState(null); + const [previewPortrait, setPreviewPortrait] = useState(false); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + const formRef = useRef(null); + const composerRef = useRef(null); + useHotkey("list.new", () => composerRef.current?.focus()); + useHotkey("boost.post", () => formRef.current?.requestSubmit()); + useHotkey("boost.attach", () => fileInputRef.current?.click()); + const dragDepthRef = useRef(0); + + useEffect(() => { + const currentUserId = getLocalUserId(); + setUserId(currentUserId); + (async () => { + try { + const { data } = await localDb + .from("boost_posts") + .select("*") + .order("created_at", { ascending: false }) + .range(0, PAGE_SIZE - 1); + const rows = data ?? []; + setPosts(rows); + if (rows.length > 0) setLikes(await loadLikes(rows.map((post) => post.id), currentUserId)); + } catch (error) { + console.error("Error loading posts:", error); + } finally { + setLoading(false); + } + })(); + }, []); + + const selectMedia = (file: File) => { + const isVideo = file.type.startsWith("video/"); + const isImage = file.type.startsWith("image/"); + if (!isVideo && !isImage) return; + if (isVideo && file.size > MAX_VIDEO_BYTES) { + alert("Video must be smaller than 100MB"); + return; + } + if (isImage && file.size > MAX_IMAGE_BYTES) { + alert("Image must be smaller than 10MB"); + return; + } + setMediaFile(file); + setMediaKind(isVideo ? "video" : "image"); + setMediaPreview(URL.createObjectURL(file)); + setPreviewPortrait(false); + }; + + // The preview URL is not revoked once posted: the new post keeps showing it for the session. + const clearMedia = (revoke: boolean) => { + if (revoke && mediaPreview) URL.revokeObjectURL(mediaPreview); + setMediaFile(null); + setMediaKind(null); + setMediaPreview(null); + setPreviewPortrait(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current++; + if (event.dataTransfer.types.includes("Files")) setDragging(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current--; + if (dragDepthRef.current === 0) setDragging(false); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = 0; + setDragging(false); + const file = event.dataTransfer.files?.[0]; + if (file) selectMedia(file); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if ((!content.trim() && !mediaFile) || submitting || !userId) return; + try { + setSubmitting(true); + // ponytail: object URLs die on reload; persist uploads (IndexedDB) if media must survive a refresh. + const { data: post, error } = await localDb + .from("boost_posts") + .insert({ + user_id: userId, + display_name: getDisplayName() || null, + content: content.trim() || null, + is_public: isPublic, + post_type: mediaKind === "video" ? "video" : mediaKind === "image" ? "photo" : "text", + video_url: mediaKind === "video" ? mediaPreview : null, + image_url: mediaKind === "image" ? mediaPreview : null, + workflow_generated: false, + }) + .select() + .single(); + if (error || !post) throw new Error(`Failed to create boost post: ${error?.message}`); + setPosts([post, ...posts]); + setLikes(new Map(likes).set(post.id, { count: 0, isLiked: false })); + setContent(""); + clearMedia(false); + } catch (error) { + console.error("Error creating post:", error); + } finally { + setSubmitting(false); + } + }; + + const deletePost = async (postId: string) => { + try { + await localDb.from("boost_posts").delete().eq("id", postId); + setPosts(posts.filter((post) => post.id !== postId)); + } catch (error) { + console.error("Error deleting post:", error); + } + }; + + const toggleLike = async (postId: string) => { + const state = likes.get(postId); + if (!state || !userId) return; + try { + if (state.isLiked) { + await localDb.from("boost_post_likes").delete().eq("post_id", postId).eq("user_id", userId); + setLikes(new Map(likes).set(postId, { count: state.count - 1, isLiked: false })); + } else { + await localDb.from("boost_post_likes").insert({ post_id: postId, user_id: userId }); + setLikes(new Map(likes).set(postId, { count: state.count + 1, isLiked: true })); + } + } catch (error) { + console.error("Error toggling like:", error); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ {dragging && ( +
+

Drop image or video here

+
+ )} +