fix dark mode, add member page
This commit is contained in:
		
							parent
							
								
									27cec4e77e
								
							
						
					
					
						commit
						d011c703ed
					
				
					 12 changed files with 298 additions and 31 deletions
				
			
		|  | @ -49,6 +49,22 @@ export interface PartialMember { | ||||||
|   avatar_urls: string[] | null; |   avatar_urls: string[] | null; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface Member extends PartialMember { | ||||||
|  |   bio: string | null; | ||||||
|  |   links: string | null; | ||||||
|  |   names: FieldEntry[]; | ||||||
|  |   pronouns: Pronoun[]; | ||||||
|  |   fields: Field[]; | ||||||
|  |   user: MemberPartialUser; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface MemberPartialUser { | ||||||
|  |   id: string; | ||||||
|  |   name: string; | ||||||
|  |   display_name: string | null; | ||||||
|  |   avatar_urls: string[] | null; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface APIError { | export interface APIError { | ||||||
|   code: ErrorCode; |   code: ErrorCode; | ||||||
|   message?: string; |   message?: string; | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| import type { APIError } from "./entities"; | import type { APIError } from "./entities"; | ||||||
|  | import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||||
| 
 | 
 | ||||||
| export async function apiFetch<T>( | export async function apiFetch<T>( | ||||||
|   path: string, |   path: string, | ||||||
|   { method, body, token }: { method?: string; body?: any; token?: string }, |   { method, body, token }: { method?: string; body?: any; token?: string }, | ||||||
| ) { | ) { | ||||||
|   const apiBase = typeof process !== "undefined" ? process.env.ORIGIN : ""; |  | ||||||
| 
 | 
 | ||||||
|   const resp = await fetch(`${apiBase}/api/v1${path}`, { |   const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, { | ||||||
|     method: method || "GET", |     method: method || "GET", | ||||||
|     headers: { |     headers: { | ||||||
|       ...(token ? { Authorization: token } : {}), |       ...(token ? { Authorization: token } : {}), | ||||||
|  |  | ||||||
							
								
								
									
										28
									
								
								src/lib/components/FallbackImage.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/lib/components/FallbackImage.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   export let urls: string[] | null; | ||||||
|  |   export let alt: string; | ||||||
|  | 
 | ||||||
|  |   const contentTypeFor = (url: string) => { | ||||||
|  |     if (url.endsWith(".webp")) { | ||||||
|  |       return "image/webp"; | ||||||
|  |     } else if (url.endsWith(".jpg") || url.endsWith(".jpeg")) { | ||||||
|  |       return "image/jpeg"; | ||||||
|  |     } else if (url.endsWith(".png")) { | ||||||
|  |       return "image/png"; | ||||||
|  |     } else { | ||||||
|  |       return "application/octet-stream"; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | {#if urls} | ||||||
|  |   <picture class="rounded-circle img-fluid"> | ||||||
|  |     {#each urls as url} | ||||||
|  |       <source width=300 srcSet={url} type={contentTypeFor(url)} /> | ||||||
|  |     {/each} | ||||||
|  |     <img width=300 src={urls[0]} {alt} class="rounded-circle img-fluid" /> | ||||||
|  |   </picture> | ||||||
|  | {:else} | ||||||
|  |   <!-- TODO: actual placeholder that isn't a cat --> | ||||||
|  |   <img width=300 class="rounded-circle img-fluid" src="https://placekitten.com/512/512" {alt} /> | ||||||
|  | {/if} | ||||||
							
								
								
									
										15
									
								
								src/lib/components/PartialMemberCard.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/lib/components/PartialMemberCard.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import type { PartialMember, User } from "$lib/api/entities"; | ||||||
|  |   import FallbackImage from "./FallbackImage.svelte"; | ||||||
|  | 
 | ||||||
|  |   export let user: User; | ||||||
|  |   export let member: PartialMember; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div> | ||||||
|  |   <FallbackImage | ||||||
|  |     urls={member.avatar_urls} | ||||||
|  |     alt="Avatar for {member.name}" | ||||||
|  |   /> | ||||||
|  |   <a class="text-reset" href="/@{user.name}/{member.name}"><h5 class="m-2">{member.display_name ?? member.name}</h5></a> | ||||||
|  | </div> | ||||||
|  | @ -1,6 +1,14 @@ | ||||||
| import { writable } from "svelte/store"; | import { writable } from "svelte/store"; | ||||||
|  | import { browser } from "$app/environment"; | ||||||
| 
 | 
 | ||||||
| import type { MeUser } from "./api/entities"; | import type { MeUser } from "./api/entities"; | ||||||
| 
 | 
 | ||||||
| export const userStore = writable<MeUser | null>(null); | export const userStore = writable<MeUser | null>(null); | ||||||
| export const tokenStore = writable<string | null>(null); | export const tokenStore = writable<string | null>(null); | ||||||
|  | 
 | ||||||
|  | let defaultThemeValue = "dark"; | ||||||
|  | const initialThemeValue = browser | ||||||
|  |   ? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue | ||||||
|  |   : defaultThemeValue; | ||||||
|  | 
 | ||||||
|  | export const themeStore = writable<string>(initialThemeValue); | ||||||
|  |  | ||||||
|  | @ -6,7 +6,9 @@ | ||||||
| 
 | 
 | ||||||
|   import type { PageData } from "./$types"; |   import type { PageData } from "./$types"; | ||||||
|   import StatusIcon from "$lib/components/StatusIcon.svelte"; |   import StatusIcon from "$lib/components/StatusIcon.svelte"; | ||||||
|     import PronounLink from "$lib/components/PronounLink.svelte"; |   import PronounLink from "$lib/components/PronounLink.svelte"; | ||||||
|  |   import PartialMemberCard from "$lib/components/PartialMemberCard.svelte"; | ||||||
|  |   import FallbackImage from "$lib/components/FallbackImage.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  | @ -21,13 +23,13 @@ | ||||||
| <div class="container"> | <div class="container"> | ||||||
|   <div class="grid"> |   <div class="grid"> | ||||||
|     <div class="row"> |     <div class="row"> | ||||||
|       {#if data.avatar_urls} |       <div class="col-md text-center"> | ||||||
|         <div class="col-md" /> |         <FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" /> | ||||||
|       {/if} |       </div> | ||||||
|       <div class="col-md"> |       <div class="col-md"> | ||||||
|         {#if data.display_name} |         {#if data.display_name} | ||||||
|           <h2>{data.display_name}</h2> |           <h2>{data.display_name}</h2> | ||||||
|           <h4>@{data.name}</h4> |           <h5 class="text-body-secondary">@{data.name}</h5> | ||||||
|         {:else} |         {:else} | ||||||
|           <h2>@{data.name}</h2> |           <h2>@{data.name}</h2> | ||||||
|         {/if} |         {/if} | ||||||
|  | @ -64,7 +66,7 @@ | ||||||
|             {#each data.pronouns as pronouns} |             {#each data.pronouns as pronouns} | ||||||
|               <li> |               <li> | ||||||
|                 <StatusIcon status={pronouns.status} /> |                 <StatusIcon status={pronouns.status} /> | ||||||
|                 <PronounLink pronouns={pronouns} /> |                 <PronounLink {pronouns} /> | ||||||
|               </li> |               </li> | ||||||
|             {/each} |             {/each} | ||||||
|           </ul> |           </ul> | ||||||
|  | @ -81,4 +83,17 @@ | ||||||
|       {/each} |       {/each} | ||||||
|     </div> |     </div> | ||||||
|   {/if} |   {/if} | ||||||
|  |   {#if data.members} | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="col"> | ||||||
|  |         <hr /> | ||||||
|  |         <h2>Members</h2> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="row row-cols-1 row-cols-sm-2 row-cols-md-5 text-center"> | ||||||
|  |       {#each data.members as member} | ||||||
|  |         <PartialMemberCard user={data} {member} /> | ||||||
|  |       {/each} | ||||||
|  |     </div> | ||||||
|  |   {/if} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								src/routes/@[username]/[memberName]/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/routes/@[username]/[memberName]/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | ||||||
|  | import { apiFetch } from "$lib/api/fetch"; | ||||||
|  | import type { Member } from "$lib/api/entities"; | ||||||
|  | 
 | ||||||
|  | export const load = async ({ params }) => { | ||||||
|  |   const resp = await apiFetch<Member>(`/users/${params.username}/members/${params.memberName}`, { | ||||||
|  |     method: "GET", | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return resp; | ||||||
|  | }; | ||||||
							
								
								
									
										87
									
								
								src/routes/@[username]/[memberName]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/routes/@[username]/[memberName]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,87 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import { marked } from "marked"; | ||||||
|  |   import sanitizeHtml from "sanitize-html"; | ||||||
|  | 
 | ||||||
|  |   import FieldCard from "$lib/components/FieldCard.svelte"; | ||||||
|  | 
 | ||||||
|  |   import type { PageData } from "./$types"; | ||||||
|  |   import StatusIcon from "$lib/components/StatusIcon.svelte"; | ||||||
|  |   import PronounLink from "$lib/components/PronounLink.svelte"; | ||||||
|  |   import FallbackImage from "$lib/components/FallbackImage.svelte"; | ||||||
|  |   import { Button, Icon } from "sveltestrap"; | ||||||
|  | 
 | ||||||
|  |   export let data: PageData; | ||||||
|  | 
 | ||||||
|  |   let bio: string | null; | ||||||
|  |   $: bio = data.bio ? sanitizeHtml(marked.parse(data.bio)) : null; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <svelte:head> | ||||||
|  |   <title>{data.display_name ?? data.name} - @{data.user.name} - pronouns.cc</title> | ||||||
|  | </svelte:head> | ||||||
|  | 
 | ||||||
|  | <div class="container"> | ||||||
|  |   <div> | ||||||
|  |     <Button color="secondary" href="/@{data.user.name}"> | ||||||
|  |       <Icon name="arrow-left" /> Back to {data.user.display_name ?? data.user.name} | ||||||
|  |     </Button> | ||||||
|  |   </div> | ||||||
|  |   <div class="grid"> | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="col-md text-center"> | ||||||
|  |         <FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" /> | ||||||
|  |       </div> | ||||||
|  |       <div class="col-md"> | ||||||
|  |         <h2>{data.display_name ?? data.name}</h2> | ||||||
|  |         <h5 class="text-body-secondary">{data.name} (@{data.user.name})</h5> | ||||||
|  |         <hr /> | ||||||
|  |         {#if bio} | ||||||
|  |           <p>{@html bio}</p> | ||||||
|  |         {/if} | ||||||
|  |       </div> | ||||||
|  |       {#if data.links} | ||||||
|  |         <div class="col-md"> | ||||||
|  |           <ul> | ||||||
|  |             {#each data.links as link} | ||||||
|  |               <li><a href={link}>{link}</a></li> | ||||||
|  |             {/each} | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |     </div> | ||||||
|  |     <div class="row"> | ||||||
|  |       {#if data.names} | ||||||
|  |         <div class="col-md"> | ||||||
|  |           <h4>Names</h4> | ||||||
|  |           <ul class="list-unstyled"> | ||||||
|  |             {#each data.names as name} | ||||||
|  |               <li><StatusIcon status={name.status} /> {name.value}</li> | ||||||
|  |             {/each} | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |       {#if data.pronouns} | ||||||
|  |         <div class="col-md"> | ||||||
|  |           <h4>Pronouns</h4> | ||||||
|  |           <ul class="list-unstyled"> | ||||||
|  |             {#each data.pronouns as pronouns} | ||||||
|  |               <li> | ||||||
|  |                 <StatusIcon status={pronouns.status} /> | ||||||
|  |                 <PronounLink {pronouns} /> | ||||||
|  |               </li> | ||||||
|  |             {/each} | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   {#if data.fields} | ||||||
|  |     <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3"> | ||||||
|  |       {#each data.fields as field} | ||||||
|  |         <div class="col"> | ||||||
|  |           <FieldCard {field} /> | ||||||
|  |         </div> | ||||||
|  |       {/each} | ||||||
|  |     </div> | ||||||
|  |   {/if} | ||||||
|  | </div> | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import { apiFetch } from "$lib/api/fetch"; | import { apiFetch } from "$lib/api/fetch"; | ||||||
|  | import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||||
| 
 | 
 | ||||||
| export const load = async () => { | export const load = async () => { | ||||||
|   const resp = await apiFetch<UrlsResponse>("/auth/urls", { |   const resp = await apiFetch<UrlsResponse>("/auth/urls", { | ||||||
|     method: "POST", |     method: "POST", | ||||||
|     body: { |     body: { | ||||||
|       callback_domain: process.env.ORIGIN, |       callback_domain: PUBLIC_BASE_URL, | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										33
									
								
								src/routes/login/discord/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/routes/login/discord/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | ||||||
|  | import type { MeUser } from "$lib/api/entities"; | ||||||
|  | import { apiFetch } from "$lib/api/fetch"; | ||||||
|  | import type { PageServerLoad } from "./$types"; | ||||||
|  | import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||||
|  | 
 | ||||||
|  | export const load = (async (event) => { | ||||||
|  |   try { | ||||||
|  |     const resp = await apiFetch<CallbackResponse>("/auth/discord/callback", { | ||||||
|  |       method: "POST", | ||||||
|  |       body: { | ||||||
|  |         callback_domain: PUBLIC_BASE_URL, | ||||||
|  |         code: event.url.searchParams.get("code"), | ||||||
|  |         state: event.url.searchParams.get("state"), | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |       ...resp, | ||||||
|  |     }; | ||||||
|  |   } catch (e) { | ||||||
|  |     return { error: e }; | ||||||
|  |   } | ||||||
|  | }) satisfies PageServerLoad; | ||||||
|  | 
 | ||||||
|  | interface CallbackResponse { | ||||||
|  |   has_account: boolean; | ||||||
|  |   token?: string; | ||||||
|  |   user?: MeUser; | ||||||
|  | 
 | ||||||
|  |   discord?: string; | ||||||
|  |   ticket?: string; | ||||||
|  |   require_invite: boolean; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  |   import type { PageData } from "./$types"; | ||||||
|  | 
 | ||||||
|  |   export let data: PageData; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <svelte:head> | ||||||
|  |   <title>Log in with Discord - pronouns.cc</title> | ||||||
|  | </svelte:head> | ||||||
|  | 
 | ||||||
|  | <h1>Log in with Discord</h1> | ||||||
|  | 
 | ||||||
|  | {#if data.error} | ||||||
|  | 
 | ||||||
|  | {:else} | ||||||
|  | 
 | ||||||
|  | {/if} | ||||||
|  | @ -2,34 +2,65 @@ | ||||||
|   import { onMount } from "svelte"; |   import { onMount } from "svelte"; | ||||||
|   import { browser } from "$app/environment"; |   import { browser } from "$app/environment"; | ||||||
| 
 | 
 | ||||||
|   import { Collapse,Icon,  Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from "sveltestrap"; |   import { | ||||||
|  |     Collapse, | ||||||
|  |     Icon, | ||||||
|  |     Nav, | ||||||
|  |     Navbar, | ||||||
|  |     NavbarBrand, | ||||||
|  |     NavbarToggler, | ||||||
|  |     NavItem, | ||||||
|  |     NavLink, | ||||||
|  |   } from "sveltestrap"; | ||||||
| 
 | 
 | ||||||
|   import Logo from "./Logo.svelte"; |   import Logo from "./Logo.svelte"; | ||||||
|  |   import { userStore, themeStore } from "$lib/store"; | ||||||
|  |   import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; | ||||||
|  |   import { apiFetch } from "$lib/api/fetch"; | ||||||
| 
 | 
 | ||||||
|   let darkTheme: boolean = false; |   let theme: string; | ||||||
|  |   let currentUser: MeUser | null; | ||||||
|   let showMenu: boolean = false; |   let showMenu: boolean = false; | ||||||
| 
 | 
 | ||||||
|  |   $: currentUser = $userStore; | ||||||
|  |   $: theme = $themeStore; | ||||||
|  | 
 | ||||||
|   onMount(() => { |   onMount(() => { | ||||||
|     darkTheme = |     const localUser = localStorage.getItem("pronouns-user"); | ||||||
|       localStorage.theme === "dark" || |     userStore.set(localUser ? JSON.parse(localUser) : null); | ||||||
|       (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches); | 
 | ||||||
|  |     const token = localStorage.getItem("pronouns-token"); | ||||||
|  |     if (token) { | ||||||
|  |       apiFetch<MeUser>("/users/@me", { token }) | ||||||
|  |         .then((user) => { | ||||||
|  |           userStore.set(user); | ||||||
|  |           localStorage.setItem("pronouns-user", JSON.stringify(user)); | ||||||
|  |         }) | ||||||
|  |         .catch((e) => { | ||||||
|  |           console.log("getting /users/@me:", e); | ||||||
|  | 
 | ||||||
|  |           if ( | ||||||
|  |             (e as APIError).code == ErrorCode.InvalidToken || | ||||||
|  |             (e as APIError).code == ErrorCode.Forbidden | ||||||
|  |           ) { | ||||||
|  |             localStorage.removeItem("pronouns-token"); | ||||||
|  |             localStorage.removeItem("pronouns-user"); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   $: updateTheme(darkTheme); |   $: updateTheme(theme); | ||||||
| 
 | 
 | ||||||
|   const updateTheme = (isDark: boolean) => { |   const updateTheme = (newTheme: string) => { | ||||||
|     if (!browser) return; |     if (!browser) return; | ||||||
| 
 | 
 | ||||||
|     if (isDark) { |     document.documentElement.setAttribute("data-bs-theme", newTheme); | ||||||
|       document.documentElement.setAttribute("data-bs-theme", "dark"); |     localStorage.setItem("pronouns-theme", newTheme); | ||||||
|     } else { |  | ||||||
|       document.documentElement.setAttribute("data-bs-theme", "light"); |  | ||||||
|     } |  | ||||||
|     localStorage.setItem("theme", isDark ? "dark" : "light"); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const toggleTheme = () => { |   const toggleTheme = () => { | ||||||
|     darkTheme = !darkTheme; |     themeStore.set(theme === "dark" ? "light" : "dark") | ||||||
|   }; |   }; | ||||||
|   const toggleMenu = () => { |   const toggleMenu = () => { | ||||||
|     showMenu = !showMenu; |     showMenu = !showMenu; | ||||||
|  | @ -37,9 +68,9 @@ | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <Navbar | <Navbar | ||||||
|   color={darkTheme ? "dark" : "light"} |   color={theme === "dark" ? "dark" : "light"} | ||||||
|   light={!darkTheme} |   light={theme !== "dark"} | ||||||
|   dark={darkTheme} |   dark={theme === "dark"} | ||||||
|   expand="lg" |   expand="lg" | ||||||
|   class="mb-4" |   class="mb-4" | ||||||
| > | > | ||||||
|  | @ -48,15 +79,21 @@ | ||||||
|   <Collapse isOpen={showMenu} navbar expand="lg"> |   <Collapse isOpen={showMenu} navbar expand="lg"> | ||||||
|     <Nav class="ms-auto" navbar> |     <Nav class="ms-auto" navbar> | ||||||
|       <NavItem> |       <NavItem> | ||||||
|         <NavLink href="/login">Log in</NavLink> |         {#if currentUser} | ||||||
|  |           <NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink> | ||||||
|  |           <NavLink href="/settings">Settings</NavLink> | ||||||
|  |           <NavLink href="/logout">Log out</NavLink> | ||||||
|  |         {:else} | ||||||
|  |           <NavLink href="/login">Log in</NavLink> | ||||||
|  |         {/if} | ||||||
|       </NavItem> |       </NavItem> | ||||||
|       <NavItem> |       <NavItem> | ||||||
|         <NavLink |         <NavLink | ||||||
|           on:click={() => toggleTheme()} |           on:click={() => toggleTheme()} | ||||||
|           title={darkTheme ? "Switch to light mode" : "Switch to dark mode"} |           title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} | ||||||
|         > |         > | ||||||
|           <Icon name={darkTheme ? "sun" : "moon-stars"} height="24" /> |           <Icon name={theme === "dark" ? "sun" : "moon-stars"} height="24" /> | ||||||
|           {darkTheme ? "Light mode" : "Dark mode"} |           {theme === "dark" ? "Light mode" : "Dark mode"} | ||||||
|         </NavLink> |         </NavLink> | ||||||
|       </NavItem> |       </NavItem> | ||||||
|     </Nav> |     </Nav> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue