A social knowledge tool for researchers built on ATProto

feat: basic app shell

+247 -88
+16 -76
src/webapp/app/(authenticated)/layout.tsx
··· 1 1 'use client'; 2 2 3 3 import { useEffect } from 'react'; 4 - import { usePathname, useRouter } from 'next/navigation'; 4 + import { useRouter } from 'next/navigation'; 5 5 import { useAuth } from '@/hooks/useAuth'; 6 - import { useDisclosure, useMediaQuery } from '@mantine/hooks'; 7 - import { 8 - ActionIcon, 9 - AppShell, 10 - Group, 11 - NavLink, 12 - Text, 13 - Affix, 14 - } from '@mantine/core'; 15 - import { FiSidebar } from 'react-icons/fi'; 16 - import { IoDocumentTextOutline } from 'react-icons/io5'; 17 - import { BsFolder2 } from 'react-icons/bs'; 18 - import { BiUser } from 'react-icons/bi'; 6 + import { useDisclosure } from '@mantine/hooks'; 7 + import { ActionIcon, AppShell, Affix } from '@mantine/core'; 19 8 import { FiPlus } from 'react-icons/fi'; 20 - import { HiOutlineGlobeAlt } from 'react-icons/hi'; 9 + import Header from '@/components/navigation/header/Header'; 10 + import Navbar from '@/components/navigation/navbar/Navbar'; 21 11 22 - export default function AuthenticatedLayout({ 23 - children, 24 - }: { 12 + interface Props { 25 13 children: React.ReactNode; 26 - }) { 27 - const { isAuthenticated, isLoading } = useAuth(); 14 + } 15 + export default function AuthenticatedLayout(props: Props) { 28 16 const router = useRouter(); 29 - 30 - const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); 31 - const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); 32 - const isMobile = useMediaQuery('(max-width: 768px)'); 33 - 34 - const pathname = usePathname(); 17 + const { isAuthenticated, isLoading } = useAuth(); 18 + const [opened, { toggle }] = useDisclosure(); 35 19 36 20 useEffect(() => { 37 21 if (!isLoading && !isAuthenticated) { ··· 49 33 navbar={{ 50 34 width: 300, 51 35 breakpoint: 'sm', 52 - collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, 36 + collapsed: { mobile: !opened, desktop: opened }, 53 37 }} 54 38 padding="md" 55 39 > 56 - <AppShell.Header> 57 - <Group h="100%" px="md" gap={'xs'}> 58 - <ActionIcon 59 - variant="subtle" 60 - size="lg" 61 - onClick={() => { 62 - isMobile ? toggleMobile() : toggleDesktop(); 63 - }} 64 - > 65 - <FiSidebar /> 66 - </ActionIcon> 67 - <Text fw={600}>Semble</Text> 68 - </Group> 69 - </AppShell.Header> 70 - 71 - <AppShell.Navbar p="md"> 72 - <NavLink 73 - href="/explore" 74 - label="Explore" 75 - active={pathname === '/explore'} 76 - leftSection={<HiOutlineGlobeAlt />} 77 - /> 78 - <NavLink 79 - href="/library" 80 - label="My cards" 81 - active={pathname === '/library'} 82 - leftSection={<IoDocumentTextOutline />} 83 - /> 84 - <NavLink 85 - href="/collections" 86 - label="My collections" 87 - active={pathname === '/collections'} 88 - leftSection={<BsFolder2 />} 89 - /> 90 - <NavLink 91 - href="/profile" 92 - label="Profile" 93 - active={pathname === '/profile'} 94 - leftSection={<BiUser />} 95 - mt="auto" 96 - /> 97 - </AppShell.Navbar> 40 + <Header onToggleNavbar={toggle} /> 41 + <Navbar /> 98 42 99 43 <AppShell.Main> 100 - {children} 44 + {props.children} 101 45 <Affix position={{ bottom: 20, right: 20 }}> 102 46 <ActionIcon 103 47 onClick={() => router.push('/cards/add')} 104 - size={56} 48 + size={'input-lg'} 105 49 radius="xl" 106 - color="blue" 107 50 variant="filled" 108 - style={{ 109 - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', 110 - }} 111 51 > 112 - <FiPlus size={24} /> 52 + <FiPlus size={26} /> 113 53 </ActionIcon> 114 54 </Affix> 115 55 </AppShell.Main>
+3 -11
src/webapp/app/layout.tsx
··· 1 1 import { Analytics } from '@vercel/analytics/next'; 2 2 import type { Metadata } from 'next'; 3 - import { 4 - ColorSchemeScript, 5 - mantineHtmlProps, 6 - MantineProvider, 7 - } from '@mantine/core'; 8 - import '@mantine/core/styles.css'; 9 - import { AuthProvider } from '@/hooks/useAuth'; 10 - import { theme } from '@/styles/theme'; 3 + import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core'; 11 4 import { Hanken_Grotesk } from 'next/font/google'; 5 + import Providers from '@/providers'; 12 6 13 7 export const metadata: Metadata = { 14 8 title: { ··· 37 31 <ColorSchemeScript /> 38 32 </head> 39 33 <body> 40 - <MantineProvider theme={theme}> 41 - <AuthProvider>{children}</AuthProvider> 42 - </MantineProvider> 34 + <Providers>{children}</Providers> 43 35 <Analytics /> 44 36 </body> 45 37 </html>
+34
src/webapp/components/navigation/header/Header.tsx
··· 1 + import { ActionIcon, AppShellHeader, Group, Image } from '@mantine/core'; 2 + import SembleLogo from '@/assets/semble-logo.svg'; 3 + import { FiSidebar } from 'react-icons/fi'; 4 + 5 + interface Props { 6 + onToggleNavbar: () => void; 7 + } 8 + 9 + export default function Header(props: Props) { 10 + return ( 11 + <AppShellHeader withBorder={false}> 12 + <Group h="100%" px="md" gap={'xs'} justify="space-between"> 13 + <Group> 14 + <Image 15 + src={SembleLogo.src} 16 + alt="Semble logo" 17 + w={'auto'} 18 + h={28} 19 + ml={'xs'} 20 + /> 21 + <ActionIcon 22 + variant="subtle" 23 + color="gray" 24 + size={'lg'} 25 + radius={'xl'} 26 + onClick={props.onToggleNavbar} 27 + > 28 + <FiSidebar size={22} /> 29 + </ActionIcon> 30 + </Group> 31 + </Group> 32 + </AppShellHeader> 33 + ); 34 + }
+31
src/webapp/components/navigation/navItem/NavItem.tsx
··· 1 + 'use client'; 2 + 3 + import { NavLink } from '@mantine/core'; 4 + import Link from 'next/link'; 5 + import { usePathname } from 'next/navigation'; 6 + 7 + interface Props { 8 + href: string; 9 + label: string; 10 + icon: React.ReactElement; 11 + activeIcon: React.ReactElement; 12 + badge?: number; 13 + } 14 + 15 + export default function NavItem(props: Props) { 16 + const pathname = usePathname(); 17 + const isActive = pathname === props.href; 18 + 19 + return ( 20 + <NavLink 21 + component={Link} 22 + href={props.href} 23 + variant="subtle" 24 + c="gray" 25 + px={'6'} 26 + fw={600} 27 + label={props.label} 28 + leftSection={isActive ? props.activeIcon : props.icon} 29 + /> 30 + ); 31 + }
+43
src/webapp/components/navigation/navbar/Navbar.tsx
··· 1 + import NavItem from '../navItem/NavItem'; 2 + import { 3 + AppShellSection, 4 + AppShellNavbar, 5 + ScrollArea, 6 + Divider, 7 + } from '@mantine/core'; 8 + import { IoDocumentTextOutline } from 'react-icons/io5'; 9 + import { MdOutlineEmojiNature } from 'react-icons/md'; 10 + 11 + export default function Navbar() { 12 + return ( 13 + <AppShellNavbar withBorder={false}> 14 + <AppShellSection 15 + grow 16 + component={ScrollArea} 17 + px={'md'} 18 + pb={'md'} 19 + pt={'xs'} 20 + > 21 + <NavItem 22 + href="/library" 23 + label="Library" 24 + icon={<IoDocumentTextOutline size={25} />} 25 + activeIcon={<IoDocumentTextOutline size={25} />} 26 + /> 27 + <NavItem 28 + href="/explore" 29 + label="Explore" 30 + icon={<MdOutlineEmojiNature size={25} />} 31 + activeIcon={<MdOutlineEmojiNature size={25} />} 32 + /> 33 + 34 + <Divider my={'sm'} /> 35 + {/*<FeedNavList /> 36 + <ListNavList /> 37 + <ChatNavList />*/} 38 + </AppShellSection> 39 + 40 + <AppShellSection p={'md'}></AppShellSection> 41 + </AppShellNavbar> 42 + ); 43 + }
+25
src/webapp/package-lock.json
··· 14 14 "@mantine/form": "^8.1.3", 15 15 "@mantine/hooks": "^8.1.3", 16 16 "@mantine/notifications": "^8.1.3", 17 + "@tanstack/react-query": "^5.85.5", 17 18 "@vercel/analytics": "^1.5.0", 18 19 "date-fns": "^4.1.0", 19 20 "dayjs": "^1.11.13", ··· 7073 7074 }, 7074 7075 "engines": { 7075 7076 "node": ">=14.16" 7077 + } 7078 + }, 7079 + "node_modules/@tanstack/query-core": { 7080 + "version": "5.85.5", 7081 + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", 7082 + "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", 7083 + "funding": { 7084 + "type": "github", 7085 + "url": "https://github.com/sponsors/tannerlinsley" 7086 + } 7087 + }, 7088 + "node_modules/@tanstack/react-query": { 7089 + "version": "5.85.5", 7090 + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", 7091 + "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", 7092 + "dependencies": { 7093 + "@tanstack/query-core": "5.85.5" 7094 + }, 7095 + "funding": { 7096 + "type": "github", 7097 + "url": "https://github.com/sponsors/tannerlinsley" 7098 + }, 7099 + "peerDependencies": { 7100 + "react": "^18 || ^19" 7076 7101 } 7077 7102 }, 7078 7103 "node_modules/@testing-library/dom": {
+1
src/webapp/package.json
··· 30 30 "@mantine/form": "^8.1.3", 31 31 "@mantine/hooks": "^8.1.3", 32 32 "@mantine/notifications": "^8.1.3", 33 + "@tanstack/react-query": "^5.85.5", 33 34 "@vercel/analytics": "^1.5.0", 34 35 "date-fns": "^4.1.0", 35 36 "dayjs": "^1.11.13",
+19
src/webapp/providers/index.tsx
··· 1 + 'use client'; 2 + 3 + import { AuthProvider } from '@/hooks/useAuth'; 4 + import MantineProvider from './mantine'; 5 + import TanStackQueryProvider from './tanstack'; 6 + 7 + interface Props { 8 + children: React.ReactNode; 9 + } 10 + 11 + export default function Providers(props: Props) { 12 + return ( 13 + <TanStackQueryProvider> 14 + <AuthProvider> 15 + <MantineProvider>{props.children}</MantineProvider> 16 + </AuthProvider> 17 + </TanStackQueryProvider> 18 + ); 19 + }
+13
src/webapp/providers/mantine.tsx
··· 1 + 'use client'; 2 + 3 + import { theme } from '@/styles/theme'; 4 + import { MantineProvider as BaseProvider } from '@mantine/core'; 5 + import '@mantine/core/styles.css'; 6 + 7 + interface Props { 8 + children: React.ReactNode; 9 + } 10 + 11 + export default function MantineProvider(props: Props) { 12 + return <BaseProvider theme={theme}>{props.children}</BaseProvider>; 13 + }
+53
src/webapp/providers/tanstack.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + QueryClient, 5 + QueryClientProvider, 6 + defaultShouldDehydrateQuery, 7 + isServer, 8 + } from '@tanstack/react-query'; 9 + 10 + function makeQueryClient() { 11 + return new QueryClient({ 12 + defaultOptions: { 13 + queries: { 14 + staleTime: 60 * 1000, 15 + }, 16 + dehydrate: { 17 + // include pending queries in dehydration 18 + shouldDehydrateQuery: (query) => 19 + defaultShouldDehydrateQuery(query) || 20 + query.state.status === 'pending', 21 + }, 22 + }, 23 + }); 24 + } 25 + 26 + let browserQueryClient: QueryClient | undefined = undefined; 27 + 28 + function getQueryClient() { 29 + if (isServer) { 30 + // Server: always make a new query client 31 + return makeQueryClient(); 32 + } else { 33 + // Browser: make a new query client if we don't already have one 34 + // This is very important, so we don't re-make a new client if React 35 + // suspends during the initial render. This may not be needed if we 36 + // have a suspense boundary BELOW the creation of the query client 37 + if (!browserQueryClient) browserQueryClient = makeQueryClient(); 38 + return browserQueryClient; 39 + } 40 + } 41 + 42 + interface Props { 43 + children: React.ReactNode; 44 + } 45 + export default function TanStackQueryProvider(props: Props) { 46 + const queryClient = getQueryClient(); 47 + 48 + return ( 49 + <QueryClientProvider client={queryClient}> 50 + {props.children} 51 + </QueryClientProvider> 52 + ); 53 + }
+9 -1
src/webapp/styles/theme.tsx
··· 1 1 'use client'; 2 2 3 - import { Button, createTheme, TextInput } from '@mantine/core'; 3 + import { Button, createTheme, NavLink, TextInput } from '@mantine/core'; 4 4 5 5 export const theme = createTheme({ 6 6 primaryColor: 'tangerine', ··· 50 50 defaultProps: { 51 51 radius: 'xl', 52 52 }, 53 + }), 54 + NavLink: NavLink.extend({ 55 + styles: (theme) => ({ 56 + label: { 57 + fontSize: theme.fontSizes.md, 58 + fontWeight: 600, 59 + }, 60 + }), 53 61 }), 54 62 }, 55 63 });