feat: improve public chat UI and shared components

This commit is contained in:
CREDO23 2026-01-26 18:39:59 +02:00
parent 37adc54d6a
commit ee65e1377f
14 changed files with 403 additions and 275 deletions

View file

@ -2,18 +2,15 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { User, Users } from "lucide-react";
import { Globe, Link2, User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
type ChatVisibility,
type ThreadRecord,
updateThreadVisibility,
} from "@/lib/chat/thread-persistence";
import { type ChatVisibility, type ThreadRecord, updateThreadVisibility } from "@/lib/chat/thread-persistence";
import { cn } from "@/lib/utils";
interface ChatShareButtonProps {
@ -48,11 +45,19 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
// Public share mutation
const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue(
togglePublicShareMutationAtom
);
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
const isPublicEnabled =
currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false;
const publicShareToken = currentThreadState.publicShareToken ?? null;
const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => {
@ -87,12 +92,41 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
);
const handlePublicShareToggle = useCallback(async () => {
if (!thread) return;
try {
const response = await togglePublicShare({
thread_id: thread.id,
enabled: !isPublicEnabled,
});
// Update atom state with response
setCurrentThreadState((prev) => ({
...prev,
publicShareEnabled: response.enabled,
publicShareToken: response.share_token,
}));
} catch(error) {
console.error("Failed to toggle public share:", error);
}
}, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]);
const handleCopyPublicLink = useCallback(async () => {
if (!publicShareToken) return;
const publicUrl = `${window.location.origin}/public/${publicShareToken}`;
await navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard");
}, [publicShareToken]);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
return null;
}
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = isPublicEnabled ? "Public" : currentVisibility === "PRIVATE" ? "Private" : "Shared";
return (
<Popover open={open} onOpenChange={setOpen}>
@ -108,9 +142,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
)}
>
<CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
<span className="hidden md:inline text-sm">{buttonLabel}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
@ -124,6 +156,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="p-1.5 space-y-1">
{/* Visibility Options */}
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
@ -166,6 +199,65 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
</button>
);
})}
{/* Divider */}
<div className="border-t border-border my-1" />
{/* Public Share Option */}
<button
type="button"
onClick={handlePublicShareToggle}
disabled={isTogglingPublic}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed",
isPublicEnabled && "bg-accent/80"
)}
>
<div
className={cn(
"size-7 rounded-md shrink-0 grid place-items-center",
isPublicEnabled ? "bg-primary/10" : "bg-muted"
)}
>
<Globe
className={cn(
"size-4 block",
isPublicEnabled ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isPublicEnabled && "text-primary")}>
Public
</span>
{isPublicEnabled && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
ON
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Anyone with the link can read
</p>
</div>
{isPublicEnabled && publicShareToken && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyPublicLink();
}}
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors"
title="Copy public link"
>
<Link2 className="size-4 text-muted-foreground" />
</button>
)}
</button>
</div>
</PopoverContent>
</Popover>