feat: notification polling 30s->10s, video thumbnails, @-mentions
- NotificationBell: refetchInterval 30000->10000, refetchOnWindowFocus, i18n label - ProfileTabs: Mux thumbnail for VIDEO posts in grid, play icon overlay - parse-caption: add mention segment type for @word parsing - MentionTextarea: new component with @-autocomplete via search.pets - PostCard: render @mentions as blue links to /search - CommentSheet: parse comment bodies for mentions, use MentionTextarea - PhotoPostForm: use MentionTextarea for caption input
This commit is contained in:
1 parent
6542c06e02
commit
2568c32cf4
7 files changed
+212
-30
No files matched your search
@@ -5,8 +5,9 @@ import { Trash2, Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import Link from "next/link";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MentionTextarea } from "@/components/ui/mention-textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { parseCaption } from "@/lib/parse-caption";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CommentSheetProps {
|
||||
@@ -144,7 +146,17 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-0.5 break-words">{comment.body}</p>
|
||||
<p className="text-sm mt-0.5 break-words">
|
||||
{parseCaption(comment.body).map((seg, i) =>
|
||||
seg.type === "hashtag" ? (
|
||||
<Link key={i} href={`/hashtag/${seg.tag}`} className="text-orange-500 hover:underline">#{seg.tag}</Link>
|
||||
) : seg.type === "mention" ? (
|
||||
<Link key={i} href={`/search?q=${seg.name}`} className="text-blue-500 hover:underline">@{seg.name}</Link>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{isOwn && (
|
||||
<button
|
||||
@@ -170,13 +182,13 @@ export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheet
|
||||
|
||||
{/* Comment input — sticky at bottom */}
|
||||
<div className="flex gap-2 px-4 py-3 bg-background">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
maxLength={500}
|
||||
placeholder={t("placeholder")}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onChange={(val) => setBody(val)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
|
||||
/>
|
||||
|
||||
@@ -436,6 +436,14 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
|
||||
>
|
||||
#{seg.tag}
|
||||
</Link>
|
||||
) : seg.type === "mention" ? (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/search?q=${seg.name}`}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
@{seg.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bell } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface NotificationBellProps {
|
||||
/** "sidebar" = full row with label; "mobile" = icon + label stacked */
|
||||
@@ -15,11 +16,13 @@ interface NotificationBellProps {
|
||||
export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const t = useTranslations("Nav");
|
||||
|
||||
const { data } = useQuery({
|
||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 10_000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
@@ -43,12 +46,11 @@ export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Notifications</span>
|
||||
<span>{t("notifications")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile variant — icon stacked above label
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
@@ -64,7 +66,7 @@ export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Alerts</span>
|
||||
<span>{t("notifications")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MentionTextarea } from "@/components/ui/mention-textarea";
|
||||
import { MultiImageUpload } from "@/components/post-creation/MultiImageUpload";
|
||||
|
||||
/**
|
||||
@@ -62,19 +62,6 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const handleCaptionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setCaption(value);
|
||||
if (value.length > 500) {
|
||||
setCaptionError("Caption must be 500 characters or fewer.");
|
||||
} else {
|
||||
setCaptionError(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!activePetId) {
|
||||
toast.error("No active pet selected.");
|
||||
@@ -116,11 +103,18 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
|
||||
{/* Caption textarea */}
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
placeholder="Write a caption…"
|
||||
value={caption}
|
||||
onChange={handleCaptionChange}
|
||||
maxLength={510} // let Zod catch exact limit; UI shows error at 500
|
||||
onChange={(val) => {
|
||||
setCaption(val);
|
||||
if (val.length > 500) {
|
||||
setCaptionError("Caption must be 500 characters or fewer.");
|
||||
} else {
|
||||
setCaptionError(null);
|
||||
}
|
||||
}}
|
||||
maxLength={510}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Layers2 } from "lucide-react";
|
||||
import { Layers2, Play } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -71,7 +71,13 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
|
||||
{posts.map((post) => {
|
||||
const firstImage = post.images?.[0];
|
||||
const isCarousel = (post.images?.length ?? 0) > 1;
|
||||
const mediaUrl = firstImage ? getMediaUrl(firstImage.storageKey) : null;
|
||||
const isVideo = post.type === "VIDEO";
|
||||
const muxPlaybackId = post.videoPost?.muxPlaybackId;
|
||||
const mediaUrl = firstImage
|
||||
? getMediaUrl(firstImage.storageKey)
|
||||
: isVideo && muxPlaybackId
|
||||
? `https://image.mux.com/${muxPlaybackId}/thumbnail.jpg?width=320&time=0`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -91,7 +97,12 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted" />
|
||||
)}
|
||||
{isCarousel && (
|
||||
{isVideo && (
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<Play className="h-4 w-4 text-white drop-shadow fill-white" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{isCarousel && !isVideo && (
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<Layers2 className="h-4 w-4 text-white drop-shadow" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
|
||||
ref?: React.Ref<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
query: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export function MentionTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
maxLength,
|
||||
rows,
|
||||
className,
|
||||
onKeyDown,
|
||||
ref: externalRef,
|
||||
}: MentionTextareaProps) {
|
||||
const trpc = useTRPC();
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const internalRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Merged ref: keeps internal ref in sync and forwards to caller
|
||||
const mergedRef = useCallback(
|
||||
(el: HTMLTextAreaElement | null) => {
|
||||
(internalRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
|
||||
if (typeof externalRef === "function") {
|
||||
externalRef(el);
|
||||
} else if (externalRef) {
|
||||
(externalRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
|
||||
}
|
||||
},
|
||||
[externalRef]
|
||||
);
|
||||
|
||||
const textareaRef = internalRef;
|
||||
|
||||
const { data } = useQuery({
|
||||
...trpc.search.pets.queryOptions({ query: mention?.query ?? ".", limit: 6 }),
|
||||
enabled: !!mention && mention.query.length >= 1,
|
||||
});
|
||||
|
||||
const detectMention = useCallback((text: string, cursorPos: number) => {
|
||||
const textBeforeCursor = text.slice(0, cursorPos);
|
||||
const match = textBeforeCursor.match(/@(\w+)$/);
|
||||
if (match) {
|
||||
setMention({ query: match[1], start: cursorPos - match[0].length });
|
||||
} else {
|
||||
setMention(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
detectMention(newValue, e.target.selectionStart ?? newValue.length);
|
||||
},
|
||||
[onChange, detectMention]
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
setMention(null);
|
||||
return;
|
||||
}
|
||||
detectMention(value, (e.target as HTMLTextAreaElement).selectionStart ?? value.length);
|
||||
},
|
||||
[value, detectMention]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(petName: string) => {
|
||||
if (!mention) return;
|
||||
const before = value.slice(0, mention.start);
|
||||
const after = value.slice(mention.start + 1 + mention.query.length);
|
||||
const inserted = `${before}@${petName} ${after}`;
|
||||
onChange(inserted);
|
||||
setMention(null);
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
const pos = before.length + petName.length + 2;
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(pos, pos);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[mention, value, onChange]
|
||||
);
|
||||
|
||||
const pets = data?.pets ?? [];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={mergedRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
{mention && pets.length > 0 && (
|
||||
<div className="absolute z-50 bottom-full mb-1 left-0 right-0 bg-card border rounded-md shadow-lg overflow-hidden max-h-48 overflow-y-auto">
|
||||
{pets.map((pet) => (
|
||||
<button
|
||||
key={pet.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted text-sm text-left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(pet.name);
|
||||
}}
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
{pet.avatarKey && (
|
||||
<AvatarImage src={getAvatarUrl(pet.avatarKey)!} alt={pet.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium flex-1 truncate">{pet.name}</span>
|
||||
<span className="text-muted-foreground text-xs shrink-0">{pet.species.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
export type CaptionSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "hashtag"; tag: string };
|
||||
| { type: "hashtag"; tag: string }
|
||||
| { type: "mention"; name: string };
|
||||
|
||||
export function parseCaption(caption: string): CaptionSegment[] {
|
||||
const segments: CaptionSegment[] = [];
|
||||
const parts = caption.split(/(#\w{1,50})/g);
|
||||
const parts = caption.split(/(#\w{1,50}|@\w{1,50})/g);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("#")) {
|
||||
segments.push({ type: "hashtag", tag: part.slice(1).toLowerCase() });
|
||||
} else if (part.startsWith("@")) {
|
||||
segments.push({ type: "mention", name: part.slice(1) });
|
||||
} else if (part) {
|
||||
segments.push({ type: "text", text: part });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user