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; | ||||
| } | ||||
| 
 | ||||
| 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 { | ||||
|   code: ErrorCode; | ||||
|   message?: string; | ||||
|  |  | |||
|  | @ -1,12 +1,12 @@ | |||
| import type { APIError } from "./entities"; | ||||
| import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||
| 
 | ||||
| export async function apiFetch<T>( | ||||
|   path: 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", | ||||
|     headers: { | ||||
|       ...(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 { browser } from "$app/environment"; | ||||
| 
 | ||||
| import type { MeUser } from "./api/entities"; | ||||
| 
 | ||||
| export const userStore = writable<MeUser | 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); | ||||
|  |  | |||
|  | @ -7,6 +7,8 @@ | |||
|   import type { PageData } from "./$types"; | ||||
|   import StatusIcon from "$lib/components/StatusIcon.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; | ||||
| 
 | ||||
|  | @ -21,13 +23,13 @@ | |||
| <div class="container"> | ||||
|   <div class="grid"> | ||||
|     <div class="row"> | ||||
|       {#if data.avatar_urls} | ||||
|         <div class="col-md" /> | ||||
|       {/if} | ||||
|       <div class="col-md text-center"> | ||||
|         <FallbackImage urls={data.avatar_urls} alt="Avatar for @{data.name}" /> | ||||
|       </div> | ||||
|       <div class="col-md"> | ||||
|         {#if data.display_name} | ||||
|           <h2>{data.display_name}</h2> | ||||
|           <h4>@{data.name}</h4> | ||||
|           <h5 class="text-body-secondary">@{data.name}</h5> | ||||
|         {:else} | ||||
|           <h2>@{data.name}</h2> | ||||
|         {/if} | ||||
|  | @ -64,7 +66,7 @@ | |||
|             {#each data.pronouns as pronouns} | ||||
|               <li> | ||||
|                 <StatusIcon status={pronouns.status} /> | ||||
|                 <PronounLink pronouns={pronouns} /> | ||||
|                 <PronounLink {pronouns} /> | ||||
|               </li> | ||||
|             {/each} | ||||
|           </ul> | ||||
|  | @ -81,4 +83,17 @@ | |||
|       {/each} | ||||
|     </div> | ||||
|   {/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> | ||||
|  |  | |||
							
								
								
									
										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 { PUBLIC_BASE_URL } from "$env/static/public"; | ||||
| 
 | ||||
| export const load = async () => { | ||||
|   const resp = await apiFetch<UrlsResponse>("/auth/urls", { | ||||
|     method: "POST", | ||||
|     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 { 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 { 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; | ||||
| 
 | ||||
|   $: currentUser = $userStore; | ||||
|   $: theme = $themeStore; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     darkTheme = | ||||
|       localStorage.theme === "dark" || | ||||
|       (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches); | ||||
|     const localUser = localStorage.getItem("pronouns-user"); | ||||
|     userStore.set(localUser ? JSON.parse(localUser) : null); | ||||
| 
 | ||||
|     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 (isDark) { | ||||
|       document.documentElement.setAttribute("data-bs-theme", "dark"); | ||||
|     } else { | ||||
|       document.documentElement.setAttribute("data-bs-theme", "light"); | ||||
|     } | ||||
|     localStorage.setItem("theme", isDark ? "dark" : "light"); | ||||
|     document.documentElement.setAttribute("data-bs-theme", newTheme); | ||||
|     localStorage.setItem("pronouns-theme", newTheme); | ||||
|   }; | ||||
| 
 | ||||
|   const toggleTheme = () => { | ||||
|     darkTheme = !darkTheme; | ||||
|     themeStore.set(theme === "dark" ? "light" : "dark") | ||||
|   }; | ||||
|   const toggleMenu = () => { | ||||
|     showMenu = !showMenu; | ||||
|  | @ -37,9 +68,9 @@ | |||
| </script> | ||||
| 
 | ||||
| <Navbar | ||||
|   color={darkTheme ? "dark" : "light"} | ||||
|   light={!darkTheme} | ||||
|   dark={darkTheme} | ||||
|   color={theme === "dark" ? "dark" : "light"} | ||||
|   light={theme !== "dark"} | ||||
|   dark={theme === "dark"} | ||||
|   expand="lg" | ||||
|   class="mb-4" | ||||
| > | ||||
|  | @ -48,15 +79,21 @@ | |||
|   <Collapse isOpen={showMenu} navbar expand="lg"> | ||||
|     <Nav class="ms-auto" navbar> | ||||
|       <NavItem> | ||||
|         {#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> | ||||
|         <NavLink | ||||
|           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" /> | ||||
|           {darkTheme ? "Light mode" : "Dark mode"} | ||||
|           <Icon name={theme === "dark" ? "sun" : "moon-stars"} height="24" /> | ||||
|           {theme === "dark" ? "Light mode" : "Dark mode"} | ||||
|         </NavLink> | ||||
|       </NavItem> | ||||
|     </Nav> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue