A social knowledge tool for researchers built on ATProto

feat: header component, layout pages, navbar redesign

+307 -236
+8 -1
src/webapp/app/(authenticated)/collections/layout.tsx
··· 1 + import Header from '@/components/navigation/header/Header'; 1 2 import type { Metadata } from 'next'; 3 + import { Fragment } from 'react'; 2 4 3 5 export const metadata: Metadata = { 4 6 title: 'Collections', ··· 10 12 } 11 13 12 14 export default function Layout(props: Props) { 13 - return props.children; 15 + return ( 16 + <Fragment> 17 + <Header /> 18 + {props.children} 19 + </Fragment> 20 + ); 14 21 }
+3 -85
src/webapp/app/(authenticated)/collections/page.tsx
··· 1 - 'use client'; 2 - 3 - import { useEffect, useState } from 'react'; 4 - import { useRouter } from 'next/navigation'; 5 - import { getAccessToken } from '@/services/auth'; 6 - import { ApiClient } from '@/api-client/ApiClient'; 7 - import type { GetMyCollectionsResponse } from '@/api-client/types'; 8 - import { 9 - Box, 10 - Button, 11 - Group, 12 - Loader, 13 - SimpleGrid, 14 - Stack, 15 - Text, 16 - Title, 17 - } from '@mantine/core'; 18 - import CollectionCard from '@/features/collections/components/collectionCard/CollectionCard'; 19 - 20 - export default function CollectionsPage() { 21 - const [collections, setCollections] = useState< 22 - GetMyCollectionsResponse['collections'] 23 - >([]); 24 - const [loading, setLoading] = useState(true); 25 - const [error, setError] = useState(''); 26 - const router = useRouter(); 27 - 28 - // Create API client instance 29 - const apiClient = new ApiClient( 30 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 31 - () => getAccessToken(), 32 - ); 33 - 34 - useEffect(() => { 35 - const fetchCollections = async () => { 36 - try { 37 - setLoading(true); 38 - const response = await apiClient.getMyCollections({ limit: 50 }); 39 - setCollections(response.collections); 40 - } catch (error: any) { 41 - console.error('Error fetching collections:', error); 42 - setError(error.message || 'Failed to load collections'); 43 - } finally { 44 - setLoading(false); 45 - } 46 - }; 47 - 48 - fetchCollections(); 49 - }, []); 50 - 51 - if (loading) { 52 - return <Loader />; 53 - } 54 - 55 - return ( 56 - <Box> 57 - <Stack> 58 - <Group justify="space-between"> 59 - <Stack gap={0}> 60 - <Title order={1}>Collections</Title> 61 - <Text c={'gray'}>Organize your cards into collections</Text> 62 - </Stack> 63 - <Button onClick={() => router.push('/collections/create')}> 64 - Create Collection 65 - </Button> 66 - </Group> 67 - 68 - {error && <Text c="red">{error}</Text>} 1 + import CollectionsContainer from '@/features/collections/containers/collectionsContainer/CollectionsContainer'; 69 2 70 - {collections.length > 0 ? ( 71 - <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing={'md'}> 72 - {collections.map((collection) => ( 73 - <CollectionCard key={collection.id} collection={collection} /> 74 - ))} 75 - </SimpleGrid> 76 - ) : ( 77 - <Stack align="center"> 78 - <Text>No collections yet</Text> 79 - <Button onClick={() => router.push('/collections/create')}> 80 - Create Your First Collection 81 - </Button> 82 - </Stack> 83 - )} 84 - </Stack> 85 - </Box> 86 - ); 3 + export default function Page() { 4 + return <CollectionsContainer />; 87 5 }
+8 -1
src/webapp/app/(authenticated)/library/layout.tsx
··· 1 + import Header from '@/components/navigation/header/Header'; 1 2 import type { Metadata } from 'next'; 3 + import { Fragment } from 'react'; 2 4 3 5 export const metadata: Metadata = { 4 6 title: 'Library', ··· 10 12 } 11 13 12 14 export default function Layout(props: Props) { 13 - return props.children; 15 + return ( 16 + <Fragment> 17 + <Header /> 18 + {props.children} 19 + </Fragment> 20 + ); 14 21 }
+2 -11
src/webapp/app/(authenticated)/library/page.tsx
··· 1 - import { Anchor, Text } from '@mantine/core'; 2 - import Link from 'next/link'; 1 + import LibraryContainer from '@/features/library/containers/libraryContainer/LibraryContainer'; 3 2 4 3 export default function Page() { 5 - return ( 6 - <Text> 7 - Under construction. Check{' '} 8 - <Anchor component={Link} href={'/my-cards'}> 9 - My Cards 10 - </Anchor>{' '} 11 - in the meantime 12 - </Text> 13 - ); 4 + return <LibraryContainer />; 14 5 }
+8 -1
src/webapp/app/(authenticated)/my-cards/layout.tsx
··· 1 + import Header from '@/components/navigation/header/Header'; 1 2 import type { Metadata } from 'next'; 3 + import { Fragment } from 'react'; 2 4 3 5 export const metadata: Metadata = { 4 6 title: 'My Cards', ··· 10 12 } 11 13 12 14 export default function Layout(props: Props) { 13 - return props.children; 15 + return ( 16 + <Fragment> 17 + <Header /> 18 + {props.children} 19 + </Fragment> 20 + ); 14 21 }
+3 -82
src/webapp/app/(authenticated)/my-cards/page.tsx
··· 1 - 'use client'; 2 - 3 - import { useEffect, useState, useMemo, useCallback } from 'react'; 4 - import { useRouter } from 'next/navigation'; 5 - import { ApiClient } from '@/api-client/ApiClient'; 6 - import { getAccessToken } from '@/services/auth'; 7 - import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 8 - import { Button, Loader, Stack, Text, Grid } from '@mantine/core'; 9 - import type { GetMyUrlCardsResponse } from '@/api-client/types'; 10 - 11 - export default function DashboardPage() { 12 - const [urlCards, setUrlCards] = useState<GetMyUrlCardsResponse['cards']>([]); 13 - const [loading, setLoading] = useState(true); 14 - const [cardsLoading, setCardsLoading] = useState(true); 15 - const router = useRouter(); 1 + import MyCardsContainer from '@/features/cards/containers/myCardsContainer/MyCardsContainer'; 16 2 17 - // Memoize API client instance to prevent recreation on every render 18 - const apiClient = useMemo( 19 - () => 20 - new ApiClient( 21 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 22 - () => getAccessToken(), 23 - ), 24 - [], 25 - ); 26 - 27 - // Memoize the fetch function to prevent useEffect from running on every render 28 - const fetchData = useCallback(async () => { 29 - try { 30 - // Fetch URL cards 31 - setCardsLoading(true); 32 - const cardsResponse = await apiClient.getMyUrlCards({ limit: 10 }); 33 - setUrlCards(cardsResponse.cards); 34 - } catch (error) { 35 - console.error('Error fetching data:', error); 36 - } finally { 37 - setLoading(false); 38 - setCardsLoading(false); 39 - } 40 - }, [apiClient]); 41 - 42 - useEffect(() => { 43 - fetchData(); 44 - }, [fetchData]); 45 - 46 - if (loading) { 47 - return <Loader />; 48 - } 49 - 50 - return ( 51 - <div> 52 - {/* Recent Cards Section */} 53 - <Stack> 54 - {cardsLoading ? ( 55 - <Loader /> 56 - ) : urlCards.length > 0 ? ( 57 - <Grid gutter={'md'} grow> 58 - {urlCards.map((card) => ( 59 - <Grid.Col 60 - key={card.id} 61 - span={{ base: 12, xs: 6, sm: 2, lg: 2, xl: 2 }} 62 - > 63 - <UrlCard 64 - id={card.id} 65 - url={card.url} 66 - cardContent={card.cardContent} 67 - note={card.note} 68 - collections={card.collections} 69 - /> 70 - </Grid.Col> 71 - ))} 72 - </Grid> 73 - ) : ( 74 - <Stack align="center" gap={'xs'}> 75 - <Text c={'grey'}>No cards yet</Text> 76 - <Button onClick={() => router.push('/cards/add')}> 77 - Add Your First Card 78 - </Button> 79 - </Stack> 80 - )} 81 - </Stack> 82 - </div> 83 - ); 3 + export default function Page() { 4 + return <MyCardsContainer />; 84 5 }
+15
src/webapp/components/navigation/NavbarToggle.tsx
··· 1 + 'use client'; 2 + 3 + import { useNavbarContext } from '@/providers/navbar'; 4 + import { ActionIcon } from '@mantine/core'; 5 + import { FiSidebar } from 'react-icons/fi'; 6 + 7 + export default function NavbarToggle() { 8 + const { toggle } = useNavbarContext(); 9 + 10 + return ( 11 + <ActionIcon variant="light" color="gray" size={'lg'} onClick={toggle}> 12 + <FiSidebar /> 13 + </ActionIcon> 14 + ); 15 + }
+5 -7
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 1 - import { useDisclosure } from '@mantine/hooks'; 2 1 import { AppShell } from '@mantine/core'; 3 - import Header from '@/components/navigation/header/Header'; 4 2 import Navbar from '@/components/navigation/navbar/Navbar'; 5 3 import ComposerDrawer from '@/features/composer/components/composerDrawer/ComposerDrawer'; 4 + import { useNavbarContext } from '@/providers/navbar'; 6 5 7 6 interface Props { 8 7 children: React.ReactNode; 9 8 } 10 9 11 10 export default function AppLayout(props: Props) { 12 - const [opened, { toggle }] = useDisclosure(); 11 + const { opened } = useNavbarContext(); 13 12 14 13 return ( 15 14 <AppShell 16 - header={{ height: 60 }} 15 + header={{ height: 0 }} 17 16 navbar={{ 18 17 width: 300, 19 - breakpoint: 'xs', 18 + breakpoint: 0, 20 19 collapsed: { mobile: !opened, desktop: opened }, 21 20 }} 22 - padding="md" 23 21 > 24 - <Header onToggleNavbar={toggle} /> 22 + {/*<Header />*/} 25 23 <Navbar /> 26 24 27 25 <AppShell.Main>
+21
src/webapp/components/navigation/backButton/BackButton.tsx
··· 1 + 'use client'; 2 + 3 + import { useRouter } from 'next/navigation'; 4 + import { ActionIcon } from '@mantine/core'; 5 + import { BiSolidLeftArrowAlt } from 'react-icons/bi'; 6 + 7 + export default function BackButton() { 8 + const router = useRouter(); 9 + 10 + return ( 11 + <ActionIcon 12 + variant="light" 13 + color="gray" 14 + size={'lg'} 15 + radius={'xl'} 16 + onClick={() => router.back()} 17 + > 18 + <BiSolidLeftArrowAlt /> 19 + </ActionIcon> 20 + ); 21 + }
+10 -34
src/webapp/components/navigation/header/Header.tsx
··· 1 - import { 2 - ActionIcon, 3 - Anchor, 4 - AppShellHeader, 5 - Group, 6 - Image, 7 - } from '@mantine/core'; 8 - import SembleLogo from '@/assets/semble-logo.svg'; 9 - import { FiSidebar } from 'react-icons/fi'; 10 - import ProfileMenu from '@/features/profile/components/profileMenu/ProfileMenu'; 11 - import Link from 'next/link'; 1 + import { Box, Divider, Group } from '@mantine/core'; 2 + import NavbarToggle from '../NavbarToggle'; 3 + import BackButton from '../backButton/BackButton'; 12 4 13 - interface Props { 14 - onToggleNavbar: () => void; 15 - } 16 - 17 - export default function Header(props: Props) { 5 + export default function Header() { 18 6 return ( 19 - <AppShellHeader withBorder={false}> 20 - <Group h="100%" px="md" gap={'xs'} justify="space-between"> 21 - <Group> 22 - <Anchor component={Link} href={'/library'}> 23 - <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 24 - </Anchor> 25 - <ActionIcon 26 - variant="subtle" 27 - color="gray" 28 - size={'lg'} 29 - radius={'xl'} 30 - onClick={props.onToggleNavbar} 31 - > 32 - <FiSidebar size={22} /> 33 - </ActionIcon> 34 - </Group> 35 - <ProfileMenu /> 7 + <Box> 8 + <Group gap={'xs'} p={'xs'} justify="space-between"> 9 + <BackButton /> 10 + <NavbarToggle /> 36 11 </Group> 37 - </AppShellHeader> 12 + <Divider /> 13 + </Box> 38 14 ); 39 15 }
+16 -11
src/webapp/components/navigation/navbar/Navbar.tsx
··· 6 6 ScrollArea, 7 7 Divider, 8 8 Stack, 9 + Group, 10 + Anchor, 11 + Image, 9 12 } from '@mantine/core'; 10 13 import { LuLibrary } from 'react-icons/lu'; 11 14 import { MdOutlineEmojiNature } from 'react-icons/md'; 12 15 import { FaRegNoteSticky } from 'react-icons/fa6'; 16 + import Link from 'next/link'; 17 + import SembleLogo from '@/assets/semble-logo.svg'; 18 + import ProfileMenu from '@/features/profile/components/profileMenu/ProfileMenu'; 13 19 14 20 export default function Navbar() { 15 21 return ( 16 - <AppShellNavbar withBorder={false}> 17 - <AppShellSection 18 - grow 19 - component={ScrollArea} 20 - px={'md'} 21 - pb={'md'} 22 - pt={'xs'} 23 - > 24 - <Stack gap={5}> 22 + <AppShellNavbar px={'md'} pb={'md'} pt={'xs'}> 23 + <Group justify="space-between" ml={'sm'}> 24 + <Anchor component={Link} href={'/library'}> 25 + <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 26 + </Anchor> 27 + <ProfileMenu /> 28 + </Group> 29 + 30 + <AppShellSection grow component={ScrollArea}> 31 + <Stack gap={5} mt={'lg'}> 25 32 <NavItem 26 33 href="/library" 27 34 label="Library" ··· 42 49 <Divider my={'sm'} /> 43 50 <CollectionsNavList /> 44 51 </AppShellSection> 45 - 46 - {/*<AppShellSection p={'md'}></AppShellSection>*/} 47 52 </AppShellNavbar> 48 53 ); 49 54 }
src/webapp/components/navigation/navbarToggle

This is a binary file and will not be displayed.

+68
src/webapp/features/cards/containers/myCardsContainer/MyCardsContainer.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + Alert, 5 + Container, 6 + Grid, 7 + Stack, 8 + Title, 9 + Button, 10 + Text, 11 + Loader, 12 + } from '@mantine/core'; 13 + import useMyCards from '../../lib/queries/useMyCards'; 14 + import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 15 + import { BiPlus } from 'react-icons/bi'; 16 + import Link from 'next/link'; 17 + 18 + export default function MyCardsContainer() { 19 + const { data, error, isPending } = useMyCards(); 20 + 21 + return ( 22 + <Container p={'xs'} fluid> 23 + <Stack> 24 + <Title order={1}>My Cards</Title> 25 + 26 + {(isPending || !data) && <Loader />} 27 + {error && ( 28 + <Alert variant="white" color="red" title="Could not load cards" /> 29 + )} 30 + {data && data.cards.length > 0 && ( 31 + <Grid gutter={'md'} grow> 32 + {data.cards.map((card) => ( 33 + <Grid.Col 34 + key={card.id} 35 + span={{ base: 12, xs: 6, sm: 2, lg: 2, xl: 2 }} 36 + > 37 + <UrlCard 38 + id={card.id} 39 + url={card.url} 40 + cardContent={card.cardContent} 41 + note={card.note} 42 + collections={card.collections} 43 + /> 44 + </Grid.Col> 45 + ))} 46 + </Grid> 47 + )} 48 + {data && data.cards.length === 0 && ( 49 + <Stack align="center" gap={'xs'}> 50 + <Text fz={'h3'} fw={600} c={'gray'}> 51 + No cards 52 + </Text> 53 + <Button 54 + component={Link} 55 + href={'/cards/add'} 56 + variant="light" 57 + color={'gray'} 58 + size="md" 59 + rightSection={<BiPlus size={22} />} 60 + > 61 + Add your first card 62 + </Button> 63 + </Stack> 64 + )} 65 + </Stack> 66 + </Container> 67 + ); 68 + }
+23
src/webapp/features/cards/lib/queries/useMyCards.tsx
··· 1 + import { ApiClient } from '@/api-client/ApiClient'; 2 + import { getAccessToken } from '@/services/auth'; 3 + import { useQuery } from '@tanstack/react-query'; 4 + 5 + export default function useMyCards() { 6 + const apiClient = new ApiClient( 7 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 + () => getAccessToken(), 9 + ); 10 + 11 + const { data, error, isPending } = useQuery({ 12 + queryKey: ['my cards'], 13 + queryFn: async () => { 14 + const cards = await apiClient.getMyUrlCards({ limit: 10 }); 15 + if (!cards) { 16 + throw new Error('Could not get my cards'); 17 + } 18 + return cards; 19 + }, 20 + }); 21 + 22 + return { data, error, isPending }; 23 + }
+62
src/webapp/features/collections/containers/collectionsContainer/CollectionsContainer.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + Button, 5 + Container, 6 + Stack, 7 + Title, 8 + Text, 9 + Loader, 10 + Alert, 11 + SimpleGrid, 12 + } from '@mantine/core'; 13 + import useCollections from '../../lib/queries/useCollections'; 14 + import Link from 'next/link'; 15 + import { BiPlus } from 'react-icons/bi'; 16 + import CollectionCard from '../../components/collectionCard/CollectionCard'; 17 + 18 + export default function CollectionsContainer() { 19 + const { data, error, isPending } = useCollections(); 20 + 21 + return ( 22 + <Container p={'xs'} fluid> 23 + <Stack> 24 + <Title order={1}>Collections</Title> 25 + 26 + {(isPending || !data) && <Loader />} 27 + {error && ( 28 + <Alert 29 + variant="white" 30 + color="red" 31 + title="Could not load collections" 32 + /> 33 + )} 34 + <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing={'md'}> 35 + {data && 36 + data.collections && 37 + data.collections.length > 0 && 38 + data.collections.map((c) => ( 39 + <CollectionCard key={c.id} collection={c} /> 40 + ))} 41 + </SimpleGrid> 42 + {data && data.collections.length === 0 && ( 43 + <Stack align="center" gap={'xs'}> 44 + <Text fz={'h3'} fw={600} c={'gray'}> 45 + No collections 46 + </Text> 47 + <Button 48 + component={Link} 49 + href={'/collections/create'} 50 + variant="light" 51 + color={'gray'} 52 + size="md" 53 + rightSection={<BiPlus size={22} />} 54 + > 55 + Create your first collection 56 + </Button> 57 + </Stack> 58 + )} 59 + </Stack> 60 + </Container> 61 + ); 62 + }
+11
src/webapp/features/library/containers/libraryContainer/LibraryContainer.tsx
··· 1 + import { Container, Stack, Title } from '@mantine/core'; 2 + 3 + export default function LibraryContainer() { 4 + return ( 5 + <Container p={'xs'} fluid> 6 + <Stack> 7 + <Title order={1}>Library</Title> 8 + </Stack> 9 + </Container> 10 + ); 11 + }
+2 -2
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
··· 28 28 }; 29 29 30 30 if (isPending || !data) { 31 - return <Skeleton w={38} h={38} radius={'md'} />; 31 + return <Skeleton w={38} h={38} radius={'md'} ml={4} />; 32 32 } 33 33 34 34 if (error) { ··· 39 39 <Group> 40 40 <Menu shadow="sm" width={280}> 41 41 <Menu.Target> 42 - <Avatar src={data.avatarUrl} style={{ cursor: 'pointer' }} /> 42 + <Avatar src={data.avatarUrl} style={{ cursor: 'pointer' }} ml={4} /> 43 43 </Menu.Target> 44 44 <Menu.Dropdown> 45 45 <Menu.Item component="a" href="/profile">
+4 -1
src/webapp/providers/index.tsx
··· 3 3 import { AuthProvider } from '@/hooks/useAuth'; 4 4 import MantineProvider from './mantine'; 5 5 import TanStackQueryProvider from './tanstack'; 6 + import { NavbarProvider } from './navbar'; 6 7 7 8 interface Props { 8 9 children: React.ReactNode; ··· 12 13 return ( 13 14 <TanStackQueryProvider> 14 15 <AuthProvider> 15 - <MantineProvider>{props.children}</MantineProvider> 16 + <MantineProvider> 17 + <NavbarProvider>{props.children}</NavbarProvider> 18 + </MantineProvider> 16 19 </AuthProvider> 17 20 </TanStackQueryProvider> 18 21 );
+38
src/webapp/providers/navbar.tsx
··· 1 + 'use client'; 2 + 3 + import React, { createContext, useContext } from 'react'; 4 + import { useDisclosure } from '@mantine/hooks'; 5 + 6 + interface NavbarContext { 7 + opened: boolean; 8 + toggle: () => void; 9 + } 10 + 11 + const NavbarContext = createContext<NavbarContext>({ 12 + opened: true, 13 + toggle: () => {}, 14 + }); 15 + 16 + interface ProviderProps { 17 + children: React.ReactNode; 18 + } 19 + 20 + export function NavbarProvider(props: ProviderProps) { 21 + const [opened, { toggle }] = useDisclosure(); 22 + 23 + return ( 24 + <NavbarContext.Provider value={{ opened, toggle }}> 25 + {props.children} 26 + </NavbarContext.Provider> 27 + ); 28 + } 29 + 30 + export function useNavbarContext() { 31 + const context = useContext(NavbarContext); 32 + 33 + if (!context) { 34 + throw new Error('useNavbarContext must be used within a NavbarProvider'); 35 + } 36 + 37 + return context; 38 + }