mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Polish channel modal forms (#1000)
This commit is contained in:
@@ -1,52 +1,20 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { Compass, Search, X, type LucideIcon } from "lucide-react";
|
||||||
Compass,
|
|
||||||
FileText,
|
|
||||||
Hash,
|
|
||||||
LogIn,
|
|
||||||
Search,
|
|
||||||
Users,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/ui/dialog";
|
} from "@/shared/ui/dialog";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Input } from "@/shared/ui/input";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||||
|
|
||||||
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
|
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
|
||||||
|
type BrowserTab = "all" | "joined" | "archived";
|
||||||
function formatRelativeTime(isoString: string | null) {
|
|
||||||
if (!isoString) {
|
|
||||||
return "No activity";
|
|
||||||
}
|
|
||||||
|
|
||||||
const diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1_000);
|
|
||||||
|
|
||||||
if (diff < 60) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diff < 60 * 60) {
|
|
||||||
return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diff < 60 * 60 * 24) {
|
|
||||||
return `${Math.floor(diff / (60 * 60))}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(isoString));
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrowseState({
|
function BrowseState({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -60,7 +28,7 @@ function BrowseState({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
|
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
|
||||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
@@ -88,24 +56,34 @@ export function ChannelBrowserDialog({
|
|||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
}: ChannelBrowserDialogProps) {
|
}: ChannelBrowserDialogProps) {
|
||||||
const [query, setQuery] = React.useState("");
|
const [query, setQuery] = React.useState("");
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [activeTab, setActiveTab] = React.useState<BrowserTab>("all");
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
|
||||||
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
|
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const tabListRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const tabTriggerRefs = React.useRef<
|
||||||
|
Record<BrowserTab, HTMLButtonElement | null>
|
||||||
|
>({
|
||||||
|
all: null,
|
||||||
|
joined: null,
|
||||||
|
archived: null,
|
||||||
|
});
|
||||||
|
const [tabIndicator, setTabIndicator] = React.useState({
|
||||||
|
left: 0,
|
||||||
|
width: 0,
|
||||||
|
});
|
||||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||||
|
|
||||||
const isForumMode = channelTypeFilter === "forum";
|
const isForumMode = channelTypeFilter === "forum";
|
||||||
const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
|
const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
|
||||||
const browseDescription = isForumMode
|
|
||||||
? "Discover and join open forums."
|
|
||||||
: "Discover and join open channels.";
|
|
||||||
const searchPlaceholder = isForumMode
|
const searchPlaceholder = isForumMode
|
||||||
? "Search forums by name or description"
|
? "Search forums by name or description"
|
||||||
: "Search channels by name or description";
|
: "Search channels by name or description";
|
||||||
const entityLabel = isForumMode ? "forum" : "channel";
|
const entityLabel = isForumMode ? "forum" : "channel";
|
||||||
|
|
||||||
const browsableChannels = React.useMemo(() => {
|
const matchingChannels = React.useMemo(() => {
|
||||||
const filtered = channels.filter(
|
const filtered = channels.filter(
|
||||||
(channel) =>
|
(channel) =>
|
||||||
channel.channelType !== "dm" &&
|
channel.channelType !== "dm" &&
|
||||||
@@ -126,53 +104,114 @@ export function ChannelBrowserDialog({
|
|||||||
);
|
);
|
||||||
}, [channels, channelTypeFilter, deferredQuery]);
|
}, [channels, channelTypeFilter, deferredQuery]);
|
||||||
|
|
||||||
const notJoined = React.useMemo(
|
const currentChannels = React.useMemo(
|
||||||
() => browsableChannels.filter((channel) => !channel.isMember),
|
() => matchingChannels.filter((channel) => channel.archivedAt === null),
|
||||||
[browsableChannels],
|
[matchingChannels],
|
||||||
);
|
);
|
||||||
|
|
||||||
const joined = React.useMemo(
|
const joinedChannels = React.useMemo(
|
||||||
() => browsableChannels.filter((channel) => channel.isMember),
|
() => currentChannels.filter((channel) => channel.isMember),
|
||||||
[browsableChannels],
|
[currentChannels],
|
||||||
);
|
|
||||||
const hasArchivedJoinedChannels = React.useMemo(
|
|
||||||
() => joined.some((channel) => channel.archivedAt !== null),
|
|
||||||
[joined],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flat list for keyboard navigation: not-joined first, then joined
|
const archivedChannels = React.useMemo(
|
||||||
const allItems = React.useMemo(
|
() => matchingChannels.filter((channel) => channel.archivedAt !== null),
|
||||||
() => [...notJoined, ...joined],
|
[matchingChannels],
|
||||||
[notJoined, joined],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const visibleChannels =
|
||||||
|
activeTab === "archived"
|
||||||
|
? archivedChannels
|
||||||
|
: activeTab === "joined"
|
||||||
|
? joinedChannels
|
||||||
|
: matchingChannels;
|
||||||
|
|
||||||
|
const orderedVisibleChannels = React.useMemo(
|
||||||
|
() => [
|
||||||
|
...visibleChannels.filter((channel) => !channel.isMember),
|
||||||
|
...visibleChannels.filter((channel) => channel.isMember),
|
||||||
|
],
|
||||||
|
[visibleChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTabLabel = isForumMode ? "All forums" : "All channels";
|
||||||
|
|
||||||
|
const updateTabIndicator = React.useCallback(() => {
|
||||||
|
const list = tabListRef.current;
|
||||||
|
const trigger = tabTriggerRefs.current[activeTab];
|
||||||
|
|
||||||
|
if (!open || !list || !trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndicator = {
|
||||||
|
left: trigger.offsetLeft,
|
||||||
|
width: trigger.offsetWidth,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTabIndicator((current) =>
|
||||||
|
Math.abs(current.left - nextIndicator.left) < 0.5 &&
|
||||||
|
Math.abs(current.width - nextIndicator.width) < 0.5
|
||||||
|
? current
|
||||||
|
: nextIndicator,
|
||||||
|
);
|
||||||
|
}, [activeTab, open]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
updateTabIndicator();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
const updateIfActive = () => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
updateTabIndicator();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const frameId = window.requestAnimationFrame(updateIfActive);
|
||||||
|
const observer = new ResizeObserver(updateTabIndicator);
|
||||||
|
const list = tabListRef.current;
|
||||||
|
|
||||||
|
void document.fonts.ready.then(updateIfActive);
|
||||||
|
|
||||||
|
if (list) {
|
||||||
|
observer.observe(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trigger of Object.values(tabTriggerRefs.current)) {
|
||||||
|
if (trigger) {
|
||||||
|
observer.observe(trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [open, updateTabIndicator]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setSelectedIndex(0);
|
setActiveTab("all");
|
||||||
|
setSelectedIndex(null);
|
||||||
setJoiningChannelId(null);
|
setJoiningChannelId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex((current) => {
|
setSelectedIndex((current) => {
|
||||||
if (allItems.length === 0) {
|
if (current === null || orderedVisibleChannels.length === 0) {
|
||||||
return 0;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.min(current, allItems.length - 1);
|
return Math.min(current, orderedVisibleChannels.length - 1);
|
||||||
});
|
});
|
||||||
}, [allItems]);
|
}, [orderedVisibleChannels]);
|
||||||
|
|
||||||
async function handleJoin(channelId: string) {
|
async function handleJoin(channelId: string) {
|
||||||
setJoiningChannelId(channelId);
|
setJoiningChannelId(channelId);
|
||||||
@@ -191,144 +230,199 @@ export function ChannelBrowserDialog({
|
|||||||
onSelectChannel(channel.id);
|
onSelectChannel(channel.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedItem = allItems[selectedIndex];
|
const selectedItem =
|
||||||
|
selectedIndex !== null ? orderedVisibleChannels[selectedIndex] : undefined;
|
||||||
|
const emptyTitle =
|
||||||
|
deferredQuery.length > 0
|
||||||
|
? `No ${entityLabel}s match your search`
|
||||||
|
: activeTab === "archived"
|
||||||
|
? `No archived ${entityLabel}s`
|
||||||
|
: activeTab === "joined"
|
||||||
|
? `No joined ${entityLabel}s`
|
||||||
|
: `No ${entityLabel}s to browse`;
|
||||||
|
const emptyDescription =
|
||||||
|
deferredQuery.length > 0
|
||||||
|
? "Try a different name or keyword."
|
||||||
|
: activeTab === "archived"
|
||||||
|
? `Archived ${entityLabel}s you have joined will appear here.`
|
||||||
|
: activeTab === "joined"
|
||||||
|
? `${entityLabel[0].toUpperCase()}${entityLabel.slice(1)}s you join will appear here.`
|
||||||
|
: `All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="gap-0 overflow-hidden p-0"
|
aria-describedby={undefined}
|
||||||
|
className="gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
|
||||||
data-testid={
|
data-testid={
|
||||||
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog"
|
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog"
|
||||||
}
|
}
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<DialogHeader className="border-b border-border/80 px-6 py-5">
|
<DialogHeader className="space-y-0 pb-5">
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-xs">
|
<DialogTitle>{browseTitle}</DialogTitle>
|
||||||
<Compass className="h-4 w-4" />
|
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
|
||||||
</span>
|
<X className="h-4 w-4" />
|
||||||
{browseTitle}
|
<span className="sr-only">Close</span>
|
||||||
</DialogTitle>
|
</DialogClose>
|
||||||
<DialogDescription>{browseDescription}</DialogDescription>
|
</div>
|
||||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-xs">
|
<label
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
className="mt-4 flex cursor-text items-center gap-3 rounded-xl border border-input bg-background px-3 py-2.5 shadow-xs transition-colors duration-150 ease-out hover:border-muted-foreground/40 hover:bg-muted/70 focus-within:border-muted-foreground/50 focus-within:bg-muted/70 group/search"
|
||||||
<Input
|
htmlFor="channel-browser-search"
|
||||||
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
|
>
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
|
||||||
|
<input
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
className="block h-6 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 text-muted-foreground/55 shadow-none caret-foreground outline-none transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 group-hover/search:text-muted-foreground group-hover/search:placeholder:text-muted-foreground group-focus-within/search:text-foreground group-focus-within/search:placeholder:text-foreground"
|
||||||
data-testid="channel-browser-search"
|
data-testid="channel-browser-search"
|
||||||
|
id="channel-browser-search"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setQuery(event.target.value);
|
setQuery(event.target.value);
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(null);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "ArrowDown" && allItems.length > 0) {
|
if (
|
||||||
|
event.key === "ArrowDown" &&
|
||||||
|
orderedVisibleChannels.length > 0
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelectedIndex((current) =>
|
setSelectedIndex((current) =>
|
||||||
Math.min(current + 1, allItems.length - 1),
|
current === null
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
current + 1,
|
||||||
|
orderedVisibleChannels.length - 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "ArrowUp" && allItems.length > 0) {
|
if (
|
||||||
|
event.key === "ArrowUp" &&
|
||||||
|
orderedVisibleChannels.length > 0
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelectedIndex((current) => Math.max(current - 1, 0));
|
setSelectedIndex((current) =>
|
||||||
|
current === null
|
||||||
|
? orderedVisibleChannels.length - 1
|
||||||
|
: Math.max(current - 1, 0),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
!event.nativeEvent.isComposing &&
|
!event.nativeEvent.isComposing &&
|
||||||
selectedItem
|
orderedVisibleChannels.length > 0
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSelect(selectedItem);
|
handleSelect(selectedItem ?? orderedVisibleChannels[0]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
spellCheck={false}
|
||||||
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
|
<span
|
||||||
|
className={`hidden shrink-0 text-xs text-muted-foreground/50 transition-opacity duration-150 ease-out group-focus-within/search:opacity-0 sm:block ${
|
||||||
|
query.length > 0 ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{BROWSE_CHANNELS_SHORTCUT_HINT}
|
{BROWSE_CHANNELS_SHORTCUT_HINT}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</label>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
<div className="h-[min(60vh,30rem)] overflow-hidden">
|
||||||
{browsableChannels.length === 0 ? (
|
<div className="flex h-full flex-col">
|
||||||
deferredQuery.length > 0 ? (
|
<Tabs
|
||||||
<BrowseState
|
className="shrink-0"
|
||||||
description="Try a different name or keyword."
|
onValueChange={(value) => {
|
||||||
icon={Search}
|
setActiveTab(value as BrowserTab);
|
||||||
title={`No ${entityLabel}s match your search`}
|
setSelectedIndex(null);
|
||||||
/>
|
}}
|
||||||
) : (
|
value={activeTab}
|
||||||
<BrowseState
|
>
|
||||||
description={`All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`}
|
<TabsList
|
||||||
icon={Compass}
|
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
|
||||||
title={`No ${entityLabel}s to browse`}
|
ref={tabListRef}
|
||||||
/>
|
>
|
||||||
)
|
<span
|
||||||
) : (
|
aria-hidden="true"
|
||||||
<div className="p-3">
|
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
|
||||||
{notJoined.length > 0 ? (
|
data-ready={tabIndicator.width > 0}
|
||||||
<>
|
data-testid="channel-browser-tab-indicator"
|
||||||
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
style={{
|
||||||
<span>
|
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
|
||||||
{notJoined.length} {entityLabel}
|
}}
|
||||||
{notJoined.length !== 1 ? "s" : ""} to join
|
/>
|
||||||
</span>
|
<TabsTrigger
|
||||||
<span>Enter to view</span>
|
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||||
</div>
|
ref={(element) => {
|
||||||
<div className="space-y-2">
|
tabTriggerRefs.current.all = element;
|
||||||
{notJoined.map((channel) => {
|
}}
|
||||||
const flatIndex = allItems.indexOf(channel);
|
value="all"
|
||||||
return (
|
>
|
||||||
<ChannelCard
|
{allTabLabel}
|
||||||
channel={channel}
|
</TabsTrigger>
|
||||||
isJoining={joiningChannelId === channel.id}
|
<TabsTrigger
|
||||||
isSelected={flatIndex === selectedIndex}
|
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||||
key={channel.id}
|
ref={(element) => {
|
||||||
onJoin={() => {
|
tabTriggerRefs.current.joined = element;
|
||||||
void handleJoin(channel.id);
|
}}
|
||||||
}}
|
value="joined"
|
||||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
>
|
||||||
onSelect={() => handleSelect(channel)}
|
Joined
|
||||||
/>
|
</TabsTrigger>
|
||||||
);
|
<TabsTrigger
|
||||||
})}
|
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||||
</div>
|
ref={(element) => {
|
||||||
</>
|
tabTriggerRefs.current.archived = element;
|
||||||
) : null}
|
}}
|
||||||
|
value="archived"
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{joined.length > 0 ? (
|
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
|
||||||
<>
|
{orderedVisibleChannels.length === 0 ? (
|
||||||
<div className="mb-3 mt-4 flex items-center px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
<BrowseState
|
||||||
<span>Joined</span>
|
description={emptyDescription}
|
||||||
</div>
|
icon={deferredQuery.length > 0 ? Search : Compass}
|
||||||
<div className="space-y-2">
|
title={emptyTitle}
|
||||||
{joined.map((channel) => {
|
/>
|
||||||
const flatIndex = allItems.indexOf(channel);
|
) : (
|
||||||
return (
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
|
||||||
<ChannelCard
|
{orderedVisibleChannels.map((channel, index) => (
|
||||||
channel={channel}
|
<ChannelCard
|
||||||
isJoining={false}
|
channel={channel}
|
||||||
isSelected={flatIndex === selectedIndex}
|
isJoining={joiningChannelId === channel.id}
|
||||||
key={channel.id}
|
isSelected={index === selectedIndex}
|
||||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
key={channel.id}
|
||||||
onSelect={() => handleSelect(channel)}
|
onJoin={
|
||||||
/>
|
!channel.isMember
|
||||||
);
|
? () => {
|
||||||
})}
|
void handleJoin(channel.id);
|
||||||
</div>
|
}
|
||||||
</>
|
: undefined
|
||||||
) : null}
|
}
|
||||||
|
onSelect={() => handleSelect(channel)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
|
|
||||||
{hasArchivedJoinedChannels
|
|
||||||
? `Showing open ${entityLabel}s and your archived ${entityLabel}s. Private ${entityLabel}s require an invite.`
|
|
||||||
: `Showing open ${entityLabel}s. Private ${entityLabel}s require an invite.`}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -340,80 +434,80 @@ function ChannelCard({
|
|||||||
isJoining,
|
isJoining,
|
||||||
isSelected,
|
isSelected,
|
||||||
onJoin,
|
onJoin,
|
||||||
onMouseEnter,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
isJoining: boolean;
|
isJoining: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onJoin?: () => void;
|
onJoin?: () => void;
|
||||||
onMouseEnter: () => void;
|
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const memberLabel = `${channel.memberCount} ${
|
||||||
|
channel.memberCount === 1 ? "member" : "members"
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
className={
|
className={
|
||||||
isSelected
|
isSelected
|
||||||
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-xs outline-hidden transition-colors"
|
? "group/channel-row flex min-h-16 items-center gap-4 bg-muted/40 px-4 py-3 transition-colors duration-150 ease-out"
|
||||||
: "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-xs outline-hidden transition-colors hover:border-primary/20 hover:bg-accent"
|
: "group/channel-row flex min-h-16 items-center gap-4 px-4 py-3 transition-colors duration-150 ease-out hover:bg-muted/40"
|
||||||
}
|
}
|
||||||
data-testid={`browse-channel-${channel.name}`}
|
data-testid={`browse-channel-${channel.name}`}
|
||||||
onClick={onSelect}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<button
|
||||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
|
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-left text-foreground outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
{channel.channelType === "forum" ? (
|
onClick={(event) => {
|
||||||
<FileText className="h-4 w-4" />
|
event.stopPropagation();
|
||||||
) : (
|
onSelect();
|
||||||
<Hash className="h-4 w-4" />
|
}}
|
||||||
)}
|
type="button"
|
||||||
</div>
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span className="shrink-0 text-sm font-normal text-muted-foreground">
|
||||||
<p className="text-sm font-semibold tracking-tight">
|
#
|
||||||
|
</span>
|
||||||
|
<p className="min-w-0 truncate text-base font-medium tracking-tight">
|
||||||
{channel.name}
|
{channel.name}
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="secondary">{channel.channelType}</Badge>
|
|
||||||
{channel.archivedAt ? (
|
{channel.archivedAt ? (
|
||||||
<Badge variant="warning">archived</Badge>
|
<Badge className="ml-1 shrink-0" variant="warning">
|
||||||
|
archived
|
||||||
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
{channel.memberCount}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatRelativeTime(channel.lastMessageAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{channel.description ? (
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground line-clamp-2">
|
<span>{memberLabel}</span>
|
||||||
{channel.description}
|
{channel.description ? (
|
||||||
</p>
|
<>
|
||||||
) : null}
|
<span className="px-1.5">·</span>
|
||||||
|
<span title={channel.description}>{channel.description}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{!channel.isMember && onJoin ? (
|
{!channel.isMember && onJoin ? (
|
||||||
<Button
|
<Button
|
||||||
className="shrink-0"
|
className={
|
||||||
disabled={isJoining}
|
isJoining
|
||||||
onClick={(event) => {
|
? "shrink-0"
|
||||||
event.stopPropagation();
|
: "shrink-0 opacity-0 transition-opacity duration-150 ease-out group-hover/channel-row:opacity-100 group-focus-within/channel-row:opacity-100"
|
||||||
onJoin();
|
}
|
||||||
}}
|
disabled={isJoining}
|
||||||
size="sm"
|
onClick={(event) => {
|
||||||
type="button"
|
event.stopPropagation();
|
||||||
variant="default"
|
onJoin();
|
||||||
>
|
}}
|
||||||
<LogIn className="mr-1.5 h-3.5 w-3.5" />
|
size="sm"
|
||||||
{isJoining ? "Joining..." : "Join"}
|
type="button"
|
||||||
</Button>
|
variant="default"
|
||||||
) : null}
|
>
|
||||||
</div>
|
{isJoining ? "Joining..." : "Join"}
|
||||||
</button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,12 @@ export function useRichTextEditor({
|
|||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
autocapitalize: "none",
|
||||||
|
autocorrect: "off",
|
||||||
class:
|
class:
|
||||||
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
|
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
|
||||||
"data-testid": "message-input",
|
"data-testid": "message-input",
|
||||||
|
spellcheck: "false",
|
||||||
},
|
},
|
||||||
// ArrowUp in an empty composer → edit your last message (Slack
|
// ArrowUp in an empty composer → edit your last message (Slack
|
||||||
// parity). Handled here in ProseMirror's own DOM `keydown` hook —
|
// parity). Handled here in ProseMirror's own DOM `keydown` hook —
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function ChannelFindBar({
|
|||||||
<div className="relative flex min-w-0 flex-1 items-center">
|
<div className="relative flex min-w-0 flex-1 items-center">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm",
|
"h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
@@ -70,6 +72,7 @@ export function ChannelFindBar({
|
|||||||
onChange={(event) => onQueryChange(event.target.value)}
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Find in channel"
|
placeholder="Find in channel"
|
||||||
|
spellCheck={false}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { Lock, Zap } from "lucide-react";
|
import { ClockFading, Hash } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
|
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
|
||||||
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
|
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||||
import { Dialog } from "@/shared/ui/dialog";
|
import { Dialog } from "@/shared/ui/dialog";
|
||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { Switch } from "@/shared/ui/switch";
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
|
|
||||||
/** Default TTL for ephemeral channels: 1 day of inactivity. */
|
/** Default TTL for ephemeral channels: 1 day of inactivity. */
|
||||||
const EPHEMERAL_TTL_SECONDS = 86400;
|
const EPHEMERAL_TTL_SECONDS = 86400;
|
||||||
|
const CREATE_FIELD_SHELL_CLASS =
|
||||||
|
"rounded-xl border border-input bg-background shadow-xs transition-colors duration-150 ease-out hover:border-muted-foreground/40 hover:bg-muted/70 focus-within:border-muted-foreground/50 focus-within:bg-muted/70";
|
||||||
|
const CREATE_FIELD_CONTROL_CLASS =
|
||||||
|
"border-0 bg-transparent shadow-none outline-none ring-0 placeholder:text-muted-foreground/45 focus:bg-transparent focus:outline-hidden focus-visible:ring-0";
|
||||||
|
|
||||||
type ChannelKind = "stream" | "forum";
|
type ChannelKind = "stream" | "forum";
|
||||||
|
|
||||||
@@ -136,7 +141,10 @@ export function CreateChannelDialog({
|
|||||||
>
|
>
|
||||||
<ChooserDialogContent
|
<ChooserDialogContent
|
||||||
className="max-w-lg"
|
className="max-w-lg"
|
||||||
|
contentClassName="pt-3"
|
||||||
data-testid="create-channel-dialog"
|
data-testid="create-channel-dialog"
|
||||||
|
footerClassName="border-t-0 pt-0"
|
||||||
|
headerClassName="pb-2"
|
||||||
title={`Create a new ${kindLabel}`}
|
title={`Create a new ${kindLabel}`}
|
||||||
description={
|
description={
|
||||||
channelKind === "forum"
|
channelKind === "forum"
|
||||||
@@ -165,7 +173,7 @@ export function CreateChannelDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-5"
|
||||||
id="create-channel-form"
|
id="create-channel-form"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
void handleSubmit(event);
|
void handleSubmit(event);
|
||||||
@@ -179,24 +187,34 @@ export function CreateChannelDialog({
|
|||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<div
|
||||||
autoCapitalize="none"
|
className={cn(
|
||||||
autoComplete="off"
|
"flex h-9 items-center px-3",
|
||||||
autoCorrect="off"
|
CREATE_FIELD_SHELL_CLASS,
|
||||||
data-testid="create-channel-name"
|
)}
|
||||||
disabled={isCreating}
|
>
|
||||||
id="create-channel-name"
|
<Input
|
||||||
onChange={(event) => {
|
autoCapitalize="none"
|
||||||
setName(event.target.value);
|
autoComplete="off"
|
||||||
setErrorMessage(null);
|
autoCorrect="off"
|
||||||
}}
|
className={cn("h-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
|
||||||
placeholder={
|
data-testid="create-channel-name"
|
||||||
channelKind === "forum" ? "design-discussions" : "release-notes"
|
disabled={isCreating}
|
||||||
}
|
id="create-channel-name"
|
||||||
ref={nameInputRef}
|
onChange={(event) => {
|
||||||
spellCheck={false}
|
setName(event.target.value);
|
||||||
value={name}
|
setErrorMessage(null);
|
||||||
/>
|
}}
|
||||||
|
placeholder={
|
||||||
|
channelKind === "forum"
|
||||||
|
? "design-discussions"
|
||||||
|
: "release-notes"
|
||||||
|
}
|
||||||
|
ref={nameInputRef}
|
||||||
|
spellCheck={false}
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -210,91 +228,137 @@ export function CreateChannelDialog({
|
|||||||
(optional)
|
(optional)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<div className={CREATE_FIELD_SHELL_CLASS}>
|
||||||
className="min-h-16 resize-none"
|
<Textarea
|
||||||
data-testid="create-channel-description"
|
className={cn(
|
||||||
disabled={isCreating}
|
"min-h-16 resize-none px-3 py-2",
|
||||||
id="create-channel-description"
|
CREATE_FIELD_CONTROL_CLASS,
|
||||||
onChange={(event) => {
|
)}
|
||||||
setDescription(event.target.value);
|
data-testid="create-channel-description"
|
||||||
setErrorMessage(null);
|
disabled={isCreating}
|
||||||
}}
|
id="create-channel-description"
|
||||||
placeholder={`What this ${kindLabel} is for`}
|
onChange={(event) => {
|
||||||
rows={2}
|
setDescription(event.target.value);
|
||||||
value={description}
|
setErrorMessage(null);
|
||||||
/>
|
}}
|
||||||
|
placeholder={`What this ${kindLabel} is for`}
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Type */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<p className="text-sm font-medium text-foreground">Type</p>
|
||||||
<label
|
<Tabs
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
className="w-full"
|
||||||
htmlFor="create-channel-private"
|
data-testid="create-channel-ephemeral"
|
||||||
>
|
onValueChange={(value) => setEphemeral(value === "temporary")}
|
||||||
<Lock className="h-3.5 w-3.5" />
|
value={ephemeral ? "temporary" : "ongoing"}
|
||||||
Private — only visible to invited members
|
>
|
||||||
</label>
|
<TabsList className="relative grid h-auto w-full grid-cols-2 items-stretch rounded-xl bg-muted/45 p-1 text-muted-foreground">
|
||||||
<Switch
|
<span
|
||||||
checked={visibility === "private"}
|
aria-hidden="true"
|
||||||
data-testid="create-channel-visibility"
|
className="pointer-events-none absolute bottom-1 left-1 top-1 z-0 w-[calc(50%-0.25rem)] rounded-lg bg-background/95 shadow-xs transition-transform duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
|
||||||
disabled={isCreating}
|
data-testid="create-channel-type-indicator"
|
||||||
id="create-channel-private"
|
style={{
|
||||||
onCheckedChange={(checked) =>
|
transform: ephemeral
|
||||||
setVisibility(checked ? "private" : "open")
|
? "translate3d(100%, 0, 0)"
|
||||||
}
|
: "translate3d(0, 0, 0)",
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<TabsTrigger
|
||||||
<label
|
className="group/type-tab relative z-10 h-full min-h-24 flex-col items-start justify-start gap-1.5 whitespace-normal rounded-lg bg-transparent px-3 py-2.5 text-left text-muted-foreground/55 opacity-70 shadow-none transition-[color,opacity] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-muted-foreground hover:opacity-85 data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:opacity-100 data-[state=active]:shadow-none motion-reduce:transition-none"
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
disabled={isCreating}
|
||||||
htmlFor="create-channel-ephemeral"
|
value="ongoing"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-current" />
|
||||||
Ephemeral — auto-archives after 1 day of inactivity
|
<span className="min-w-0 space-y-0.5">
|
||||||
</label>
|
<span className="block text-sm font-medium">Ongoing</span>
|
||||||
<Switch
|
<span className="block text-xs leading-4 text-muted-foreground/45 group-data-[state=active]/type-tab:text-muted-foreground/65">
|
||||||
checked={ephemeral}
|
For projects, teams, and recurring conversations.
|
||||||
disabled={isCreating}
|
</span>
|
||||||
id="create-channel-ephemeral"
|
</span>
|
||||||
onCheckedChange={setEphemeral}
|
</TabsTrigger>
|
||||||
/>
|
<TabsTrigger
|
||||||
</div>
|
aria-label="Ephemeral — auto-archives after 1 day of inactivity"
|
||||||
|
className="group/type-tab relative z-10 h-full min-h-24 flex-col items-start justify-start gap-1.5 whitespace-normal rounded-lg bg-transparent px-3 py-2.5 text-left text-muted-foreground/55 opacity-70 shadow-none transition-[color,opacity] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-muted-foreground hover:opacity-85 data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:opacity-100 data-[state=active]:shadow-none motion-reduce:transition-none"
|
||||||
|
disabled={isCreating}
|
||||||
|
value="temporary"
|
||||||
|
>
|
||||||
|
<ClockFading className="mt-0.5 h-4 w-4 shrink-0 text-current" />
|
||||||
|
<span className="min-w-0 space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium">Temporary</span>
|
||||||
|
<span className="block text-xs leading-4 text-muted-foreground/45 group-data-[state=active]/type-tab:text-muted-foreground/65">
|
||||||
|
For quick discussions that archive automatically when
|
||||||
|
inactive.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<fieldset
|
||||||
|
className="space-y-2"
|
||||||
|
data-testid="create-channel-visibility"
|
||||||
|
>
|
||||||
|
<legend className="text-sm font-medium text-foreground">
|
||||||
|
Permissions
|
||||||
|
</legend>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<PermissionOption
|
||||||
|
description={`Anyone can see and join this ${kindLabel}.`}
|
||||||
|
disabled={isCreating}
|
||||||
|
name="create-channel-visibility"
|
||||||
|
onChange={() => setVisibility("open")}
|
||||||
|
selected={visibility === "open"}
|
||||||
|
title="Open"
|
||||||
|
value="open"
|
||||||
|
/>
|
||||||
|
<PermissionOption
|
||||||
|
description={`Only members can invite people to this ${kindLabel}.`}
|
||||||
|
disabled={isCreating}
|
||||||
|
name="create-channel-visibility"
|
||||||
|
onChange={() => setVisibility("private")}
|
||||||
|
selected={visibility === "private"}
|
||||||
|
title="Private"
|
||||||
|
value="private"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{/* Template Selector */}
|
{/* Template Selector */}
|
||||||
<div className="space-y-1.5">
|
{templates.length > 0 ? (
|
||||||
<label
|
<div className="space-y-1.5">
|
||||||
className="text-sm font-medium text-foreground"
|
<label
|
||||||
htmlFor="create-channel-template"
|
className="text-sm font-medium text-foreground"
|
||||||
>
|
htmlFor="create-channel-template"
|
||||||
Template{" "}
|
>
|
||||||
<span className="font-normal text-muted-foreground">
|
Template{" "}
|
||||||
(optional)
|
<span className="font-normal text-muted-foreground">
|
||||||
</span>
|
(optional)
|
||||||
</label>
|
</span>
|
||||||
<select
|
</label>
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
<select
|
||||||
data-testid="create-channel-template"
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
disabled={isCreating || templates.length === 0}
|
data-testid="create-channel-template"
|
||||||
id="create-channel-template"
|
disabled={isCreating}
|
||||||
onChange={(event) => handleTemplateChange(event.target.value)}
|
id="create-channel-template"
|
||||||
value={selectedTemplateId ?? ""}
|
onChange={(event) => handleTemplateChange(event.target.value)}
|
||||||
>
|
value={selectedTemplateId ?? ""}
|
||||||
<option value="">
|
>
|
||||||
{templatesQuery.isLoading
|
<option value="">No template</option>
|
||||||
? "Loading..."
|
{templates.map((template: ChannelTemplate) => (
|
||||||
: templates.length === 0
|
<option key={template.id} value={template.id}>
|
||||||
? "No templates created yet"
|
{template.name}
|
||||||
: "No template"}
|
</option>
|
||||||
</option>
|
))}
|
||||||
{templates.map((template: ChannelTemplate) => (
|
</select>
|
||||||
<option key={template.id} value={template.id}>
|
</div>
|
||||||
{template.name}
|
) : null}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
@@ -305,3 +369,66 @@ export function CreateChannelDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PermissionOption({
|
||||||
|
description,
|
||||||
|
disabled,
|
||||||
|
name,
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
description: string;
|
||||||
|
disabled: boolean;
|
||||||
|
name: string;
|
||||||
|
onChange: () => void;
|
||||||
|
selected: boolean;
|
||||||
|
title: string;
|
||||||
|
value: ChannelVisibility;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-start gap-3 rounded-lg px-1 py-1.5 transition-none",
|
||||||
|
"has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
|
||||||
|
selected
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
disabled && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked={selected}
|
||||||
|
className="sr-only"
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
onChange={onChange}
|
||||||
|
type="radio"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-none",
|
||||||
|
selected
|
||||||
|
? "border-foreground"
|
||||||
|
: "border-muted-foreground/45 group-hover:border-muted-foreground",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full bg-foreground transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
|
||||||
|
selected ? "scale-100 opacity-100" : "scale-50 opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium text-current">{title}</span>
|
||||||
|
<span className="block text-xs leading-4 text-muted-foreground/65">
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function NewDirectMessageDialog({
|
|||||||
<ProfileAvatar
|
<ProfileAvatar
|
||||||
avatarUrl={user.avatarUrl}
|
avatarUrl={user.avatarUrl}
|
||||||
className="h-5 w-5 text-[10px] shadow-none"
|
className="h-5 w-5 text-[10px] shadow-none"
|
||||||
iconClassName="h-3 w-3"
|
iconClassName="h-4 w-4"
|
||||||
label={formatUserName(user)}
|
label={formatUserName(user)}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{formatUserName(user)}</span>
|
<span className="font-medium">{formatUserName(user)}</span>
|
||||||
@@ -198,7 +198,7 @@ export function NewDirectMessageDialog({
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/60 transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
|
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -74,7 +74,7 @@ const AlertDialogTitle = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
className={cn("text-lg font-semibold tracking-tight", className)}
|
className={cn("text-xl font-semibold tracking-tight", className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
className={cn("flex items-center justify-center text-current")}
|
className={cn("flex items-center justify-center text-current")}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
import {
|
import { DialogContent, DialogHeader, DialogTitle } from "./dialog";
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "./dialog";
|
|
||||||
|
|
||||||
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
|
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
|
||||||
typeof DialogContent
|
typeof DialogContent
|
||||||
@@ -16,6 +11,7 @@ type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
footerClassName?: string;
|
footerClassName?: string;
|
||||||
footerTestId?: string;
|
footerTestId?: string;
|
||||||
|
contentClassName?: string;
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
headerTestId?: string;
|
headerTestId?: string;
|
||||||
scrollAreaClassName?: string;
|
scrollAreaClassName?: string;
|
||||||
@@ -31,7 +27,8 @@ export const ChooserDialogContent = React.forwardRef<
|
|||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
description,
|
contentClassName,
|
||||||
|
description: _description,
|
||||||
footer,
|
footer,
|
||||||
footerClassName,
|
footerClassName,
|
||||||
footerTestId,
|
footerTestId,
|
||||||
@@ -40,11 +37,13 @@ export const ChooserDialogContent = React.forwardRef<
|
|||||||
scrollAreaClassName,
|
scrollAreaClassName,
|
||||||
scrollAreaTestId,
|
scrollAreaTestId,
|
||||||
title,
|
title,
|
||||||
|
"aria-describedby": ariaDescribedBy,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => (
|
) => (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0",
|
"flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0",
|
||||||
className,
|
className,
|
||||||
@@ -53,16 +52,10 @@ export const ChooserDialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<DialogHeader
|
<DialogHeader
|
||||||
className={cn(
|
className={cn("shrink-0 px-6 py-5 pr-14", headerClassName)}
|
||||||
"shrink-0 border-b border-border/60 px-6 py-5 pr-14",
|
|
||||||
headerClassName,
|
|
||||||
)}
|
|
||||||
data-testid={headerTestId}
|
data-testid={headerTestId}
|
||||||
>
|
>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
{description ? (
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
) : null}
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -72,7 +65,7 @@ export const ChooserDialogContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
data-testid={scrollAreaTestId}
|
data-testid={scrollAreaTestId}
|
||||||
>
|
>
|
||||||
<div className="py-5">{children}</div>
|
<div className={cn("py-5", contentClassName)}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{footer ? (
|
{footer ? (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
|
||||||
isDark ? "bg-black/60" : "bg-black/10",
|
isDark ? "bg-black/60" : "bg-black/10",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -32,26 +32,34 @@ const DialogOverlay = React.forwardRef<
|
|||||||
});
|
});
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||||
|
typeof DialogPrimitive.Content
|
||||||
|
> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
DialogContentProps
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
|
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
|
{showCloseButton ? (
|
||||||
<X className="h-4 w-4" />
|
<DialogPrimitive.Close className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
|
||||||
<span className="sr-only">Close</span>
|
<X className="h-4 w-4" />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
) : null}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</div>
|
</div>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
@@ -74,7 +82,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
className={cn("text-lg font-semibold tracking-tight", className)}
|
className={cn("text-xl font-semibold tracking-tight", className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-input/40 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-lg border border-input/40 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const Textarea = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-20 w-full rounded-lg border border-input/40 bg-background px-3 py-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex min-h-20 w-full rounded-lg border border-input/40 bg-background px-3 py-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className,
|
||||||
|
|||||||
Reference in New Issue
Block a user