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:
admin committed 2026-06-23 06:17:58 +02:00
1 parent 6542c06e02
commit 2568c32cf4
7 files changed
+212 -30

No files matched your search

+16 -4
View File
@@ -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"
/>
+8
View File
@@ -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>
);
}
+11 -17
View File
@@ -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"
/>
+14 -3
View File
@@ -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>
+152
View File
@@ -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>
);
}
+5 -2
View File
@@ -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 });
}