diff --git a/.vscode/cspell.json b/.vscode/cspell.json index ec0264db..2f2e3597 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -22,6 +22,7 @@ "nuxt", "nuxthq", "nuxtjs", + "partialize", "pinia", "Pinia", "planetscale", @@ -30,6 +31,7 @@ "ratelimit", "Ratelimiter", "RPID", + "shadcn", "Shortcode", "simplewebauthn", "starttls", @@ -49,6 +51,7 @@ "unvalidated", "vueuse", "waitlist", + "zustand", "zxcvbn" ], "ignoreWords": ["ABCDEFGHJKMNPQRSTVWXYZ"], diff --git a/apps/web/package.json b/apps/web/package.json index a0698d48..653c6e6f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,8 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/themes": "^3.0.2", "@simplewebauthn/browser": "^10.0.0", diff --git a/apps/web/src/app/[orgShortCode]/_components/atoms.ts b/apps/web/src/app/[orgShortCode]/_components/atoms.ts new file mode 100644 index 00000000..365b84a7 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/_components/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const sidebarSubmenuOpenAtom = atom(false); diff --git a/apps/web/src/app/[orgShortCode]/_components/sidebar-content.tsx b/apps/web/src/app/[orgShortCode]/_components/sidebar-content.tsx new file mode 100644 index 00000000..3b92bd08 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/_components/sidebar-content.tsx @@ -0,0 +1,425 @@ +'use client'; + +import useLoading from '@/src/hooks/use-loading'; +import { cn, generateAvatarUrl, getInitials } from '@/src/lib/utils'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { + Avatar, + AvatarFallback, + AvatarImage +} from '@/src/components/shadcn-ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuPortal, + DropdownMenuGroup +} from '@/src/components/shadcn-ui/dropdown-menu'; + +import { + Check, + AddressBook, + SignOut, + ChatCircle, + MoonStars, + Gear, + SpinnerGap, + Sun, + CaretRight, + CaretLeft, + SquaresFour, + Shield, + CaretUp, + Book, + QuestionMark, + MapPin, + Activity, + Megaphone, + PushPin, + CaretDoubleLeft, + CaretUpDown, + Plus, + Palette, + Monitor, + Question, + Paperclip, + User +} from '@phosphor-icons/react'; +import { env } from 'next-runtime-env'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMemo } from 'react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { SidebarNavButton } from './sidebar-nav-button'; +import { useTheme } from 'next-themes'; +import { sidebarSubmenuOpenAtom } from './atoms'; +import { useAtom } from 'jotai'; +import { usePreferencesState } from '@/src/stores/preferences-store'; +import { + ToggleGroup, + ToggleGroupItem +} from '@/src/components/shadcn-ui/toggle-group'; + +export default function SidebarContent() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + return ( +
+ +
+
+ + Spaces + + +
+ +
+ My personal space + +
+
+
+ + UnInbox + + v0.1.23 +
+
+ ); +} + +const PLATFORM_URL = env('NEXT_PUBLIC_PLATFORM_URL'); + +function OrgMenu() { + const setCurrentOrg = useGlobalStore((state) => state.setCurrentOrg); + const currentOrg = useGlobalStore((state) => state.currentOrg); + const username = useGlobalStore((state) => state.user.username); + const orgs = useGlobalStore((state) => state.orgs); + const queryClient = useQueryClient(); + const [, setSidebarSubmenuOpen] = useAtom(sidebarSubmenuOpenAtom); + + const { theme, setTheme } = useTheme(); + const router = useRouter(); + + const orgAvatarUrl = useMemo( + () => + generateAvatarUrl({ + publicId: currentOrg.publicId, + avatarTimestamp: currentOrg.avatarTimestamp, + size: '5xl' + }), + [currentOrg.publicId, currentOrg.avatarTimestamp] + ); + + const userAvatarUrl = useMemo( + () => + generateAvatarUrl({ + publicId: currentOrg.orgMemberProfile.publicId, + avatarTimestamp: currentOrg.orgMemberProfile.avatarTimestamp, + size: '5xl' + }), + [ + currentOrg.orgMemberProfile.publicId, + currentOrg.orgMemberProfile.avatarTimestamp + ] + ); + + const { run: logOut, loading: loggingOut } = useLoading( + async () => { + await fetch(`${PLATFORM_URL}/auth/logout`, { + method: 'POST', + credentials: 'include' + }); + queryClient.removeQueries(); + router.replace('/'); + }, + { + onError: (error) => { + if (error) toast.error(error.message); + } + } + ); + + const displayName = + `${currentOrg.orgMemberProfile.firstName ?? username} ${currentOrg.orgMemberProfile.lastName}`.trim(); + + return ( +
+ setSidebarSubmenuOpen(open)}> + +
+ + + + {getInitials(currentOrg.name)} + + +
+ + {currentOrg.name} + + + {displayName} + +
+
+ +
+ +
+
+ + +
+ + Signed in as + +
+ + + {getInitials(displayName)} + +
+ + {displayName} + + + @{username} + +
+
+
+
+ + + + + Organizations + + + {orgs.map((org) => ( + +
{ + setCurrentOrg(org.shortCode); + router.push(`/${org.shortCode}/convo`); + }} + className={ + 'flex w-full cursor-pointer flex-row items-center justify-between gap-2' + }> +
+ + + {getInitials(org.name)} + + + {org.name} + +
+ {org.shortCode === currentOrg.shortCode && } +
+
+ ))} + +
{ + router.push(`/join/org`); + }} + className={ + 'flex w-full cursor-pointer flex-row items-center justify-between gap-2' + }> +
+
+ +
+ + Create Organization + +
+
+
+
+ + + + + + Settings + + + { + event.preventDefault(); + }}> +
+
+ + Theme +
+ + { + setTheme(value); + }}> + + + + + + + + + + +
+
+ + +
+ + Help +
+
+ + + +
+ + Documentation +
+
+ +
+ + Support +
+
+ +
+ + Roadmap +
+
+ +
+ + Status +
+
+ +
+ + Changelog +
+
+
+
+
+
+ + { + setSidebarSubmenuOpen(false); + logOut(); + }} + disabled={loggingOut} + className="focus:bg-red-9 focus:text-slate-1 text-slate-11"> +
+ + Log out +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/[orgShortCode]/_components/sidebar-nav-button.tsx b/apps/web/src/app/[orgShortCode]/_components/sidebar-nav-button.tsx index 99eff898..79d91a41 100644 --- a/apps/web/src/app/[orgShortCode]/_components/sidebar-nav-button.tsx +++ b/apps/web/src/app/[orgShortCode]/_components/sidebar-nav-button.tsx @@ -15,7 +15,6 @@ export function SidebarNavButton({ icon, isActive, isExpanded, - sidebarCollapsed, disabled, badge, children, @@ -29,7 +28,6 @@ export function SidebarNavButton({ disabled?: boolean; badge?: ReactNode; children?: ReactNode; - sidebarCollapsed: boolean; }) { const [expanded, setExpanded] = useState(isExpanded); const [active, setActive] = useState(false); @@ -56,7 +54,6 @@ export function SidebarNavButton({
- + {label} {badge && {badge}} diff --git a/apps/web/src/app/[orgShortCode]/_components/sidebar.tsx b/apps/web/src/app/[orgShortCode]/_components/sidebar.tsx index b2f19ca6..6bc81204 100644 --- a/apps/web/src/app/[orgShortCode]/_components/sidebar.tsx +++ b/apps/web/src/app/[orgShortCode]/_components/sidebar.tsx @@ -1,369 +1,105 @@ 'use client'; -import useLoading from '@/src/hooks/use-loading'; -import { cn, generateAvatarUrl, getInitials } from '@/src/lib/utils'; -import { useGlobalStore } from '@/src/providers/global-store-provider'; -import { Button } from '@/src/components/shadcn-ui/button'; +import { cn } from '@/src/lib/utils'; +import { usePreferencesState } from '@/src/stores/preferences-store'; import { - Avatar, - AvatarFallback, - AvatarImage -} from '@/src/components/shadcn-ui/avatar'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuPortal, - DropdownMenuGroup -} from '@/src/components/shadcn-ui/dropdown-menu'; - -import { - Check, - AddressBook, - SignOut, - ChatCircle, - MoonStars, - Gear, - SpinnerGap, - Sun, - CaretRight, - CaretLeft, - SquaresFour, - Shield, - CaretUp, - Book, - QuestionMark, - MapPin, - Activity, - Megaphone + CaretDoubleLeft, + CaretLineRight, + Cross, + PushPin, + X } from '@phosphor-icons/react'; -import { env } from 'next-runtime-env'; -import { useTheme } from 'next-themes'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import { useQueryClient } from '@tanstack/react-query'; -import React from 'react'; -import { SidebarNavButton } from './sidebar-nav-button'; +import SidebarContent from './sidebar-content'; +import { sidebarSubmenuOpenAtom } from './atoms'; +import { useAtom } from 'jotai'; +import { useEffect } from 'react'; +import { useIsMobile } from '@/src/hooks/is-mobile'; export default function Sidebar() { - const [collapsed, setCollapsed] = useState(false); - const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + const { + sidebarDocked, + sidebarExpanded, + setSidebarExpanded, + setSidebarDocking + } = usePreferencesState(); + const [sidebarSubmenuOpen] = useAtom(sidebarSubmenuOpenAtom); + const isMobile = useIsMobile(); + useEffect(() => { + setSidebarExpanded(true); + + setTimeout(() => { + setSidebarExpanded(false); + }, 1000); + }, [setSidebarExpanded]); return (
-
- - {collapsed ? 'Un' : 'UnInbox'} - - -
- } - isActive={false} - isExpanded={true} - label="My Personal Space" - sidebarCollapsed={collapsed}> - } - isActive={false} - label="Conversations" - sidebarCollapsed={collapsed}> - - } - isActive={false} - label="Contacts" - disabled - badge="Soon" - sidebarCollapsed={collapsed} - /> - } - isActive={false} - label="Screener" - disabled - badge="Soon" - sidebarCollapsed={collapsed} - /> - -
- -
- ); -} - -const PLATFORM_URL = env('NEXT_PUBLIC_PLATFORM_URL'); - -function OrgMenu({ collapsed }: { collapsed: boolean }) { - const setCurrentOrg = useGlobalStore((state) => state.setCurrentOrg); - const currentOrg = useGlobalStore((state) => state.currentOrg); - const username = useGlobalStore((state) => state.user.username); - const orgs = useGlobalStore((state) => state.orgs); - const queryClient = useQueryClient(); - - const { setTheme, resolvedTheme } = useTheme(); - const router = useRouter(); - - const orgAvatarUrl = useMemo( - () => - generateAvatarUrl({ - publicId: currentOrg.publicId, - avatarTimestamp: currentOrg.avatarTimestamp, - size: '5xl' - }), - [currentOrg.publicId, currentOrg.avatarTimestamp] - ); - - const userAvatarUrl = useMemo( - () => - generateAvatarUrl({ - publicId: currentOrg.orgMemberProfile.publicId, - avatarTimestamp: currentOrg.orgMemberProfile.avatarTimestamp, - size: '5xl' - }), - [ - currentOrg.orgMemberProfile.publicId, - currentOrg.orgMemberProfile.avatarTimestamp - ] - ); - - const { run: logOut, loading: loggingOut } = useLoading( - async () => { - await fetch(`${PLATFORM_URL}/auth/logout`, { - method: 'POST', - credentials: 'include' - }); - queryClient.removeQueries(); - router.replace('/'); - }, - { - onError: (error) => { - if (error) toast.error(error.message); - } - } - ); - - const displayName = - `${currentOrg.orgMemberProfile.firstName ?? username} ${currentOrg.orgMemberProfile.lastName}`.trim(); - - return ( -
- - -
-
- - - - {getInitials(currentOrg.name)} - - - - {currentOrg.name} - -
-
- + 'absolute z-[100] m-0 flex h-full flex-row items-start justify-center gap-0 p-2 transition-all duration-1000 ease-in-out', + !isMobile && sidebarDocked ? 'left-0 w-60 pr-0' : 'w-[252px] pr-3', + !sidebarDocked || isMobile + ? sidebarExpanded + ? 'left-0' + : '-left-[232px]' + : '' + )} + onMouseEnter={() => { + setSidebarExpanded(true); + }} + onMouseLeave={() => { + !sidebarSubmenuOpen && !isMobile && setSidebarExpanded(false); + }} + onFocus={() => { + setSidebarExpanded(true); + }}> + + {!isMobile && ( +
+
setSidebarDocking(!sidebarDocked)}> +
+ {sidebarDocked ? ( + + ) : ( + + )} +
- - - -
- - Signed in as - -
- - - {getInitials(displayName)} - -
- - {displayName} - - - @{username} - -
+ )} + {isMobile && ( +
+
setSidebarExpanded(false)}> +
+
- - -
- {orgs.map((org) => ( - - - - ))}
- - - - - Settings - - - - { - setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); - }}> -
- - {resolvedTheme === 'dark' ? 'Light Mode' : 'Dark Mode'} - - {resolvedTheme === 'dark' ? ( - - ) : ( - - )} -
-
- - Help - - - -
- Documentation - -
-
- -
- Support - -
-
- -
- Roadmap - -
-
- -
- Status - -
-
- -
- Changelog - -
-
-
-
-
-
- - - - - - + )} +
); } diff --git a/apps/web/src/app/[orgShortCode]/convo/[convoId]/_components/context-panel.tsx b/apps/web/src/app/[orgShortCode]/convo/[convoId]/_components/context-panel.tsx index 991d5969..78b7d9a3 100644 --- a/apps/web/src/app/[orgShortCode]/convo/[convoId]/_components/context-panel.tsx +++ b/apps/web/src/app/[orgShortCode]/convo/[convoId]/_components/context-panel.tsx @@ -24,7 +24,7 @@ export function ContextPanel({ const [participantOpen, setParticipantOpen] = useState(false); return ( -
+
( -
+
{index === firstItemIndex && hasNextPage ? ( -
+
Loading...
@@ -95,20 +95,22 @@ export function MessagesPanel({ Loading...
) : ( - - { - if (isFetchingNextPage || !hasNextPage) return; - void fetchNextPage(); - }} - data={allMessages} - initialTopMostItemIndex={Math.max(0, allMessages.length - 1)} - firstItemIndex={firstItemIndex} - itemContent={itemRenderer} - customScrollParent={scrollParent ?? undefined} - style={{ overscrollBehavior: 'contain' }} - /> - +
+ + { + if (isFetchingNextPage || !hasNextPage) return; + void fetchNextPage(); + }} + data={allMessages} + initialTopMostItemIndex={Math.max(0, allMessages.length - 1)} + firstItemIndex={firstItemIndex} + itemContent={itemRenderer} + customScrollParent={scrollParent ?? undefined} + style={{ overscrollBehavior: 'contain' }} + /> + +
); } @@ -142,7 +144,7 @@ function MessageItem({ return (
+
{isLoading ? (
Loading...
) : ( diff --git a/apps/web/src/app/[orgShortCode]/convo/layout.tsx b/apps/web/src/app/[orgShortCode]/convo/layout.tsx index 9830fa37..28e4e323 100644 --- a/apps/web/src/app/[orgShortCode]/convo/layout.tsx +++ b/apps/web/src/app/[orgShortCode]/convo/layout.tsx @@ -1,13 +1,24 @@ 'use client'; +import { Button } from '@/src/components/shadcn-ui/button'; import ConvoList from './_components/convo-list'; +import { usePreferencesState } from '@/src/stores/preferences-store'; export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) { + const { + sidebarDocked, + sidebarExpanded, + setSidebarExpanded, + setSidebarDocking + } = usePreferencesState(); return ( -
- -
{children}
+
+
+ {/* */} + +
+
{children}
); } diff --git a/apps/web/src/app/[orgShortCode]/layout.tsx b/apps/web/src/app/[orgShortCode]/layout.tsx index 3e25a181..120207c8 100644 --- a/apps/web/src/app/[orgShortCode]/layout.tsx +++ b/apps/web/src/app/[orgShortCode]/layout.tsx @@ -64,8 +64,8 @@ export default function Layout({ return ( -
-
+
+
{children}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e1581ac1..c3546a7d 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -43,7 +43,11 @@ export default function RootLayout({ + className={cn( + inter.variable, + calSans.variable, + 'h-full max-h-svh overflow-hidden font-sans' + )}> @@ -157,7 +157,7 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/apps/web/src/components/shadcn-ui/toggle-group.tsx b/apps/web/src/components/shadcn-ui/toggle-group.tsx new file mode 100644 index 00000000..4669dc46 --- /dev/null +++ b/apps/web/src/components/shadcn-ui/toggle-group.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'; +import { type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/src/lib/utils'; +import { toggleVariants } from '@/src/components/shadcn-ui/toggle'; + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: 'default', + variant: 'default' +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/apps/web/src/components/shadcn-ui/toggle.tsx b/apps/web/src/components/shadcn-ui/toggle.tsx new file mode 100644 index 00000000..5adfa378 --- /dev/null +++ b/apps/web/src/components/shadcn-ui/toggle.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as TogglePrimitive from '@radix-ui/react-toggle'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/src/lib/utils'; + +const toggleVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground' + }, + size: { + default: 'h-10 px-3', + xs: 'h-6 px-1.5', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +); + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +export { Toggle, toggleVariants }; diff --git a/apps/web/src/hooks/is-mobile.ts b/apps/web/src/hooks/is-mobile.ts new file mode 100644 index 00000000..51f280ff --- /dev/null +++ b/apps/web/src/hooks/is-mobile.ts @@ -0,0 +1,8 @@ +'use client'; +import { useWindowSize } from '@uidotdev/usehooks'; + +export function useIsMobile(): boolean { + const maxWidth = 768; + const { width } = useWindowSize(); + return width ? width <= maxWidth : true; +} diff --git a/apps/web/src/stores/preferences-store.ts b/apps/web/src/stores/preferences-store.ts new file mode 100644 index 00000000..fce3b3c4 --- /dev/null +++ b/apps/web/src/stores/preferences-store.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type PreferencesStoreState = { + sidebarDocked: boolean; + sidebarExpanded: boolean; +}; +export type PreferencesStoreActions = { + setSidebarDocking: (docked: boolean) => void; + setSidebarExpanded: (expanded: boolean) => void; +}; + +const initialState: PreferencesStoreState = { + sidebarDocked: true, + sidebarExpanded: true +}; + +export const usePreferencesState = create< + PreferencesStoreState & PreferencesStoreActions +>()( + persist( + (set) => ({ + ...initialState, + setSidebarDocking: (docked: boolean) => + set((state) => ({ + ...state, + sidebarDocked: docked + })), + setSidebarExpanded: (expanded: boolean) => + set((state) => ({ + ...state, + sidebarExpanded: expanded + })) + }), + { + name: 'user-preferences', + partialize: (state) => ({ + sidebarDocked: state.sidebarDocked + }) + } + ) +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbfae58d..c20e280f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,12 @@ importers: '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-toggle': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)