chore: code refactor
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EmojiPicker — a tiny, dependency-free emoji popover for text inputs.
|
||||
*
|
||||
* Renders just a trigger button; the panel is portaled to <body> with fixed
|
||||
* positioning and a transparent full-screen backdrop that swallows the
|
||||
* dismiss click. That keeps it OUTSIDE any surrounding drawer/sheet DOM, so
|
||||
* opening it can't resize or reposition the host (e.g. the vaul comments
|
||||
* drawer), and the dismiss click can't leak through and close that drawer.
|
||||
*
|
||||
* It stays open after a pick so several emojis can be added in a row; the
|
||||
* caller decides what to do with each via `onSelect`.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Smile } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A curated, broadly-useful set — enough to feel like a picker without
|
||||
// shipping a full emoji database. Grouped for easy scanning.
|
||||
const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [
|
||||
{
|
||||
label: "Smileys",
|
||||
emojis: [
|
||||
"😀", "😃", "😄", "😁", "😅", "😂", "🙂", "😊",
|
||||
"😇", "😍", "🥰", "😘", "😉", "😌", "😎", "🤗",
|
||||
"🤔", "😴", "😢", "😭", "😮", "😲", "🥲", "😬",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Gestures & people",
|
||||
emojis: [
|
||||
"🙏", "👍", "👎", "👏", "🙌", "🤝", "✌️", "🤞",
|
||||
"💪", "🫶", "👋", "🙇", "🧘", "🕉️", "☮️", "🤲",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Hearts",
|
||||
emojis: [
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🤍", "🤎",
|
||||
"🖤", "💖", "💕", "💗", "💓", "💞", "💔", "❣️",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Nature & spirit",
|
||||
emojis: [
|
||||
"🌸", "🌺", "🌹", "🪷", "🌻", "🌼", "🍃", "🌿",
|
||||
"🪔", "🕯️", "🔥", "✨", "🌟", "⭐", "☀️", "🌈",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Symbols",
|
||||
emojis: [
|
||||
"🎉", "🎊", "🥳", "💯", "✅", "❗", "❓", "💬",
|
||||
"📿", "🛕", "🐮", "🦚", "🪈", "🎶", "📖", "🙏🏽",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const PANEL_WIDTH = 300;
|
||||
|
||||
export function EmojiPicker({
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState<{ bottom: number; left: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function toggle() {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (r) {
|
||||
// Open ABOVE the trigger (the composer sits at the bottom of the sheet),
|
||||
// clamped so the 300px panel stays on-screen horizontally.
|
||||
const left = Math.max(
|
||||
8,
|
||||
Math.min(r.left, window.innerWidth - PANEL_WIDTH - 8),
|
||||
);
|
||||
setPos({ bottom: window.innerHeight - r.top + 6, left });
|
||||
}
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label="Add emoji"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"size-10 shrink-0 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Smile className="size-5" />
|
||||
</button>
|
||||
{open && pos && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[100]"
|
||||
aria-hidden
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Emoji picker"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: pos.bottom,
|
||||
left: pos.left,
|
||||
width: PANEL_WIDTH,
|
||||
maxWidth: "calc(100vw - 1rem)",
|
||||
}}
|
||||
className="z-[101] rounded-xl border border-border bg-card shadow-xl p-2 animate-in fade-in-0 zoom-in-95"
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto overscroll-contain">
|
||||
{EMOJI_GROUPS.map((g) => (
|
||||
<div key={g.label} className="mb-1.5">
|
||||
<p className="px-1 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{g.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{g.emojis.map((e, i) => (
|
||||
<button
|
||||
key={`${e}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(e)}
|
||||
aria-label={`Emoji ${e}`}
|
||||
className="flex size-8 items-center justify-center rounded-md text-xl leading-none hover:bg-muted"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* UserAvatar — the one avatar component: shows the user's image, or their
|
||||
* initials on a tinted disc when there's no image. Used everywhere a user
|
||||
* thumbnail appears (notification feed, comments, follower lists, and the
|
||||
* profile header's large zoomable hero).
|
||||
*
|
||||
* Sizing is class-driven (`className`) rather than a fixed px, so callers get
|
||||
* Tailwind's responsive sizes — e.g. `size-10` for a list row or
|
||||
* `size-24 sm:size-28` for the profile header. `textClassName` scales the
|
||||
* initials to match. When `onClick` is set, an image avatar becomes a
|
||||
* focusable button (used by the profile header to open its zoom preview);
|
||||
* `interactive` adds a hover-ring affordance.
|
||||
*/
|
||||
export function UserAvatar({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
textClassName = "text-sm",
|
||||
onClick,
|
||||
ariaLabel,
|
||||
interactive = false,
|
||||
}: {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
/** Box size + extras. Defaults to a 40px (size-10) circle. */
|
||||
className?: string;
|
||||
/** Initials font size — match it to the box. Defaults to text-sm. */
|
||||
textClassName?: string;
|
||||
/** When set, an image avatar renders as a focusable button (e.g. to open a
|
||||
* zoom preview or navigate). Initials stay non-interactive. */
|
||||
onClick?: () => void;
|
||||
ariaLabel?: string;
|
||||
/** Add a hover-ring affordance to an image avatar. */
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
const size = className ?? "size-10";
|
||||
|
||||
if (src) {
|
||||
const imgRing = cn(
|
||||
"rounded-full object-cover ring-1 ring-foreground/10",
|
||||
interactive && "hover:ring-foreground/30 transition",
|
||||
);
|
||||
// width/height satisfy next/image; the rendered size comes from the CSS
|
||||
// size class (which overrides the intrinsic attributes).
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel ?? `View ${name}'s picture`}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
size,
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={112}
|
||||
height={112}
|
||||
className={cn("size-full", imgRing)}
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={112}
|
||||
height={112}
|
||||
className={cn("shrink-0", size, imgRing)}
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const initials =
|
||||
name
|
||||
.replace(/^@/, "")
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full bg-primary/15 text-primary flex items-center justify-center font-semibold ring-1 ring-foreground/10",
|
||||
size,
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user