initial commit
This commit is contained in:
		
						commit
						4a80c8552b
					
				
					 46 changed files with 8309 additions and 0 deletions
				
			
		
							
								
								
									
										1
									
								
								.browserlist
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.browserlist
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| >0.3% and not dead, firefox>=98, safari>=15.4, chrome>=84 | ||||
							
								
								
									
										6
									
								
								.editorconfig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.editorconfig
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| root = true | ||||
| 
 | ||||
| [*] | ||||
| trim_trailing_whitespace = true | ||||
| indent_size = 2 | ||||
| indent_style = space | ||||
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,2 @@ | |||
| node_modules | ||||
| dist/ | ||||
							
								
								
									
										0
									
								
								.prettierrc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								.prettierrc
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										13
									
								
								index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|     <title>Tutu</title> | ||||
|     <script src="/src/index.tsx" type="module"></script> | ||||
|   </head> | ||||
|   <body> | ||||
|     <noscript>You need to enable JavaScript to run this app.</noscript> | ||||
|     <div id="root"></div> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										44
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | |||
| { | ||||
|   "$schema": "https://json.schemastore.org/package", | ||||
|   "name": "tutu", | ||||
|   "version": "0.1.0", | ||||
|   "description": "", | ||||
|   "private": true, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite --host 0.0.0.0", | ||||
|     "preview": "vite preview", | ||||
|     "prepare": "vite build" | ||||
|   }, | ||||
|   "keywords": [], | ||||
|   "author": "Rubicon", | ||||
|   "license": "Apache-2.0", | ||||
|   "devDependencies": { | ||||
|     "@suid/vite-plugin": "^0.2.0", | ||||
|     "@types/hammerjs": "^2.0.45", | ||||
|     "postcss": "^8.4.39", | ||||
|     "prettier": "^3.3.2", | ||||
|     "typescript": "^5.5.2", | ||||
|     "vite": "^5.3.2", | ||||
|     "vite-plugin-pwa": "^0.20.0", | ||||
|     "vite-plugin-solid": "^2.10.2", | ||||
|     "vite-plugin-solid-styled": "^0.11.1" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@nanostores/persistent": "^0.9.1", | ||||
|     "@nanostores/solid": "^0.4.2", | ||||
|     "@solid-primitives/event-listener": "^2.3.3", | ||||
|     "@solid-primitives/resize-observer": "^2.0.25", | ||||
|     "@solidjs/router": "^0.11.5", | ||||
|     "@suid/icons-material": "^0.7.0", | ||||
|     "@suid/material": "^0.16.0", | ||||
|     "blurhash": "^2.0.5", | ||||
|     "date-fns": "^3.6.0", | ||||
|     "hammerjs": "^2.0.8", | ||||
|     "masto": "^6.8.0", | ||||
|     "nanostores": "^0.9.5", | ||||
|     "solid-js": "^1.8.18", | ||||
|     "solid-styled": "^0.11.1" | ||||
|   }, | ||||
|   "packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903" | ||||
| } | ||||
							
								
								
									
										4882
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4882
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										17
									
								
								src/App.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/App.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| html, | ||||
| body { | ||||
|   overflow: hidden; | ||||
|   height: 100vh; | ||||
|   height: 100dvh; | ||||
| } | ||||
| 
 | ||||
| #root { | ||||
|   overflow: hidden hidden; | ||||
|   height: 100vh; | ||||
|   height: 100dvh; | ||||
|   background-color: var(--tutu-color-surface, transparent); | ||||
| } | ||||
| 
 | ||||
| .custom-emoji { | ||||
|   width: 1.25em; | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/App.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/App.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| import { Route, Router } from "@solidjs/router"; | ||||
| import { ThemeProvider } from "@suid/material"; | ||||
| import { Component, lazy } from "solid-js"; | ||||
| import { useRootTheme } from "./material/mui.js"; | ||||
| import "./App.css" | ||||
| 
 | ||||
| const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); | ||||
| const AccountMastodonOAuth2Callback = lazy(() => import("./accounts/MastodonOAuth2Callback.js")) | ||||
| const TimelineHome = lazy(() => import("./timelines/Home.js")) | ||||
| 
 | ||||
| const Routing: Component = () => { | ||||
|   return ( | ||||
|     <Router> | ||||
|       <Route path="/" component={TimelineHome}></Route> | ||||
|       <Route path={"/accounts"}> | ||||
|         <Route path={"/sign-in"} component={AccountSignIn} /> | ||||
|         <Route path={"/oauth2/mastodon"} component={AccountMastodonOAuth2Callback} /> | ||||
|       </Route> | ||||
|     </Router> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const App: Component = () => { | ||||
|   const theme = useRootTheme(); | ||||
|   return ( | ||||
|     <ThemeProvider theme={theme()}> | ||||
|       <Routing /> | ||||
|     </ThemeProvider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default App; | ||||
							
								
								
									
										124
									
								
								src/accounts/MastodonOAuth2Callback.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/accounts/MastodonOAuth2Callback.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,124 @@ | |||
| import { useNavigate, useSearchParams } from "@solidjs/router"; | ||||
| import { | ||||
|   Component, | ||||
|   Show, | ||||
|   createSignal, | ||||
|   createUniqueId, | ||||
|   onMount, | ||||
| } from "solid-js"; | ||||
| import { acceptAccountViaAuthCode } from "./stores"; | ||||
| import { $settings } from "../settings/stores"; | ||||
| import { useDocumentTitle } from "../utils"; | ||||
| import cards from "../material/cards.module.css"; | ||||
| import { LinearProgress } from "@suid/material"; | ||||
| import Img from "../material/Img"; | ||||
| import { createRestAPIClient } from "masto"; | ||||
| import { Title } from "../material/typography"; | ||||
| 
 | ||||
| type OAuth2CallbackParams = { | ||||
|   code?: string; | ||||
|   error?: string; | ||||
|   error_description?: string; | ||||
| }; | ||||
| 
 | ||||
| const MastodonOAuth2Callback: Component = () => { | ||||
|   const progressId = createUniqueId(); | ||||
|   const titleId = createUniqueId(); | ||||
|   const [params] = useSearchParams<OAuth2CallbackParams>(); | ||||
|   const navigate = useNavigate(); | ||||
|   const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); | ||||
|   const [siteImg, setSiteImg] = createSignal<{ | ||||
|     src: string; | ||||
|     srcset?: string; | ||||
|     blurhash: string; | ||||
|   }>(); | ||||
|   const [siteTitle, setSiteTitle] = createSignal("the Mastodon server"); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process; | ||||
|     if (!onGoingOAuth2Process) return; | ||||
|     const client = createRestAPIClient({ | ||||
|       url: onGoingOAuth2Process, | ||||
|     }); | ||||
|     const ins = await client.v2.instance.fetch(); | ||||
|     setDocumentTitle(`Back from ${ins.title}...`); | ||||
|     setSiteTitle(ins.title); | ||||
| 
 | ||||
|     const srcset = [] | ||||
|     if (ins.thumbnail.versions["@1x"]) { | ||||
|       srcset.push(`${ins.thumbnail.versions["@1x"]} 1x`) | ||||
|     } | ||||
|     if (ins.thumbnail.versions["@2x"]) { | ||||
|       srcset.push(`${ins.thumbnail.versions["@2x"]} 2x`) | ||||
|     } | ||||
| 
 | ||||
|     setSiteImg({ | ||||
|       src: ins.thumbnail.url, | ||||
|       blurhash: ins.thumbnail.blurhash, | ||||
|       srcset: srcset ? srcset.join(",") : undefined, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process; | ||||
|     if (onGoingOAuth2Process && params.code) { | ||||
|       const acct = await acceptAccountViaAuthCode( | ||||
|         onGoingOAuth2Process, | ||||
|         params.code, | ||||
|       ); | ||||
|       $settings.setKey('onGoingOAuth2Process', undefined) | ||||
|       navigate('/', {replace: true}) | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const error = | ||||
|       params.error || | ||||
|       (onGoingOAuth2Process ? "unknown" : "oauth2_unknown_target"); | ||||
|     const errorDescription = | ||||
|       params.error_description || | ||||
|       (error === "unknown" | ||||
|         ? "Remote server sends nothing" | ||||
|         : error === "oauth2_unknown_target" | ||||
|           ? "Unknown OAuth2 target. This is an internal error. Please contract the application maintainer." | ||||
|           : undefined); | ||||
|     const urlParams = new URLSearchParams({ | ||||
|       error, | ||||
|     }); | ||||
|     if (errorDescription) { | ||||
|       urlParams.set("errorDescription", errorDescription); | ||||
|     } | ||||
|     navigate("/accounts/sign-in?" + urlParams.toString(), { | ||||
|       replace: true, | ||||
|     }); | ||||
|   }); | ||||
|   return ( | ||||
|     <div class={cards.layoutCentered}> | ||||
|       <div class={cards.card} aria-busy="true" aria-describedby={progressId}> | ||||
|         <LinearProgress | ||||
|           class={[cards.cardNoPad, cards.cardGutSkip].join(' ')} | ||||
|           id={progressId} | ||||
|           aria-labelledby={titleId} | ||||
|         /> | ||||
|         <Show when={siteImg()} fallback={<i aria-busy="true" aria-label="Preparing image..." style={{"height": "235px", display: "block"}}></i>}> | ||||
|           <Img | ||||
|             src={siteImg()?.src} | ||||
|             srcset={siteImg()?.srcset} | ||||
|             blurhash={siteImg()?.blurhash} | ||||
|             class={[cards.cardNoPad, cards.cardGutSkip].join(' ')} | ||||
|             alt={`Banner image for ${siteTitle()}`} | ||||
|             style={{"height": "235px", "display": "block"}} | ||||
|           /> | ||||
|         </Show> | ||||
| 
 | ||||
|         <Title component="h6" id={titleId}> | ||||
|           Contracting {siteTitle}... | ||||
|         </Title> | ||||
|         <p> | ||||
|           If this page stays too long, you can close this page and sign in again. | ||||
|         </p> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default MastodonOAuth2Callback; | ||||
							
								
								
									
										173
									
								
								src/accounts/SignIn.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/accounts/SignIn.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,173 @@ | |||
| import { | ||||
|   Component, | ||||
|   Show, | ||||
|   createEffect, | ||||
|   createSelector, | ||||
|   createSignal, | ||||
|   createUniqueId, | ||||
|   onMount, | ||||
| } from "solid-js"; | ||||
| import cards from "../material/cards.module.css"; | ||||
| import TextField from "../material/TextField.js"; | ||||
| import Button from "../material/Button.js"; | ||||
| import { useDocumentTitle } from "../utils"; | ||||
| import { Title } from "../material/typography"; | ||||
| import { css } from "solid-styled"; | ||||
| import { LinearProgress } from "@suid/material"; | ||||
| import { createRestAPIClient } from "masto"; | ||||
| import { getOrRegisterApp } from "./stores"; | ||||
| import { useSearchParams } from "@solidjs/router"; | ||||
| import { $settings } from "../settings/stores"; | ||||
| 
 | ||||
| type ErrorParams = { | ||||
|   error: string; | ||||
|   errorDescription: string; | ||||
| }; | ||||
| 
 | ||||
| const SignIn: Component = () => { | ||||
|   const progressId = createUniqueId(); | ||||
|   const [params] = useSearchParams<ErrorParams>(); | ||||
|   const [rawServerUrl, setRawServerUrl] = createSignal(""); | ||||
|   const [currentState, setCurrentState] = createSignal< | ||||
|     "inactive" | "contracting" | "navigating" | ||||
|   >("inactive"); | ||||
|   const [serverUrlHelperText, setServerUrlHelperText] = createSignal(""); | ||||
|   const [serverUrlError, setServerUrlError] = createSignal(false); | ||||
|   const [targetSiteTitle, setTargetSiteTitle] = createSignal(""); | ||||
| 
 | ||||
|   useDocumentTitle("Sign In"); | ||||
|   css` | ||||
|     form { | ||||
|       display: flex; | ||||
|       flex-flow: column; | ||||
|       gap: 16px; | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   const serverUrl = () => { | ||||
|     const url = rawServerUrl(); | ||||
|     if (url.length === 0 || /^%w:/.test(url)) { | ||||
|       return url; | ||||
|     } else { | ||||
|       return `https://${url}`; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   createEffect(() => { | ||||
|     const url = serverUrl(); | ||||
|     setServerUrlError(false); | ||||
|     if (url.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       new URL(url); | ||||
|     } catch { | ||||
|       setServerUrlHelperText("Domain is required."); | ||||
|       return; | ||||
|     } | ||||
|     setServerUrlHelperText(""); | ||||
|   }); | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     $settings.setKey('onGoingOAuth2Process', undefined) | ||||
|   }) | ||||
| 
 | ||||
|   const onStartOAuth2 = async (e: Event) => { | ||||
|     e.preventDefault(); | ||||
|     setCurrentState("contracting"); | ||||
|     const url = serverUrl(); | ||||
|     try { | ||||
|       setServerUrlError(!!serverUrlHelperText()); | ||||
| 
 | ||||
|       const client = createRestAPIClient({ | ||||
|         url, | ||||
|       }); | ||||
|       const ins = await client.v2.instance.fetch(); | ||||
|       setTargetSiteTitle(ins.title); | ||||
| 
 | ||||
|       const redirectURL = new URL( | ||||
|         "./oauth2/mastodon", | ||||
|         window.location.href, | ||||
|       ).toString(); | ||||
| 
 | ||||
|       const app = await getOrRegisterApp(url, redirectURL); | ||||
|       if (app === null) { | ||||
|         alert("The mastodon server could not be used with tutu."); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const authStart = new URL("./oauth/authorize", url); | ||||
|       const searches = authStart.searchParams; | ||||
|       const args = { | ||||
|         response_type: "code", | ||||
|         client_id: app.clientId, | ||||
|         redirect_uri: redirectURL, | ||||
|         scope: "read write push", | ||||
|       }; | ||||
|       for (const [k, v] of Object.entries(args)) { | ||||
|         searches.set(k, v); | ||||
|       } | ||||
|       $settings.setKey("onGoingOAuth2Process", url) | ||||
|       window.location.href = authStart.toString(); | ||||
|     } catch (e) { | ||||
|       setServerUrlHelperText( | ||||
|         `Could not contract with the server: "${String(e)}". Please check and try again.`, | ||||
|       ); | ||||
|       setServerUrlError(true); | ||||
|       console.error(`Failed to contract ${url}.`, e); | ||||
|     } finally { | ||||
|       setCurrentState("inactive"); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div class={cards.layoutCentered}> | ||||
|       <Show when={params.error || params.errorDescription}> | ||||
|         <div class={cards.card} style={{ "margin-bottom": "20px" }}> | ||||
|           <p>Authorization is failed.</p> | ||||
|           <p>{params.errorDescription}</p> | ||||
|           <p> | ||||
|             Please try again later. If the problem persist, you can seek for | ||||
|             help from the server administrator. | ||||
|           </p> | ||||
|         </div> | ||||
|       </Show> | ||||
|       <div | ||||
|         class={/*once*/ cards.card} | ||||
|         aria-busy={currentState() !== "inactive" ? "true" : "false"} | ||||
|         aria-describedby={ | ||||
|           currentState() !== "inactive" ? progressId : undefined | ||||
|         } | ||||
|       > | ||||
|         <LinearProgress | ||||
|           class={[cards.cardNoPad, cards.cardGutSkip].join(" ")} | ||||
|           id={progressId} | ||||
|           sx={currentState() === "inactive" ? { display: "none" } : undefined} | ||||
|         /> | ||||
|         <form onSubmit={onStartOAuth2}> | ||||
|           <Title component="h6">Sign in with Your Mastodon Account</Title> | ||||
|           <TextField | ||||
|             label="Mastodon Server" | ||||
|             name="serverUrl" | ||||
|             onInput={setRawServerUrl} | ||||
|             required | ||||
|             helperText={serverUrlHelperText()} | ||||
|             error={!!serverUrlError()} | ||||
|           /> | ||||
| 
 | ||||
|           <div style={{ display: "flex", "justify-content": "end" }}> | ||||
|             <Button type="submit" disabled={currentState() !== "inactive"}> | ||||
|               {currentState() == "inactive" | ||||
|                 ? "Continue" | ||||
|                 : currentState() == "contracting" | ||||
|                   ? `Contracting ${new URL(serverUrl()).host}...` | ||||
|                   : `Moving to ${targetSiteTitle}`} | ||||
|             </Button> | ||||
|           </div> | ||||
|         </form> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default SignIn; | ||||
							
								
								
									
										174
									
								
								src/accounts/stores.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/accounts/stores.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,174 @@ | |||
| import { persistentAtom } from "@nanostores/persistent"; | ||||
| import { createOAuthAPIClient, createRestAPIClient } from "masto"; | ||||
| import { action } from "nanostores"; | ||||
| 
 | ||||
| export type Account = { | ||||
|   site: string; | ||||
|   accessToken: string; | ||||
| 
 | ||||
|   tokenType: string; | ||||
|   scope: string; | ||||
|   createdAt: number; | ||||
| }; | ||||
| 
 | ||||
| export const $accounts = persistentAtom<Account[]>("accounts", [], { | ||||
|   encode: JSON.stringify, | ||||
|   decode: JSON.parse, | ||||
| }); | ||||
| 
 | ||||
| interface OAuth2AccessToken { | ||||
|   access_token: string; | ||||
|   token_type: string; | ||||
|   scope: string; | ||||
|   created_at: number; | ||||
| } | ||||
| 
 | ||||
| async function oauth2TokenViaAuthCode(app: RegisteredApp, authCode: string) { | ||||
|   const resp = await fetch(new URL("./oauth/token", app.site), { | ||||
|     method: 'post', | ||||
|     body: JSON.stringify({ | ||||
|       grant_type: "authorization_code", | ||||
|       code: authCode, | ||||
|       client_id: app.clientId, | ||||
|       client_secret: app.clientSecret, | ||||
|       redirect_uri: app.redirectUrl, | ||||
|       scope: "read write push", | ||||
|     }), | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   switch (resp.status) { | ||||
|     case 200: | ||||
|       return (await resp.json()) as OAuth2AccessToken; | ||||
|     default: { | ||||
|       const dict = await resp.json(); | ||||
|       const explain = dict.error_desciption ?? "Unknown OAuth2 Error"; | ||||
|       throw new TypeError(explain); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const acceptAccountViaAuthCode = action( | ||||
|   $accounts, | ||||
|   "acceptAccount", | ||||
|   async ($store, site: string, authCode: string) => { | ||||
|     const app = $registeredApps.get()[site]; | ||||
|     if (!app) { | ||||
|       throw TypeError("application not found"); | ||||
|     } | ||||
|     const token = await oauth2TokenViaAuthCode(app, authCode); | ||||
| 
 | ||||
|     const acct = { | ||||
|       site: app.site, | ||||
|       accessToken: token.access_token, | ||||
|       tokenType: token.token_type, | ||||
|       scope: token.scope, | ||||
|       createdAt: token.created_at * 1000, | ||||
|     }; | ||||
| 
 | ||||
|     const all = [...$store.get(), acct]; | ||||
|     $store.set(all); | ||||
| 
 | ||||
|     return acct; | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| export type RegisteredApp = { | ||||
|   site: string; | ||||
|   clientId: string; | ||||
|   clientSecret: string; | ||||
|   vapidKey?: string; | ||||
|   redirectUrl: string; | ||||
|   scope: string; | ||||
| }; | ||||
| 
 | ||||
| export const $registeredApps = persistentAtom<{ | ||||
|   [site: string]: RegisteredApp; | ||||
| }>( | ||||
|   "registeredApps", | ||||
|   {}, | ||||
|   { | ||||
|     encode: JSON.stringify, | ||||
|     decode: JSON.parse, | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| async function getAppAccessToken(app: RegisteredApp) { | ||||
|   const resp = await fetch(new URL("./oauth/token", app.site), { | ||||
|     method: 'post', | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: JSON.stringify({ | ||||
|       client_id: app.clientId, | ||||
|       client_secret: app.clientSecret, | ||||
|       redirect_uri: app.redirectUrl, | ||||
|       grant_type: "client_credentials", | ||||
|     }), | ||||
|   }); | ||||
|   const dict = await resp.json(); | ||||
|   return dict.access_token; | ||||
| } | ||||
| 
 | ||||
| export const getOrRegisterApp = action( | ||||
|   $registeredApps, | ||||
|   "getOrRegisterApp", | ||||
|   async ($store, site: string, redirectUrl: string) => { | ||||
|     const all = $store.get(); | ||||
|     const savedApp = all[site]; | ||||
|     if (savedApp && savedApp.redirectUrl === redirectUrl) { | ||||
|       const appAccessToken = await getAppAccessToken(savedApp); | ||||
|       if (appAccessToken) { | ||||
|         const client = createRestAPIClient({ | ||||
|           url: site, | ||||
|           accessToken: appAccessToken, | ||||
|         }); | ||||
|         try { | ||||
|           const verify = await client.v1.apps.verifyCredentials(); | ||||
|           Object.assign(savedApp, { | ||||
|             vapidKey: verify.vapidKey, | ||||
|           }); | ||||
|           const oauthClient = createOAuthAPIClient({ | ||||
|             url: site, | ||||
|             accessToken: appAccessToken, | ||||
|           }); | ||||
|           try { | ||||
|             await oauthClient.revoke({ | ||||
|               clientId: savedApp.clientId, | ||||
|               clientSecret: savedApp.clientSecret, | ||||
|               token: appAccessToken, | ||||
|             }); | ||||
|           } catch {} | ||||
|           return savedApp; | ||||
|         } finally { | ||||
|           $store.set(all); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const client = createRestAPIClient({ | ||||
|       url: site, | ||||
|     }); | ||||
|     const app = await client.v1.apps.create({ | ||||
|       clientName: "TuTu", | ||||
|       website: "https://github.com/thislight/tutu", | ||||
|       redirectUris: redirectUrl, | ||||
|       scopes: "read write push", | ||||
|     }); | ||||
|     if (!app.clientId || !app.clientSecret) { | ||||
|       return null; | ||||
|     } | ||||
|     all[site] = { | ||||
|       site, | ||||
|       clientId: app.clientId, | ||||
|       clientSecret: app.clientSecret, | ||||
|       vapidKey: app.vapidKey ?? undefined, | ||||
|       redirectUrl: redirectUrl, | ||||
|       scope: "read write push", | ||||
|     }; | ||||
|     $store.set(all); | ||||
|     return all[site]; | ||||
|   }, | ||||
| ); | ||||
							
								
								
									
										5
									
								
								src/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/index.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| import {render} from 'solid-js/web' | ||||
| import App from './App.js' | ||||
| import "./material/theme.css" | ||||
| 
 | ||||
| render(() => <App />, document.getElementById("root")!) | ||||
							
								
								
									
										12
									
								
								src/masto/acct.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/masto/acct.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| import { Accessor, createResource } from "solid-js"; | ||||
| import { Account } from "../accounts/stores"; | ||||
| import { useMastoClientFor } from "./clients"; | ||||
| 
 | ||||
| export function useAcctProfile(account: Accessor<Account>) { | ||||
|   const client = useMastoClientFor(account) | ||||
|   return createResource(client, (client) => { | ||||
|     return client.v1.accounts.verifyCredentials() | ||||
|   }, { | ||||
|     name: "MastodonAccountProfile" | ||||
|   }) | ||||
| } | ||||
							
								
								
									
										41
									
								
								src/masto/clients.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/masto/clients.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | |||
| import { Accessor, createMemo, createResource } from "solid-js"; | ||||
| import { Account } from "../accounts/stores"; | ||||
| import { createRestAPIClient, mastodon } from "masto"; | ||||
| 
 | ||||
| const restfulCache: Record<string, mastodon.rest.Client> = {} | ||||
| 
 | ||||
| export function createMastoClientFor(account: Account) { | ||||
|   const cacheKey = [account.site, account.accessToken].join('') | ||||
|   const cache = restfulCache[cacheKey] | ||||
|   if (cache) return cache; | ||||
| 
 | ||||
|   const client = createRestAPIClient({ | ||||
|     url: account.site, | ||||
|     accessToken: account.accessToken, | ||||
|   }) | ||||
|   restfulCache[cacheKey] = client | ||||
| 
 | ||||
|   return client | ||||
| } | ||||
| 
 | ||||
| export function useMastoClientFor(account: Accessor<Account>) { | ||||
|   return createMemo(() => createMastoClientFor(account())) | ||||
| } | ||||
| 
 | ||||
| export function createUnauthorizedClient(site: string) { | ||||
|   const cache = restfulCache[site] | ||||
|   if (cache) return cache; | ||||
| 
 | ||||
|   const client = createRestAPIClient({ | ||||
|     url: site | ||||
|   }) | ||||
|   restfulCache[site] = client | ||||
| 
 | ||||
|   return client | ||||
| } | ||||
| 
 | ||||
| export function useInstance(client: Accessor<mastodon.rest.Client>) { | ||||
|   return createResource(client, async (client) => { | ||||
|     return await client.v2.instance.fetch() | ||||
|   }) | ||||
| } | ||||
							
								
								
									
										75
									
								
								src/masto/timelines.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/masto/timelines.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| import { type mastodon } from "masto"; | ||||
| import { Accessor, createResource, createSignal } from "solid-js"; | ||||
| 
 | ||||
| type TimelineFetchTips = { | ||||
|   direction?: "new" | "old"; | ||||
| }; | ||||
| 
 | ||||
| type Timeline = { | ||||
|   list(params: { | ||||
|     maxId?: string; | ||||
|     minId?: string; | ||||
|   }): mastodon.Paginator<mastodon.v1.Status[], unknown>; | ||||
| }; | ||||
| 
 | ||||
| export function useTimeline( | ||||
|   timeline: Accessor<Timeline>, | ||||
| ) { | ||||
|   let minId: string | undefined; | ||||
|   let maxId: string | undefined; | ||||
|   let otl: Timeline | undefined; | ||||
|   const idSet = new Set<string>(); | ||||
|   return createResource< | ||||
|     mastodon.v1.Status[], | ||||
|     [Timeline], | ||||
|     TimelineFetchTips | undefined | ||||
|   >( | ||||
|     () => [timeline()] as const, | ||||
|     async ([tl], info) => { | ||||
|       if (otl !== tl) { | ||||
|         minId = undefined; | ||||
|         maxId = undefined; | ||||
|         idSet.clear(); | ||||
|         info.value = []; | ||||
|         otl = tl; | ||||
|       } | ||||
|       const direction = | ||||
|         typeof info.refetching !== "boolean" | ||||
|           ? info.refetching?.direction | ||||
|           : "old"; | ||||
|       const pager = await tl.list( | ||||
|         direction === "old" | ||||
|           ? { | ||||
|               maxId: minId, | ||||
|             } | ||||
|           : { | ||||
|               minId: maxId, | ||||
|             }, | ||||
|       ); | ||||
|       const old = info.value || []; | ||||
|       const diff = pager.filter((x) => !idSet.has(x.id)); | ||||
|       for (const v of diff.map((x) => x.id)) { | ||||
|         idSet.add(v); | ||||
|       } | ||||
|       if (direction === "old") { | ||||
|         minId = pager[pager.length - 1]?.id; | ||||
|         if (!maxId && pager.length > 0) { | ||||
|           maxId = pager[0].id; | ||||
|         } | ||||
|         return [...old, ...diff]; | ||||
|       } else { | ||||
|         maxId = pager.length > 0 ? pager[0].id : undefined; | ||||
|         if (!minId && pager.length > 0) { | ||||
|           minId = pager[pager.length - 1]?.id; | ||||
|         } | ||||
|         return [...diff, ...old]; | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       initialValue: [], | ||||
|       storage(init) { | ||||
|         return createSignal(init, { equals: false }); | ||||
|       }, | ||||
|     }, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/masto/toot.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/masto/toot.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { createRenderEffect, createResource, type Accessor } from "solid-js"; | ||||
| 
 | ||||
| const CUSTOM_EMOJI_REGEX = /:(\S+):/g; | ||||
| 
 | ||||
| /** | ||||
|  * Resolve the custom emojis in string to HTML. | ||||
|  */ | ||||
| export function resolveCustomEmoji( | ||||
|   content: string, | ||||
|   emojis: mastodon.v1.CustomEmoji[], | ||||
| ) { | ||||
|   return content.replace(CUSTOM_EMOJI_REGEX, (original, shortcode: string) => { | ||||
|     const emoji = emojis.find((x) => x.shortcode === shortcode); | ||||
|     if (!emoji) { | ||||
|       return original; | ||||
|     } | ||||
|     return `<img src="${emoji.url}" class="custom-emoji" alt="${shortcode}"/>`; | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function appliedCustomEmoji( | ||||
|   target: { innerHTML: string }, | ||||
|   content: string, | ||||
|   emojis?: mastodon.v1.CustomEmoji[], | ||||
| ) { | ||||
|   createRenderEffect(() => { | ||||
|     const result = emojis ? resolveCustomEmoji(content, emojis) : content; | ||||
|     target.innerHTML = result; | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function hasCustomEmoji(s: string) { | ||||
|   return CUSTOM_EMOJI_REGEX.test(s); | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/material/Button.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/material/Button.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| import { Component, JSX, splitProps } from "solid-js"; | ||||
| import materialStyles from "./material.module.css"; | ||||
| 
 | ||||
| /** | ||||
|  * Material-styled button. | ||||
|  * | ||||
|  * @param type Same as `<button>`'s type property, the default is 'button' | ||||
|  */ | ||||
| const Button: Component<JSX.ButtonHTMLAttributes<HTMLButtonElement>> = ( | ||||
|   props, | ||||
| ) => { | ||||
|   const [managed, passthough] = splitProps(props, ["class", 'type']); | ||||
|   const classes = () => | ||||
|     managed.class | ||||
|       ? [materialStyles.button, managed.class].join(" ") | ||||
|       : materialStyles.button; | ||||
|   const type = () => managed.type ?? 'button' | ||||
|   return <button type={type()} class={classes()} {...passthough}></button>; | ||||
| }; | ||||
| 
 | ||||
| export default Button; | ||||
							
								
								
									
										121
									
								
								src/material/Img.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/material/Img.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | |||
| import { | ||||
|   JSX, | ||||
|   splitProps, | ||||
|   Component, | ||||
|   createSignal, | ||||
|   createEffect, | ||||
|   onMount, | ||||
|   createRenderEffect, | ||||
|   Show, | ||||
| } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| import { decode } from "blurhash"; | ||||
| import { mergeClass } from "../utils"; | ||||
| 
 | ||||
| type ImgProps = { | ||||
|   blurhash?: string; | ||||
|   keepBlur?: boolean; | ||||
| } & JSX.HTMLElementTags["img"]; | ||||
| 
 | ||||
| const Img: Component<ImgProps> = (props) => { | ||||
|   let canvas: HTMLCanvasElement; | ||||
|   let imgE: HTMLImageElement; | ||||
|   const [managed, passthough] = splitProps(props, [ | ||||
|     "blurhash", | ||||
|     "keepBlur", | ||||
|     "class", | ||||
|     "style", | ||||
|   ]); | ||||
|   const [isImgLoaded, setIsImgLoaded] = createSignal(false); | ||||
|   const [imgSize, setImgSize] = createSignal<{ | ||||
|     width: number; | ||||
|     height: number; | ||||
|   }>(); | ||||
| 
 | ||||
|   const isBlurEnabled = () => managed.keepBlur || !isImgLoaded(); | ||||
| 
 | ||||
|   css` | ||||
|     :where(.img-root) { | ||||
|       display: inline-block; | ||||
|       position: relative; | ||||
| 
 | ||||
|       > img:first-of-type { | ||||
|         object-fit: contain; | ||||
|         object-position: center; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         visibility: ${isBlurEnabled() ? "hidden" : "visible"}; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     :where(.cover) { | ||||
|       display: ${isBlurEnabled() ? "block" : "none"}; | ||||
|       position: absolute; | ||||
|       left: 0; | ||||
|       top: 0; | ||||
|       height: ${`${imgSize()?.height ?? 0}px`}; | ||||
|       width: ${`${imgSize()?.width ?? 0}px`}; | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   const onImgLoaded = () => { | ||||
|     setIsImgLoaded(true); | ||||
|     setImgSize({ | ||||
|       width: imgE.width, | ||||
|       height: imgE.height, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const onMetadataLoaded = () => { | ||||
|     setImgSize({ | ||||
|       width: imgE.width, | ||||
|       height: imgE.height, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     setImgSize((x) => { | ||||
|       const parent = imgE.parentElement; | ||||
|       if (!parent) return x; | ||||
|       return x | ||||
|         ? x | ||||
|         : { | ||||
|             width: parent.clientWidth, | ||||
|             height: parent.clientHeight, | ||||
|           }; | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   return ( | ||||
|     <div class={mergeClass(managed.class, "img-root")} style={managed.style}> | ||||
|       <Show when={managed.blurhash}> | ||||
|         <canvas | ||||
|           ref={(canvas) => { | ||||
|             createRenderEffect(() => { | ||||
|               if (!managed.blurhash) return; | ||||
|               const ctx = canvas.getContext("2d"); | ||||
|               if (!ctx) return; | ||||
|               const size = imgSize(); | ||||
|               if (!size) return; | ||||
|               const imgd = ctx?.createImageData(size.width, size.height); | ||||
|               const pixels = decode(managed.blurhash, size.width, size.height); | ||||
|               imgd.data.set(pixels); | ||||
|               ctx.putImageData(imgd, 0, 0); | ||||
|             }); | ||||
|           }} | ||||
|           class="cover" | ||||
|           role="presentation" | ||||
|         /> | ||||
|       </Show> | ||||
| 
 | ||||
|       <img | ||||
|         ref={imgE!} | ||||
|         {...passthough} | ||||
|         onLoad={onImgLoaded} | ||||
|         onLoadedMetadata={onMetadataLoaded} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Img; | ||||
							
								
								
									
										58
									
								
								src/material/Scaffold.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/material/Scaffold.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| import { createElementSize } from "@solid-primitives/resize-observer"; | ||||
| import { | ||||
|   Show, | ||||
|   createRenderEffect, | ||||
|   createSignal, | ||||
|   onCleanup, | ||||
|   type JSX, | ||||
|   type ParentComponent, | ||||
| } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| 
 | ||||
| interface ScaffoldProps { | ||||
|   topbar?: JSX.Element; | ||||
|   fab?: JSX.Element; | ||||
| } | ||||
| 
 | ||||
| const Scaffold: ParentComponent<ScaffoldProps> = (props) => { | ||||
|   const [topbarElement, setTopbarElement] = createSignal<HTMLElement>(); | ||||
| 
 | ||||
|   const topbarSize = createElementSize(topbarElement); | ||||
| 
 | ||||
|   css` | ||||
|     .scaffold-content { | ||||
|       --scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"}; | ||||
| 
 | ||||
|       height: 100%; | ||||
|       width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     .topbar { | ||||
|       position: sticky; | ||||
|       top: 0px; | ||||
|       z-index: var(--tutu-zidx-nav, auto); | ||||
|     } | ||||
| 
 | ||||
|     .fab-dock { | ||||
|       position: fixed; | ||||
|       bottom: 40px; | ||||
|       right: 40px; | ||||
|       z-index: var(--tutu-zidx-nav, auto); | ||||
|     } | ||||
|   `;
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Show when={props.topbar}> | ||||
|         <div class="topbar" ref={setTopbarElement}> | ||||
|           {props.topbar} | ||||
|         </div> | ||||
|       </Show> | ||||
|       <Show when={props.fab}> | ||||
|         <div class="fab-dock">{props.fab}</div> | ||||
|       </Show> | ||||
|       <div class="scaffold-content">{props.children}</div> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Scaffold; | ||||
							
								
								
									
										80
									
								
								src/material/Tab.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/material/Tab.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,80 @@ | |||
| import { | ||||
|   Component, | ||||
|   createEffect, | ||||
|   splitProps, | ||||
|   type JSX, | ||||
|   type ParentComponent, | ||||
| } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| import { useTabListContext } from "./Tabs"; | ||||
| 
 | ||||
| const Tab: ParentComponent< | ||||
|   { | ||||
|     focus?: boolean; | ||||
|     large?: boolean; | ||||
|   } & JSX.ButtonHTMLAttributes<HTMLButtonElement> | ||||
| > = (props) => { | ||||
|   const [managed, rest] = splitProps(props, [ | ||||
|     "focus", | ||||
|     "large", | ||||
|     "type", | ||||
|     "role", | ||||
|     "ref", | ||||
|   ]); | ||||
|   let self: HTMLButtonElement; | ||||
|   const { | ||||
|     focusOn: [, setFocusOn], | ||||
|   } = useTabListContext(); | ||||
| 
 | ||||
|   createEffect<boolean | undefined>((lastStatus) => { | ||||
|     if (managed.focus && !lastStatus) { | ||||
|       setFocusOn((x) => [...x, self]); | ||||
|     } | ||||
|     if (!managed.focus && lastStatus) { | ||||
|       setFocusOn((x) => x.filter((e) => e !== self)); | ||||
|     } | ||||
|     return managed.focus; | ||||
|   }); | ||||
|   css` | ||||
|     .tab { | ||||
|       cursor: pointer; | ||||
|       background: none; | ||||
|       border: none; | ||||
|       min-width: ${managed.large ? "160px" : "72px"}; | ||||
|       height: 48px; | ||||
|       max-width: min(calc(100% - 56px), 264px); | ||||
|       padding: 10px 24px; | ||||
|       font-size: 0.8135rem; | ||||
|       font-weight: 600; | ||||
|       text-transform: uppercase; | ||||
|       transition: color 120ms var(--tutu-anim-curve-std); | ||||
|     } | ||||
| 
 | ||||
|     :global(.MuiToolbar-root) .tab { | ||||
|       color: rgba(255, 255, 255, 0.7); | ||||
| 
 | ||||
|       &:hover, | ||||
|       &:focus, | ||||
|       &.focus, | ||||
|       &:global(.tablist-focus) { | ||||
|         color: white; | ||||
|       } | ||||
|     } | ||||
|   `;
 | ||||
|   return ( | ||||
|     <button | ||||
|       ref={(x) => { | ||||
|         self = x; | ||||
|         (managed.ref as (e: HTMLButtonElement) => void)?.(x); | ||||
|       }} | ||||
|       type={managed.type ?? "button"} | ||||
|       classList={{ tab: true, focus: managed.focus }} | ||||
|       role={managed.role ?? "tab"} | ||||
|       {...rest} | ||||
|     > | ||||
|       {props.children} | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Tab; | ||||
							
								
								
									
										165
									
								
								src/material/Tabs.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/material/Tabs.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,165 @@ | |||
| import { | ||||
|   ParentComponent, | ||||
|   createContext, | ||||
|   createEffect, | ||||
|   createMemo, | ||||
|   createRenderEffect, | ||||
|   createSignal, | ||||
|   useContext, | ||||
|   type Signal, | ||||
| } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| 
 | ||||
| const TabListContext = /* @__PURE__ */ createContext<{ | ||||
|   focusOn: Signal<HTMLElement[]>; | ||||
| }>(); | ||||
| 
 | ||||
| export function useTabListContext() { | ||||
|   const result = useContext(TabListContext); | ||||
|   if (!result) { | ||||
|     throw new TypeError("tab list context is not found"); | ||||
|   } | ||||
|   return result; | ||||
| } | ||||
| 
 | ||||
| const ANIM_SPEED = 160 / 110; // 160px/110ms
 | ||||
| 
 | ||||
| const TABLIST_FOCUS_CLASS = "tablist-focus"; | ||||
| 
 | ||||
| const Tabs: ParentComponent<{ | ||||
|   offset?: number; | ||||
|   onFocusChanged?: (element: HTMLElement[]) => void; | ||||
| }> = (props) => { | ||||
|   let self: HTMLDivElement; | ||||
|   const [focusOn, setFocusOn] = createSignal<HTMLElement[]>([]); | ||||
| 
 | ||||
|   createRenderEffect<HTMLElement[] | undefined>((lastFocusElement) => { | ||||
|     const current = focusOn(); | ||||
|     if (lastFocusElement) { | ||||
|       for (const e of lastFocusElement) { | ||||
|         e.classList.remove(TABLIST_FOCUS_CLASS); | ||||
|       } | ||||
|     } | ||||
|     for (const e of current) { | ||||
|       e.classList.add("tablist-focus"); | ||||
|     } | ||||
|     return current; | ||||
|   }); | ||||
| 
 | ||||
|   createRenderEffect(() => { | ||||
|     const callback = props.onFocusChanged; | ||||
|     if (!callback) return; | ||||
|     callback(focusOn()); | ||||
|   }); | ||||
| 
 | ||||
|   let lastLeft = 0; | ||||
|   let lastWidth = 0; | ||||
| 
 | ||||
|   const getNearestDistance = ( | ||||
|     srcRect: { x: number; width: number }, | ||||
|     prevEl: Element | null, | ||||
|     nextEl: Element | null, | ||||
|     offset?: number, | ||||
|   ) => { | ||||
|     if (!offset || offset === 0) return [0, 0] as const; | ||||
|     if (offset > 0) { | ||||
|       if (!nextEl) return [0, 0] as const; | ||||
|       const rect = nextEl.getBoundingClientRect(); | ||||
|       return [ | ||||
|         (rect.x - srcRect.x) * offset, | ||||
|         (rect.width - srcRect.width) * offset, | ||||
|       ] as const; | ||||
|     } else { | ||||
|       if (!prevEl) return [0, 0] as const; | ||||
|       const rect = prevEl.getBoundingClientRect(); | ||||
|       return [ | ||||
|         (rect.x - srcRect.x) * offset, | ||||
|         (srcRect.width - rect.width) * offset, | ||||
|       ] as const; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const focusBoundingClientRect = () => { | ||||
|     return focusOn() | ||||
|       .map((x) => x.getBoundingClientRect()) | ||||
|       .reduce( | ||||
|         (p, c) => { | ||||
|           return { | ||||
|             x: Math.min(p.x, c.x), | ||||
|             width: p.width + c.width, | ||||
|           }; | ||||
|         }, | ||||
|         { x: +Infinity, width: 0 }, | ||||
|       ); | ||||
|   }; | ||||
| 
 | ||||
|   const focusSiblings = () => { | ||||
|     const rects = focusOn().map((x) => [x, x.getBoundingClientRect()] as const); | ||||
|     if (rects.length === 0) return [null, null] as const; | ||||
|     rects.sort(([, rect1], [, rect2]) => rect1.x - rect2.x); | ||||
|     return [ | ||||
|       rects[0][0].previousElementSibling, | ||||
|       rects[rects.length - 1][0].nextElementSibling, | ||||
|     ] as const; | ||||
|   }; | ||||
| 
 | ||||
|   const indicator = () => { | ||||
|     const el = focusOn(); | ||||
|     if (!el) { | ||||
|       return ["0px", "0px", "110ms", "110ms"] as const; | ||||
|     } | ||||
|     const rect = focusBoundingClientRect(); | ||||
|     const rootRect = self.getBoundingClientRect(); | ||||
|     const left = rect.x - rootRect.x; | ||||
|     const width = rect.width; | ||||
|     const [prevEl, nextEl] = focusSiblings(); | ||||
|     const [offset, widthChange] = getNearestDistance( | ||||
|       rect, | ||||
|       prevEl, | ||||
|       nextEl, | ||||
|       props.offset, | ||||
|     ); | ||||
|     const result = [ | ||||
|       `${left + offset}px`, | ||||
|       `${width + widthChange}px`, | ||||
|       `${Math.max(Math.floor(Math.abs(left + offset - lastLeft)), 160) * ANIM_SPEED}ms`, | ||||
|       `${Math.max(Math.floor(Math.abs(width - lastWidth)), 160) * ANIM_SPEED}ms`, | ||||
|     ] as const; | ||||
|     lastLeft = left; | ||||
|     lastWidth = width; | ||||
|     return result; | ||||
|   }; | ||||
| 
 | ||||
|   css` | ||||
|     .tablist { | ||||
|       width: 100%; | ||||
|       position: relative; | ||||
|       white-space: nowrap; | ||||
|       overflow-x: auto; | ||||
| 
 | ||||
|       &::after { | ||||
|         transition: | ||||
|           left ${indicator()[2]} var(--tutu-anim-curve-std), | ||||
|           width ${indicator()[3]} var(--tutu-anim-curve-std); | ||||
|         position: absolute; | ||||
|         content: ""; | ||||
|         display: block; | ||||
|         background-color: white; | ||||
|         height: 2px; | ||||
|         width: ${indicator()[1]}; | ||||
|         left: ${indicator()[0]}; | ||||
|         bottom: 0; | ||||
|       } | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   return ( | ||||
|     <TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}> | ||||
|       <div ref={self!} class="tablist" role="tablist"> | ||||
|         {props.children} | ||||
|       </div> | ||||
|     </TabListContext.Provider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Tabs; | ||||
							
								
								
									
										80
									
								
								src/material/TextField.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/material/TextField.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,80 @@ | |||
| import { | ||||
|   Component, | ||||
|   createEffect, | ||||
|   createSignal, | ||||
|   createUniqueId, | ||||
|   onMount, | ||||
|   Show, | ||||
| } from "solid-js"; | ||||
| import formStyles from "./form.module.css"; | ||||
| 
 | ||||
| export type TextFieldProps = { | ||||
|   label?: string; | ||||
|   helperText?: string; | ||||
|   type?: "text" | "password"; | ||||
|   onChange?: (value: string) => void; | ||||
|   onInput?: (value: string) => void; | ||||
|   inputId?: string; | ||||
|   error?: boolean; | ||||
|   required?: boolean; | ||||
|   name?: string; | ||||
| }; | ||||
| 
 | ||||
| const TextField: Component<TextFieldProps> = (props) => { | ||||
|   let input: HTMLInputElement; | ||||
|   let field: HTMLDivElement; | ||||
|   const [hasContent, setHasContent] = createSignal(false); | ||||
|   const altInputId = createUniqueId(); | ||||
| 
 | ||||
|   createEffect(() => { | ||||
|     if (hasContent()) { | ||||
|       field.classList.add("float-label"); | ||||
|     } else { | ||||
|       field.classList.remove("float-label"); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     setHasContent(input.value.length > 0); | ||||
|   }); | ||||
| 
 | ||||
|   const onInputChange = (e: { currentTarget: HTMLInputElement }) => { | ||||
|     const value = (e.currentTarget as HTMLInputElement).value; | ||||
|     setHasContent(value.length > 0); | ||||
|     props.onInput?.(value); | ||||
|   }; | ||||
| 
 | ||||
|   const inputId = () => props.inputId ?? altInputId; | ||||
| 
 | ||||
|   const fieldClass = () => { | ||||
|     const cls = [formStyles.textfield]; | ||||
|     if (typeof props.helperText !== "undefined") { | ||||
|       cls.push(formStyles.withHelperText); | ||||
|     } | ||||
|     if (props.error) { | ||||
|       cls.push(formStyles.error); | ||||
|     } | ||||
|     return cls.join(" "); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div ref={field!} class={fieldClass()}> | ||||
|       <label for={inputId()}>{props.label}</label> | ||||
|       <input | ||||
|         ref={input!} | ||||
|         id={inputId()} | ||||
|         type={props.type ?? "text"} | ||||
|         onInput={onInputChange} | ||||
|         onChange={(e) => props.onChange?.(e.currentTarget.value)} | ||||
|         placeholder="" | ||||
|         required={props.required} | ||||
|         name={props.name} | ||||
|       /> | ||||
|       <Show when={typeof props.helperText !== "undefined"}> | ||||
|         <span class={formStyles.helperText}>{props.helperText}</span> | ||||
|       </Show> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default TextField; | ||||
							
								
								
									
										55
									
								
								src/material/cards.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/material/cards.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | |||
| .card { | ||||
|   composes: surface from 'material.module.css'; | ||||
|   border-radius: 2px; | ||||
|   box-shadow: var(--tutu-shadow-e2); | ||||
|   transition: var(--tutu-transition-shadow); | ||||
|   overflow: hidden; | ||||
|   background-color: var(--tutu-color-surface-l); | ||||
| 
 | ||||
|   &:focus-within, | ||||
|   &:focus-visible { | ||||
|     box-shadow: var(--tutu-shadow-e8); | ||||
|   } | ||||
| 
 | ||||
|   &:not(.manualMargin) { | ||||
|     &>:not(.cardNoPad) { | ||||
|       margin-inline: var(--card-pad, 20px); | ||||
|     } | ||||
| 
 | ||||
|     > :not(.cardGutSkip):first-child { | ||||
|       margin-top: var(--card-gut, 20px); | ||||
|     } | ||||
| 
 | ||||
|     >.cardGutSkip+*:not(.cardGutSkip) { | ||||
|       margin-top: var(--card-gut, 20px); | ||||
|     } | ||||
| 
 | ||||
|     > :not(.cardGutSkip):last-child { | ||||
|       margin-bottom: var(--card-gut, 20px); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| .layoutCentered { | ||||
|   position: absolute; | ||||
|   left: 50%; | ||||
|   top: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   width: 448px; | ||||
| 
 | ||||
|   @media (max-width: 600px) { | ||||
|     & { | ||||
|       position: static; | ||||
|       height: 100%; | ||||
|       width: 100%; | ||||
|       left: 0; | ||||
|       right: 0; | ||||
|       transform: none; | ||||
|       display: grid; | ||||
|       grid-template-rows: 1fr auto; | ||||
|       height: 100vh; | ||||
|       overflow: auto; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										80
									
								
								src/material/form.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/material/form.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,80 @@ | |||
| .textfield { | ||||
|   composes: touchTarget from 'material.module.css'; | ||||
| 
 | ||||
|   --border-color: var(--tutu-color-inactive-on-surface); | ||||
|   --active-border-color: var(--tutu-color-primary); | ||||
|   --label-color: var(--tutu-color-inactive-on-surface); | ||||
|   --active-label-color: var(--tutu-color-primary); | ||||
|   --helper-text-color: var(--tutu-color-inactive-on-surface); | ||||
| 
 | ||||
|   &>* { | ||||
|       width: 100%; | ||||
|   } | ||||
| 
 | ||||
|   &.error, &:has(>input[aria-invalid="true"]) { | ||||
|       &:not(:focus-within) { | ||||
|           --border-color: var(--tutu-color-error-on-surface); | ||||
|           --label-color: var(--tutu-color-error-on-surface); | ||||
|           --helper-text-color: var(--tutu-color-error-on-surface); | ||||
|       } | ||||
| 
 | ||||
|       &:focus-within { | ||||
|           --helper-text-color: var(--tutu-color-error-on-surface); | ||||
|       } | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   position: relative; | ||||
| 
 | ||||
|   &>label { | ||||
|       position: absolute; | ||||
|       left: 0; | ||||
|       bottom: calc(10px + var(--bottom-height, 0px)); | ||||
|       color: var(--label-color); | ||||
|       transition: bottom .2s ease-in-out, font-size .2s ease-in-out, color .2s ease-in-out; | ||||
|       cursor: text; | ||||
|       font-size: 0.8125rem; | ||||
|   } | ||||
| 
 | ||||
|   &>label:has(+ input:not(:placeholder-shown)) { | ||||
|       bottom: calc(100% - 0.8125rem); | ||||
|   } | ||||
| 
 | ||||
|   &:focus-within>label, &.float-label>label { | ||||
|       bottom: calc(100% - 0.8125rem); | ||||
|       color: var(--active-label-color); | ||||
|   } | ||||
| 
 | ||||
|   &>input[type='text'], | ||||
|   &>input[type='password'] { | ||||
|       border: none; | ||||
|       outline: none; | ||||
|       border-bottom: 1px solid var(--border-color); | ||||
|       background-color: transparent; | ||||
|       padding-top: 16px; | ||||
|       padding-bottom: 8px; | ||||
|       margin-bottom: 1px; | ||||
|       transition: border-color .2s ease-in-out; | ||||
| 
 | ||||
|       &:focus { | ||||
|           border-bottom: 2px solid var(--active-border-color); | ||||
|           margin-bottom: 0; | ||||
|       } | ||||
|   } | ||||
| 
 | ||||
|   &.withHelperText { | ||||
|       --bottom-height: 0.8125rem; | ||||
|   } | ||||
| 
 | ||||
|   & .helperText { | ||||
|       color: var(--helper-text-color); | ||||
|       font-size: 0.8125rem; | ||||
|       line-height: 100%; | ||||
|       -webkit-line-clamp: 1; | ||||
|       line-clamp: 1; | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|       min-height: 0.8125rem; | ||||
|       cursor: auto; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										65
									
								
								src/material/material.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/material/material.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,65 @@ | |||
| .surface { | ||||
|   background-color: var(--tutu-color-surface); | ||||
|   color: var(--tutu-color-on-surface); | ||||
| } | ||||
| 
 | ||||
| .touchTarget { | ||||
|   min-width: 44px; | ||||
|   min-height: 44px; | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .button { | ||||
|   composes: buttonText from './typography.module.css'; | ||||
|   composes: touchTarget; | ||||
| 
 | ||||
|   border: none; | ||||
|   background-color: transparent; | ||||
|   color: var(--tutu-color-primary); | ||||
|   font-family: inherit; | ||||
| 
 | ||||
|   &:focus,&:hover,&:focus-visible { | ||||
|       background-color: var(--tutu-color-surface-dd); | ||||
|   } | ||||
| 
 | ||||
|   &.pressed { | ||||
|       background-color: var(--tutu-color-surface-d); | ||||
|   } | ||||
| 
 | ||||
|   &.raised { | ||||
|       background-color: var(--tutu-color-primary); | ||||
|       color: var(--tutu-color-on-primary); | ||||
|   } | ||||
| 
 | ||||
|   &:disabled, &[aria-disabled]:not([aria-disabled="false"]) { | ||||
|       color: #9e9e9e; | ||||
| 
 | ||||
|       &:focus,&:hover,&:focus-visible { | ||||
|           background-color: transparent; | ||||
|       } | ||||
|   } | ||||
| 
 | ||||
|   .toolbar &, .appbar & { | ||||
|     height: 100%; | ||||
|     margin-block: 0; | ||||
|     padding-block: 0; | ||||
|     border-radius: 0; | ||||
|   } | ||||
| 
 | ||||
|   .appbar & { | ||||
|     color: var(--tutu-color-on-primary); | ||||
| 
 | ||||
|     &:focus,&:hover,&:focus-visible { | ||||
|       background-color: var(--tutu-color-primary-ll); | ||||
|     } | ||||
| 
 | ||||
|     &.pressed { | ||||
|       background-color: var(--tutu-color-primary-l); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .toolbar & { | ||||
|     color: var(--tutu-color-on-surface); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										16
									
								
								src/material/mui.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/material/mui.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { Theme, createTheme } from "@suid/material/styles"; | ||||
| import { deepPurple, amber } from "@suid/material/colors"; | ||||
| import { Accessor } from "solid-js"; | ||||
| 
 | ||||
| export function useRootTheme() : Accessor<Theme> { | ||||
|   return () => createTheme({ | ||||
|     palette: { | ||||
|       primary: { | ||||
|         main: deepPurple[500] | ||||
|       }, | ||||
|       secondary: { | ||||
|         main: amber.A200 | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| } | ||||
							
								
								
									
										135
									
								
								src/material/theme.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								src/material/theme.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,135 @@ | |||
| :root, | ||||
| [lang^="en"], [lang="en"] { | ||||
|   --md-typography-type: "regular"; | ||||
|   --title-size: 1.25rem; | ||||
|   --title-weight: 500; | ||||
|   --subheading-size: 1.125rem; | ||||
|   --body-size: 1rem; | ||||
|   --body2-weight: 500; | ||||
|   --caption-size: 0.875rem; | ||||
|   --button-size: 1rem; | ||||
|   --button-weight: 500; | ||||
|   --button-text-transform: uppercase; | ||||
| 
 | ||||
|   @media (min-width: 1024px) { | ||||
|     & { | ||||
|       --subheading-size: 1.0625rem; | ||||
|       --body-size: 0.9375rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| [lang^="zh"], [lang="zh"], | ||||
| [lang^="kr"], [lang="kr"], | ||||
| [lang^="ja"], [lang="ja"] { | ||||
|   --md-typography-type: "dense"; | ||||
|   --title-size: 1.4375rem; | ||||
|   --subheading-size: 1.1875rem; | ||||
|   --body-size: 1.0625rem; | ||||
|   --caption-size: 0.9375rem; | ||||
|   --button-size: 1.0625rem; | ||||
|   --button-text-transform: none; | ||||
| 
 | ||||
|   @media (min-width: 1024px) { | ||||
|     & { | ||||
|       --subheading-size: 1.125rem; | ||||
|       --body-size: 1rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| :root { | ||||
|   --tutu-color-primary: #673ab7; | ||||
|   /* Deep Purple 500 */ | ||||
|   --tutu-color-on-primary: white; | ||||
|   --tutu-color-primary-d: #512da8; | ||||
|   /* 700 */ | ||||
|   --tutu-color-on-primary-d: white; | ||||
|   --tutu-color-primary-dd: #4527a0; | ||||
|   /* 800 */ | ||||
|   --tutu-color-on-primary-dd: white; | ||||
|   --tutu-color-primary-l: #9575cd; | ||||
|   /* 200 */ | ||||
|   --tutu-color-on-primary-l: white; | ||||
|   --tutu-color-primary-ll: #b39ddb; | ||||
|   /* 100 */ | ||||
|   --tutu-color-on-primary-ll: black; | ||||
| 
 | ||||
|   --tutu-color-secondary: #ffd740; | ||||
|   /* Amber A200 */ | ||||
|   --tutu-color-on-secondary: black; | ||||
| 
 | ||||
|   --tutu-color-surface-l: white; | ||||
|   --tutu-color-surface: #fafafa; | ||||
|   --tutu-color-surface-d: #99999928; | ||||
|   --tutu-color-surface-dd: #99999920; | ||||
|   --tutu-color-on-surface: black; | ||||
|   --tutu-color-secondary-text-on-surface: rgba(0, 0, 0, 0.5); | ||||
|   --tutu-color-error-on-surface: #d32f2f; | ||||
|   --tutu-color-inactive-on-surface: #757575; | ||||
| 
 | ||||
|   --tutu-shadow-e1: 0px 1px 2px 0px #9e9e9e; | ||||
|   /* Switch */ | ||||
|   --tutu-shadow-e2: 0px 2px 4px 0px #9e9e9e; | ||||
|   /* (Resting) cards, raised button, quick entry / search bar */ | ||||
|   --tutu-shadow-e3: 0px 3px 6px 0px #9e9e9e; | ||||
|   /* Refresh indicator, quick entry / search bar (scrolled) */ | ||||
|   --tutu-shadow-e4: 0px 4px 8px 0px #9e9e9e; | ||||
|   /* App bar */ | ||||
|   --tutu-shadow-e6: 0px 6px 12px 0px #9e9e9e; | ||||
|   /* Snack bar, FAB (resting) */ | ||||
|   --tutu-shadow-e8: 0px 8px 16px 0px #9e9e9e; | ||||
|   /* Menu, (picked-up) cards, (pressed) raise button */ | ||||
|   --tutu-shadow-e9: 0px 9px 18px 0px #9e9e9e; | ||||
|   /* Submenu (+1dp for each submenu) */ | ||||
|   --tutu-shadow-e12: 0px 12px 24px 0px #9e9e9e; | ||||
|   /* (pressed) FAB */ | ||||
|   --tutu-shadow-e16: 0px 16px 32px 0px #9e9e9e; | ||||
|   /* Nav drawer, right drawer, modal bottom sheet */ | ||||
|   --tutu-shadow-e24: 0px 24px 48px 0px #9e9e9e; | ||||
|   /* Dialog, picker */ | ||||
| 
 | ||||
|   --tutu-anim-curve-std: cubic-bezier(0.4, 0, 0.2, 1); | ||||
|   --tutu-anim-curve-deceleration: cubic-bezier(0, 0, 0.2, 1); | ||||
|   --tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1); | ||||
|   --tutu-anim-curve-sharp: cubic-bezier(0.4, 0, 0.6, 1); | ||||
| 
 | ||||
|   @media (max-width: 300px) { | ||||
| 
 | ||||
|     /* XS screen, like wearables */ | ||||
|     & { | ||||
|       --tutu-transition-shadow: box-shadow 157.5ms var(--tutu-anim-curve-std); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @media (max-width: 600px) { | ||||
| 
 | ||||
|     /* Mobile */ | ||||
|     & { | ||||
|       --tutu-transition-shadow: box-shadow 225ms var(--tutu-anim-curve-std); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @media (max-width: 1200px) { | ||||
| 
 | ||||
|     /* Tablet */ | ||||
|     & { | ||||
|       --tutu-transition-shadow: box-shadow 292.5ms var(--tutu-anim-curve-std); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /* Desktop */ | ||||
|   --tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std); | ||||
| 
 | ||||
|   --tutu-zidx-nav: 1100; | ||||
| } | ||||
| 
 | ||||
| * { | ||||
|   font-family: Roboto, "Noto Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; | ||||
|   box-sizing: border-box; | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|   font-size: var(--body-size, 1rem); | ||||
| } | ||||
							
								
								
									
										0
									
								
								src/material/toolbar.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/material/toolbar.module.css
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										48
									
								
								src/material/typography.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/material/typography.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| .display4 { | ||||
|   font-size: 7rem; | ||||
|   font-weight: 300; | ||||
| } | ||||
| 
 | ||||
| .display3 { | ||||
|   font-size: 3.5rem; | ||||
| } | ||||
| 
 | ||||
| .display2 { | ||||
|   font-size: 2.8125rem; | ||||
| } | ||||
| 
 | ||||
| .display1 { | ||||
|   font-size: 2.125rem; | ||||
| } | ||||
| 
 | ||||
| .headline { | ||||
|   font-size: 1.5rem; | ||||
| } | ||||
| 
 | ||||
| .title { | ||||
|   font-size: var(--title-size); | ||||
|   font-weight: var(--title-weight); | ||||
| } | ||||
| 
 | ||||
| .subheading { | ||||
|   font-size: var(--subheading-size); | ||||
| } | ||||
| 
 | ||||
| .body1 { | ||||
|   font-size: var(--body-size); | ||||
| } | ||||
| 
 | ||||
| .body2 { | ||||
|   composes: body1; | ||||
|   font-weight: var(--body2-weight); | ||||
| } | ||||
| 
 | ||||
| .caption { | ||||
|   font-size: var(--caption-size); | ||||
| } | ||||
| 
 | ||||
| .buttonText { | ||||
|   font-weight: var(--button-weight); | ||||
|   font-size: var(--button-size); | ||||
|   text-transform: var(--button-text-transform); | ||||
| } | ||||
							
								
								
									
										87
									
								
								src/material/typography.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/material/typography.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,87 @@ | |||
| import { JSX, ParentComponent, splitProps, type Ref } from "solid-js"; | ||||
| import { Dynamic } from "solid-js/web"; | ||||
| import typography from "./typography.module.css"; | ||||
| import { mergeClass } from "../utils"; | ||||
| 
 | ||||
| type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any> | ||||
| 
 | ||||
| type PropsOf<E extends AnyElement> = | ||||
|   E extends ParentComponent<infer Props> | ||||
|     ? Props | ||||
|     : E extends keyof JSX.IntrinsicElements | ||||
|       ? JSX.IntrinsicElements[E] | ||||
|       : JSX.HTMLAttributes<HTMLElement>; | ||||
| 
 | ||||
| export type TypographyProps< | ||||
|   E extends AnyElement, | ||||
| > = { | ||||
|   ref?: Ref<E>; | ||||
|   component?: E; | ||||
|   class?: string; | ||||
| } & PropsOf<E>; | ||||
| 
 | ||||
| type TypographyKind = | ||||
|   | "display4" | ||||
|   | "display3" | ||||
|   | "display2" | ||||
|   | "display1" | ||||
|   | "headline" | ||||
|   | "title" | ||||
|   | "subheading" | ||||
|   | "body1" | ||||
|   | "body2" | ||||
|   | "caption" | ||||
|   | "buttonText"; | ||||
| 
 | ||||
| export function Typography<T extends AnyElement>(props: {typography: TypographyKind } & TypographyProps<T>) { | ||||
|   const [managed, passthough] = splitProps(props, [ | ||||
|     "ref", | ||||
|     "component", | ||||
|     "class", | ||||
|     "typography", | ||||
|   ]); | ||||
|   const classes = () => | ||||
|     mergeClass(managed.class, typography[managed.typography]); | ||||
|   return ( | ||||
|     <Dynamic | ||||
|       ref={managed.ref} | ||||
|       component={managed.component ?? "span"} | ||||
|       class={classes()} | ||||
|       {...passthough} | ||||
|     ></Dynamic> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export function Display4<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"display4"} {...props}></Typography> | ||||
| } | ||||
| export function Display3<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"display3"} {...props}></Typography> | ||||
| } | ||||
| export function Display2<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"display2"} {...props}></Typography> | ||||
| } | ||||
| export function Display1<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"display1"} {...props}></Typography> | ||||
| } | ||||
| export function Headline<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"headline"} {...props}></Typography> | ||||
| } | ||||
| export function Title<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|     return <Typography typography={"title"} {...props}></Typography> | ||||
| } | ||||
| export function Subheading<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"subheading"} {...props}></Typography> | ||||
| } | ||||
| export function Body1<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"body1"} {...props}></Typography> | ||||
| } | ||||
| export function Body2<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"body2"} {...props}></Typography> | ||||
| } | ||||
| export function Caption<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"caption"} {...props}></Typography> | ||||
| } | ||||
| export function ButtonText<E extends AnyElement>(props: TypographyProps<E>) { | ||||
|   return <Typography typography={"buttonText"} {...props}></Typography> | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/platform/anim.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/platform/anim.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { createContext, useContext, type Accessor } from "solid-js"; | ||||
| 
 | ||||
| export type HeroSource = {[key: string | symbol | number]: HTMLElement | undefined} | ||||
| 
 | ||||
| const HeroSourceContext = createContext<Accessor<HeroSource>>(() => ({})) | ||||
| 
 | ||||
| export const HeroSourceProvider = HeroSourceContext.Provider | ||||
| 
 | ||||
| export function useHeroSource() { | ||||
|   return useContext(HeroSourceContext) | ||||
| } | ||||
							
								
								
									
										42
									
								
								src/platform/timesrc.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/platform/timesrc.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import { | ||||
|   Accessor, | ||||
|   createContext, | ||||
|   createRenderEffect, | ||||
|   createSignal, | ||||
|   onCleanup, | ||||
|   untrack, | ||||
|   useContext, | ||||
| } from "solid-js"; | ||||
| 
 | ||||
| const TimeSourceContext = createContext<Accessor<Date>>(); | ||||
| 
 | ||||
| export const TimeSourceProvider = TimeSourceContext.Provider; | ||||
| 
 | ||||
| export function createTimeSource() { | ||||
|   let id: number | undefined; | ||||
|   const [get, set] = createSignal(new Date()); | ||||
| 
 | ||||
|   createRenderEffect(() => | ||||
|     untrack(() => { | ||||
|       id = setTimeout(() => { | ||||
|         set(new Date()); | ||||
|       }, 30 * 1000); | ||||
|     }), | ||||
|   ); | ||||
| 
 | ||||
|   onCleanup(() => { | ||||
|     if (typeof id !== "undefined") { | ||||
|       clearInterval(id); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   return get; | ||||
| } | ||||
| 
 | ||||
| export function useTimeSource() { | ||||
|   return ( | ||||
|     useContext(TimeSourceContext) ?? | ||||
|     (console.warn("useTimeSource() is used but no source is provided"), | ||||
|     createTimeSource()) | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										7
									
								
								src/settings/stores.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/settings/stores.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import { persistentMap } from "@nanostores/persistent"; | ||||
| 
 | ||||
| type Settings = { | ||||
|   onGoingOAuth2Process?: string | ||||
| } | ||||
| 
 | ||||
| export const $settings = persistentMap<Settings>("settings::", {}) | ||||
							
								
								
									
										57
									
								
								src/timelines/CompactToot.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/timelines/CompactToot.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { type Component } from "solid-js"; | ||||
| import tootStyle from "./toot.module.css"; | ||||
| import { formatRelative } from "date-fns"; | ||||
| import Img from "../material/Img"; | ||||
| import { Body2 } from "../material/typography"; | ||||
| import { css } from "solid-styled"; | ||||
| import { appliedCustomEmoji } from "../masto/toot"; | ||||
| import cardStyle from "../material/cards.module.css"; | ||||
| 
 | ||||
| type CompactTootProps = { | ||||
|   status: mastodon.v1.Status; | ||||
|   now: Date; | ||||
|   class?: string; | ||||
| }; | ||||
| 
 | ||||
| const CompactToot: Component<CompactTootProps> = (props) => { | ||||
|   const toot = () => props.status; | ||||
|   return ( | ||||
|     <section | ||||
|       class={[tootStyle.compact,  props.class || ""].join(" ")} | ||||
|       lang={toot().language || undefined} | ||||
|     > | ||||
|       <Img | ||||
|         src={toot().account.avatar} | ||||
|         class={[ | ||||
|           tootStyle.tootAvatar, | ||||
|         ].join(" ")} | ||||
|       /> | ||||
|       <div class={[tootStyle.compactAuthorGroup].join(' ')}> | ||||
|         <Body2 | ||||
|           ref={(e: { innerHTML: string }) => { | ||||
|             appliedCustomEmoji( | ||||
|               e, | ||||
|               toot().account.displayName, | ||||
|               toot().account.emojis, | ||||
|             ); | ||||
|           }} | ||||
|         ></Body2> | ||||
|         <span class={tootStyle.compactAuthorUsername}> | ||||
|           @{toot().account.username}@{new URL(toot().account.url).hostname} | ||||
|         </span> | ||||
|         <time datetime={toot().createdAt}> | ||||
|           {formatRelative(toot().createdAt, props.now)} | ||||
|         </time> | ||||
|       </div> | ||||
|       <div | ||||
|         ref={(e: { innerHTML: string }) => { | ||||
|           appliedCustomEmoji(e, toot().content, toot().emojis); | ||||
|         }} | ||||
|         class={[tootStyle.compactTootContent].join(' ')} | ||||
|       ></div> | ||||
|     </section> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default CompactToot; | ||||
							
								
								
									
										336
									
								
								src/timelines/Home.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								src/timelines/Home.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,336 @@ | |||
| import { | ||||
|   Component, | ||||
|   For, | ||||
|   onCleanup, | ||||
|   createSignal, | ||||
|   createEffect, | ||||
|   Show, | ||||
|   untrack, | ||||
|   onMount, | ||||
| } from "solid-js"; | ||||
| import { $accounts } from "../accounts/stores"; | ||||
| import { useDocumentTitle } from "../utils"; | ||||
| import { useStore } from "@nanostores/solid"; | ||||
| import { useMastoClientFor } from "../masto/clients"; | ||||
| import { type mastodon } from "masto"; | ||||
| import Scaffold from "../material/Scaffold"; | ||||
| import { | ||||
|   AppBar, | ||||
|   Button, | ||||
|   Fab, | ||||
|   LinearProgress, | ||||
|   ListItemSecondaryAction, | ||||
|   ListItemText, | ||||
|   MenuItem, | ||||
|   Switch, | ||||
|   Toolbar, | ||||
|   Typography, | ||||
| } from "@suid/material"; | ||||
| import { css } from "solid-styled"; | ||||
| import { TimeSourceProvider, createTimeSource } from "../platform/timesrc"; | ||||
| import TootThread from "./TootThread.js"; | ||||
| import { useAcctProfile } from "../masto/acct"; | ||||
| import ProfileMenuButton from "./ProfileMenuButton"; | ||||
| import Tabs from "../material/Tabs"; | ||||
| import Tab from "../material/Tab"; | ||||
| import { Create as CreateTootIcon } from "@suid/icons-material"; | ||||
| import { useTimeline } from "../masto/timelines"; | ||||
| import { makeEventListener } from "@solid-primitives/event-listener"; | ||||
| 
 | ||||
| const TimelinePanel: Component<{ | ||||
|   client: mastodon.rest.Client; | ||||
|   name: "home" | "public" | "trends"; | ||||
|   prefetch?: boolean; | ||||
| }> = (props) => { | ||||
|   const [timeline, { refetch: refetchTimeline, mutate: mutateTimeline }] = | ||||
|     useTimeline(() => | ||||
|       props.name !== "trends" | ||||
|         ? props.client.v1.timelines[props.name] | ||||
|         : props.client.v1.trends.statuses, | ||||
|     ); | ||||
| 
 | ||||
|   const tlEndObserver = new IntersectionObserver(() => { | ||||
|     if (untrack(() => props.prefetch) && !timeline.loading) | ||||
|       refetchTimeline({ direction: "old" }); | ||||
|   }); | ||||
| 
 | ||||
|   onCleanup(() => tlEndObserver.disconnect()); | ||||
| 
 | ||||
|   const onBookmark = async ( | ||||
|     index: number, | ||||
|     client: mastodon.rest.Client, | ||||
|     status: mastodon.v1.Status, | ||||
|   ) => { | ||||
|     const result = await (status.bookmarked | ||||
|       ? client.v1.statuses.$select(status.id).unbookmark() | ||||
|       : client.v1.statuses.$select(status.id).bookmark()); | ||||
|     mutateTimeline((o) => { | ||||
|       o[index] = result; | ||||
|       return o; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const onBoost = async ( | ||||
|     index: number, | ||||
|     client: mastodon.rest.Client, | ||||
|     status: mastodon.v1.Status, | ||||
|   ) => { | ||||
|     const reblogged = false; | ||||
|     mutateTimeline((o) => { | ||||
|       Object.assign(o[index].reblog ?? o[index], { | ||||
|         reblogged: !reblogged, | ||||
|       }); | ||||
|       return o; | ||||
|     }); | ||||
|     const result = reblogged | ||||
|       ? await client.v1.statuses.$select(status.id).unreblog() | ||||
|       : (await client.v1.statuses.$select(status.id).reblog()).reblog!; | ||||
|     mutateTimeline((o) => { | ||||
|       Object.assign(o[index].reblog ?? o[index], { | ||||
|         reblogged: result.reblogged, | ||||
|         reblogsCount: result.reblogsCount, | ||||
|       }); | ||||
|       return o; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div> | ||||
|         <For each={timeline()}> | ||||
|           {(item, index) => { | ||||
|             return ( | ||||
|               <TootThread | ||||
|                 status={item} | ||||
|                 onBoost={(...args) => onBoost(index(), ...args)} | ||||
|                 onBookmark={(...args) => onBookmark(index(), ...args)} | ||||
|                 client={props.client} | ||||
|               /> | ||||
|             ); | ||||
|           }} | ||||
|         </For> | ||||
|       </div> | ||||
| 
 | ||||
|       <div ref={(e) => tlEndObserver.observe(e)}></div> | ||||
|       <Show when={timeline.loading}> | ||||
|         <div class="loading-line" style={{ width: "100%" }}> | ||||
|           <LinearProgress /> | ||||
|         </div> | ||||
|       </Show> | ||||
|       <Show when={timeline.error}> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             padding: "20px 0", | ||||
|             "align-items": "center", | ||||
|             "justify-content": "center", | ||||
|           }} | ||||
|         > | ||||
|           <Button variant="contained" onClick={[refetchTimeline, "old"]}> | ||||
|             Retry | ||||
|           </Button> | ||||
|         </div> | ||||
|       </Show> | ||||
|       <Show when={!props.prefetch && !timeline.loading}> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             padding: "20px 0", | ||||
|             "align-items": "center", | ||||
|             "justify-content": "center", | ||||
|           }} | ||||
|         > | ||||
|           <Button variant="contained" onClick={[refetchTimeline, "old"]}> | ||||
|             Load More | ||||
|           </Button> | ||||
|         </div> | ||||
|       </Show> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Home: Component = () => { | ||||
|   let panelList: HTMLDivElement; | ||||
|   useDocumentTitle("Timelines"); | ||||
|   const accounts = useStore($accounts); | ||||
|   const now = createTimeSource(); | ||||
| 
 | ||||
|   const client = useMastoClientFor(() => accounts()[0]); | ||||
|   const [profile] = useAcctProfile(() => accounts()[0]); | ||||
| 
 | ||||
|   const [panelOffset, setPanelOffset] = createSignal(0); | ||||
|   const [prefetching, setPrefetching] = createSignal(true); | ||||
|   const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]); | ||||
|   const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [ | ||||
|     number, | ||||
|     number, | ||||
|   ]); | ||||
| 
 | ||||
|   let scrollEventLockReleased = true; | ||||
| 
 | ||||
|   const recalculateTabIndicator = () => { | ||||
|     scrollEventLockReleased = false; | ||||
|     try { | ||||
|       const { x: panelX, width: panelWidth } = | ||||
|         panelList.getBoundingClientRect(); | ||||
|       let minIdx = +Infinity, | ||||
|         maxIdx = -Infinity; | ||||
|       const items = panelList.querySelectorAll(".tab-panel"); | ||||
|       const ranges = Array.from(items).map((x) => { | ||||
|         const rect = x.getBoundingClientRect(); | ||||
|         const inlineStart = rect.x - panelX; | ||||
|         const inlineEnd = rect.width + inlineStart; | ||||
|         return [inlineStart, inlineEnd] as const; | ||||
|       }); | ||||
|       for (let i = 0; i < items.length; i++) { | ||||
|         const e = items.item(i); | ||||
|         const [inlineStart, inlineEnd] = ranges[i]; | ||||
|         if (inlineStart >= 0 && inlineEnd <= panelWidth) { | ||||
|           minIdx = Math.min(minIdx, i); | ||||
|           maxIdx = Math.max(maxIdx, i); | ||||
|           e.classList.add("active"); | ||||
|         } else { | ||||
|           e.classList.remove("active"); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (isFinite(minIdx) && isFinite(maxIdx)) { | ||||
|         setFocusRange([minIdx, maxIdx]); | ||||
|       } | ||||
|     } finally { | ||||
|       scrollEventLockReleased = true; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     makeEventListener(panelList, "scroll", () => { | ||||
|       if (scrollEventLockReleased) { | ||||
|         requestAnimationFrame(recalculateTabIndicator); | ||||
|       } | ||||
|     }); | ||||
|     makeEventListener(window, "resize", () => { | ||||
|       if (scrollEventLockReleased) { | ||||
|         requestAnimationFrame(recalculateTabIndicator); | ||||
|       } | ||||
|     }); | ||||
|     requestAnimationFrame(recalculateTabIndicator); | ||||
|   }); | ||||
| 
 | ||||
|   const isTabFocus = (idx: number) => { | ||||
|     const [start, end] = focusRange(); | ||||
|     if (!isFinite(start) || !isFinite(end)) return false; | ||||
|     return idx >= start && idx <= end; | ||||
|   }; | ||||
| 
 | ||||
|   const onTabClick = (idx: number) => { | ||||
|     const items = panelList.querySelectorAll(".tab-panel"); | ||||
|     if (items.length > idx) { | ||||
|       items.item(idx).scrollIntoView({ behavior: "smooth" }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   css` | ||||
|     .tab-panel { | ||||
|       overflow: visible auto; | ||||
|       max-width: 560px; | ||||
|       height: 100%; | ||||
|       padding: 40px 16px; | ||||
|       max-height: calc(100vh - var(--scaffold-topbar-height, 0px)); | ||||
|       max-height: calc(100dvh - var(--scaffold-topbar-height, 0px)); | ||||
|       scroll-snap-align: center; | ||||
| 
 | ||||
|       &:not(.active) { | ||||
|         overflow: hidden; | ||||
|       } | ||||
| 
 | ||||
|       @media (max-width: 600px) { | ||||
|         padding: 0; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .panel-list { | ||||
|       display: grid; | ||||
|       grid-auto-columns: 560px; | ||||
|       grid-auto-flow: column; | ||||
|       overflow-x: auto; | ||||
|       scroll-snap-type: x mandatory; | ||||
|       scroll-snap-stop: always; | ||||
|       height: calc(100vh - var(--scaffold-topbar-height, 0px)); | ||||
|       height: calc(100dvh - var(--scaffold-topbar-height, 0px)); | ||||
| 
 | ||||
|       @media (max-width: 600px) { | ||||
|         grid-auto-columns: 100%; | ||||
|       } | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   return ( | ||||
|     <Scaffold | ||||
|       topbar={ | ||||
|         <AppBar position="static"> | ||||
|           <Toolbar variant="dense" class="responsive"> | ||||
|             <Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}> | ||||
|               <Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}> | ||||
|                 Home | ||||
|               </Tab> | ||||
|               <Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}> | ||||
|                 Trending | ||||
|               </Tab> | ||||
|               <Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}> | ||||
|                 Public | ||||
|               </Tab> | ||||
|             </Tabs> | ||||
|             <ProfileMenuButton profile={profile()}> | ||||
|               <MenuItem onClick={(e) => setPrefetching((x) => !x)}> | ||||
|                 <ListItemText>Prefetch Toots</ListItemText> | ||||
|                 <ListItemSecondaryAction> | ||||
|                   <Switch checked={prefetching()}></Switch> | ||||
|                 </ListItemSecondaryAction> | ||||
|               </MenuItem> | ||||
|             </ProfileMenuButton> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       } | ||||
|       fab={ | ||||
|         <Fab color="secondary"> | ||||
|           <CreateTootIcon /> | ||||
|         </Fab> | ||||
|       } | ||||
|     > | ||||
|       <TimeSourceProvider value={now}> | ||||
|         <div class="panel-list" ref={panelList!}> | ||||
|           <div class="tab-panel"> | ||||
|             <div> | ||||
|               <TimelinePanel | ||||
|                 client={client()} | ||||
|                 name="home" | ||||
|                 prefetch={prefetching()} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="tab-panel"> | ||||
|             <div> | ||||
|               <TimelinePanel | ||||
|                 client={client()} | ||||
|                 name="trends" | ||||
|                 prefetch={prefetching()} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="tab-panel"> | ||||
|             <div> | ||||
|               <TimelinePanel | ||||
|                 client={client()} | ||||
|                 name="public" | ||||
|                 prefetch={prefetching()} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div></div> | ||||
|         </div> | ||||
|       </TimeSourceProvider> | ||||
|     </Scaffold> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default Home; | ||||
							
								
								
									
										80
									
								
								src/timelines/MediaAttachmentGrid.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/timelines/MediaAttachmentGrid.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,80 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { type Component, For, createSignal } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| import tootStyle from "./toot.module.css"; | ||||
| import { Portal } from "solid-js/web"; | ||||
| import MediaViewer, { MEDIA_VIEWER_HEROSRC } from "./MediaViewer"; | ||||
| import { HeroSourceProvider } from "../platform/anim"; | ||||
| 
 | ||||
| const MediaAttachmentGrid: Component<{ | ||||
|   attachments: mastodon.v1.MediaAttachment[]; | ||||
| }> = (props) => { | ||||
|   let rootRef: HTMLElement; | ||||
|   const [viewerIndex, setViewerIndex] = createSignal<number>(); | ||||
|   const viewerOpened = () => typeof viewerIndex() !== "undefined" | ||||
|   const gridTemplateColumns = () => { | ||||
|     const l = props.attachments.length; | ||||
|     if (l < 2) { | ||||
|       return "1fr"; | ||||
|     } | ||||
|     if (l < 4) { | ||||
|       return "repeat(2, 1fr)"; | ||||
|     } | ||||
|     return "repeat(3, 1fr)"; | ||||
|   }; | ||||
| 
 | ||||
|   const openViewerFor = (index: number) => { | ||||
|     setViewerIndex(index); | ||||
|   }; | ||||
| 
 | ||||
|   css` | ||||
|     .attachments { | ||||
|       grid-template-columns: ${gridTemplateColumns()}; | ||||
|     } | ||||
|   `;
 | ||||
|   return ( | ||||
|     <section | ||||
|       ref={rootRef!} | ||||
|       class={[tootStyle.tootAttachmentGrp, "attachments"].join(" ")} | ||||
|       onClick={(e) => e.stopImmediatePropagation()} | ||||
|     > | ||||
|       <For each={props.attachments}> | ||||
|         {(item, index) => { | ||||
|           switch (item.type) { | ||||
|             case "image": | ||||
|               return ( | ||||
|                 <img | ||||
|                   src={item.previewUrl} | ||||
|                   alt={item.description || undefined} | ||||
|                   onClick={[openViewerFor, index()]} | ||||
|                   loading="lazy" | ||||
|                 ></img> | ||||
|               ); | ||||
|             case "video": | ||||
|             case "gifv": | ||||
|             case "audio": | ||||
|             case "unknown": | ||||
|               return <div></div>; | ||||
|           } | ||||
|         }} | ||||
|       </For> | ||||
|       <HeroSourceProvider | ||||
|         value={() => ({ | ||||
|           [MEDIA_VIEWER_HEROSRC]: rootRef.children.item( | ||||
|             viewerIndex() || 0, | ||||
|           ) as HTMLElement, | ||||
|         })} | ||||
|       > | ||||
|         <MediaViewer | ||||
|           show={viewerOpened()} | ||||
|           index={viewerIndex() || 0} | ||||
|           onIndexUpdated={setViewerIndex} | ||||
|           media={props.attachments} | ||||
|           onClose={() => setViewerIndex(undefined)} | ||||
|         /> | ||||
|       </HeroSourceProvider> | ||||
|     </section> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default MediaAttachmentGrid; | ||||
							
								
								
									
										378
									
								
								src/timelines/MediaViewer.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										378
									
								
								src/timelines/MediaViewer.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,378 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { | ||||
|   For, | ||||
|   type Component, | ||||
|   type ParentComponent, | ||||
|   Switch, | ||||
|   Match, | ||||
|   createEffect, | ||||
|   createSignal, | ||||
|   type JSX, | ||||
|   onMount, | ||||
|   Index, | ||||
|   mergeProps, | ||||
|   requestCallback, | ||||
|   untrack, | ||||
| } from "solid-js"; | ||||
| import { css } from "solid-styled"; | ||||
| import { useHeroSource } from "../platform/anim"; | ||||
| import { Portal } from "solid-js/web"; | ||||
| import { createStore } from "solid-js/store"; | ||||
| import { IconButton, Toolbar } from "@suid/material"; | ||||
| import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material"; | ||||
| 
 | ||||
| type MediaViewerProps = { | ||||
|   show: boolean; | ||||
|   index: number; | ||||
|   media: mastodon.v1.MediaAttachment[]; | ||||
|   onIndexUpdated?: (newIndex: number) => void; | ||||
|   onClose?: () => void; | ||||
| }; | ||||
| 
 | ||||
| export const MEDIA_VIEWER_HEROSRC = Symbol("mediaViewerHeroSrc"); | ||||
| 
 | ||||
| function within(n: number, target: number, range: number) { | ||||
|   return n >= target - range || n <= target + range; | ||||
| } | ||||
| 
 | ||||
| function clamp(input: number, min: number, max: number) { | ||||
|   return Math.min(Math.max(input, min), max) | ||||
| } | ||||
| 
 | ||||
| const MediaViewer: ParentComponent<MediaViewerProps> = (props) => { | ||||
|   let rootRef: HTMLDialogElement; | ||||
| 
 | ||||
|   const heroSource = useHeroSource(); | ||||
|   const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC]; | ||||
|   type State = { | ||||
|     ref?: HTMLElement; | ||||
|     media: mastodon.v1.MediaAttachment; | ||||
|     top: number; | ||||
|     left: number; | ||||
|     scale: number; | ||||
|     osize: [number, number]; // width, height
 | ||||
|   }; | ||||
|   const [state, setState] = createStore<State[]>( | ||||
|     props.media.map( | ||||
|       (media) => | ||||
|         ({ | ||||
|           top: 0, | ||||
|           left: 0, | ||||
|           ref: undefined, | ||||
|           media, | ||||
|           scale: 1, | ||||
|           osize: [0, 9], | ||||
|         }) as State, | ||||
|     ), | ||||
|   ); | ||||
|   const [showControls, setShowControls] = createSignal(true); | ||||
|   const [dragging, setDragging] = createSignal(false); | ||||
| 
 | ||||
|   const hasPrev = () => state.length > 1 && props.index !== 0; | ||||
|   const hasNext = () => state.length > 1 && props.index < state.length - 1; | ||||
| 
 | ||||
|   css` | ||||
|     .media-viewer--root { | ||||
|       background: none; | ||||
|       border: none; | ||||
|       overflow: hidden; | ||||
|       margin: 0; | ||||
|       padding: 0; | ||||
|       outline: none; | ||||
|       max-width: 100%; | ||||
|       max-height: 100%; | ||||
|       height: 100%; | ||||
|       width: 100%; | ||||
| 
 | ||||
|       &[open] { | ||||
|         display: block; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .media-viewer { | ||||
|       display: grid; | ||||
|       grid-auto-flow: column; | ||||
|       grid-auto-columns: 100%; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       overflow: auto; | ||||
|       background-color: ${showControls() | ||||
|         ? "var(--tutu-color-surface)" | ||||
|         : "var(--tutu-color-on-surface)"}; | ||||
|       transition: background-color 0.2s var(--tutu-anim-curve-std); | ||||
|       scroll-behavior: smooth; | ||||
| 
 | ||||
|       > .media { | ||||
|         height: 100%; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .media { | ||||
|       overflow: hidden; | ||||
|       position: relative; | ||||
| 
 | ||||
|       > img { | ||||
|         position: absolute; | ||||
|         object-fit: contain; | ||||
|         top: 0; | ||||
|         left: 0; | ||||
|         transform-origin: center; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .media-ctrls { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       z-index: 1; | ||||
|       cursor: ${dragging() ? "grabbing" : "grab"}; | ||||
|     } | ||||
| 
 | ||||
|     .left-dock { | ||||
|       position: absolute; | ||||
|       left: 24px; | ||||
|       top: 50%; | ||||
|       transform: translateY(-50%) | ||||
|         ${showControls() && hasPrev() | ||||
|           ? "" | ||||
|           : "translateX(-100%) translateX(-24px)"}; | ||||
|       display: inline-block; | ||||
|       transition: transform 0.2s var(--tutu-anim-curve-std); | ||||
|     } | ||||
| 
 | ||||
|     .right-dock { | ||||
|       position: absolute; | ||||
|       right: 24px; | ||||
|       top: 50%; | ||||
|       transform: translateY(-50%) | ||||
|         ${showControls() && hasNext() | ||||
|           ? "" | ||||
|           : "translateX(100%) translateX(24px)"}; | ||||
|       display: inline-block; | ||||
|       transition: transform 0.2s var(--tutu-anim-curve-std); | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   createEffect(() => { | ||||
|     if (props.show) { | ||||
|       rootRef.showModal(); | ||||
|       untrack(() => { | ||||
|         for (let i = 0; i < state.length; i++) { | ||||
|           centre(state[i], i); | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       rootRef.close(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   createEffect(() => { | ||||
|     const viewer = rootRef.children.item(0)!; | ||||
|     const targetPageE = viewer.children.item(props.index + 1); | ||||
|     if (!targetPageE) return; | ||||
|     targetPageE.scrollIntoView(); | ||||
|   }); | ||||
| 
 | ||||
|   const minScaleOf = (state: State) => { | ||||
|     const { | ||||
|       ref, | ||||
|       osize: [width, height], | ||||
|     } = state; | ||||
|     const { width: parentWidth, height: parentHeight } = | ||||
|       ref!.parentElement!.getBoundingClientRect(); | ||||
|     if (height <= parentHeight && width <= parentWidth) { | ||||
|       return 1; | ||||
|     } | ||||
|     return Math.min(parentHeight / height, parentWidth / width); | ||||
|   }; | ||||
| 
 | ||||
|   // Position medias to the centre.
 | ||||
|   // This function is only available when the elements are layout.
 | ||||
|   const centre = ({ ref, osize: [width, height] }: State, idx: number) => { | ||||
|     const { width: parentWidth, height: parentHeight } = | ||||
|       ref!.parentElement!.getBoundingClientRect(); | ||||
|     const scale = | ||||
|       height <= parentHeight && width <= parentWidth | ||||
|         ? 1 | ||||
|         : Math.min(parentHeight / height, parentWidth / width); | ||||
|     const top = parentHeight / 2 - height / 2; | ||||
|     const left = parentWidth / 2 - width / 2; | ||||
|     setState(idx, { top, left, scale }); | ||||
|   }; | ||||
| 
 | ||||
|   const scale = ( | ||||
|     center: readonly [number, number], // left, top
 | ||||
|     move: number, | ||||
|     idx: number, | ||||
|   ) => { | ||||
|     const { ref, top: otop, left: oleft, scale: oscale, osize: [owidth, oheight] } = state[idx]; | ||||
|     const [cx, cy] = center; | ||||
|     const iy = clamp(cy - otop, 0, oheight), | ||||
|       ix = clamp(cx - oleft, 0, owidth); // in image coordinate system
 | ||||
|     const scale = move + oscale; | ||||
|     const oix = ix / oscale, | ||||
|       oiy = iy / oscale; | ||||
|     const nix = oix * scale, | ||||
|       niy = oiy * scale; | ||||
|     // Now we can calculate the center's move
 | ||||
| 
 | ||||
|     const { width: vw, height: vh } = | ||||
|       ref!.parentElement!.getBoundingClientRect(); | ||||
|     const top = vh / 2 - niy; | ||||
|     const left = vw / 2 - nix; | ||||
|     setState(idx, { top, left, scale }); | ||||
|   }; | ||||
| 
 | ||||
|   const movePrev = () => { | ||||
|     props.onIndexUpdated?.(Math.max(props.index - 1, 0)); | ||||
|   }; | ||||
| 
 | ||||
|   const moveNext = () => { | ||||
|     props.onIndexUpdated?.(Math.min(props.index + 1, state.length - 1)); | ||||
|   }; | ||||
| 
 | ||||
|   const ctrlWheel = (event: WheelEvent) => { | ||||
|     if (event.ctrlKey && event.deltaY !== 0) { | ||||
|       event.preventDefault(); | ||||
|       const center = [event.clientX, event.clientY] as const; | ||||
|       scale(center, -event.deltaY / event.clientY, props.index); | ||||
|     } else { | ||||
|       if (event.deltaX !== 0) { | ||||
|         event.preventDefault(); | ||||
|         if (event.deltaX > 0) { | ||||
|           moveNext(); | ||||
|         } else { | ||||
|           movePrev(); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let lastMousedown: [number, number, number] | null = null; // time, left, top
 | ||||
| 
 | ||||
|   const ctrlMouseDown = (event: MouseEvent) => { | ||||
|     if (event.buttons !== 1) return; | ||||
|     event.preventDefault(); | ||||
|     lastMousedown = [Date.now(), event.clientX, event.clientY]; | ||||
|     setDragging(true); | ||||
|   }; | ||||
| 
 | ||||
|   const ctrlMouseMove = (event: MouseEvent) => { | ||||
|     if (!lastMousedown) return; | ||||
|     event.preventDefault(); | ||||
|     const { movementX: mleft, movementY: mtop } = event; | ||||
|     setState(props.index, (o) => ({ left: o.left + mleft, top: o.top + mtop })); | ||||
|   }; | ||||
| 
 | ||||
|   const ctrlMouseUp = (event: MouseEvent) => { | ||||
|     if (lastMousedown !== null) { | ||||
|       event.preventDefault(); | ||||
|       const [time, left, top] = lastMousedown; | ||||
|       const { clientX: nleft, clientY: ntop } = event; | ||||
|       const now = Date.now(); | ||||
|       const target = event.target; | ||||
|       checkControls: { | ||||
|         if ( | ||||
|           target instanceof Element && | ||||
|           !target.classList.contains("media-ctrls") | ||||
|         ) { | ||||
|           // It's dispatched from sub controls, exits
 | ||||
|           break checkControls; | ||||
|         } | ||||
|         if ( | ||||
|           now - time < 250 && | ||||
|           within(left, nleft, 4) && | ||||
|           within(top, ntop, 4) | ||||
|         ) { | ||||
|           setShowControls((x) => !x); | ||||
|         } | ||||
|       } | ||||
|       lastMousedown = null; | ||||
|       setDragging(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <dialog ref={rootRef!} class="media-viewer--root"> | ||||
|       <div class="media-viewer"> | ||||
|         <div | ||||
|           class="media-ctrls" | ||||
|           onWheel={ctrlWheel} | ||||
|           onMouseDown={ctrlMouseDown} | ||||
|           onMouseUp={ctrlMouseUp} | ||||
|           onMouseMove={ctrlMouseMove} | ||||
|         > | ||||
|           <Toolbar | ||||
|             variant="dense" | ||||
|             sx={{ | ||||
|               backgroundColor: "var(--tutu-color-surface)", | ||||
|               transform: !showControls() ? "translateY(-100%)" : undefined, | ||||
|               transition: | ||||
|                 "transform 0.2s var(--tutu-anim-curve-std), box-shadow 0.2s var(--tutu-anim-curve-std)", | ||||
|               boxShadow: showControls() ? "var(--tutu-shadow-e6)" : undefined, | ||||
|             }} | ||||
|           > | ||||
|             <IconButton onClick={props.onClose}> | ||||
|               <Close /> | ||||
|             </IconButton> | ||||
|           </Toolbar> | ||||
|           <div class="left-dock"> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               onClick={(e) => (movePrev(), e.stopPropagation())} | ||||
|             > | ||||
|               <ArrowLeft /> | ||||
|             </IconButton> | ||||
|           </div> | ||||
|           <div class="right-dock"> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               onClick={(e) => (moveNext(), e.stopPropagation())} | ||||
|             > | ||||
|               <ArrowRight /> | ||||
|             </IconButton> | ||||
|           </div> | ||||
|         </div> | ||||
|         <Index each={state}> | ||||
|           {(item, index) => { | ||||
|             return ( | ||||
|               <div class="media" data-index={index}> | ||||
|                 <Switch | ||||
|                   fallback={ | ||||
|                     <pre>{JSON.stringify(item().media, undefined, 2)}</pre> | ||||
|                   } | ||||
|                 > | ||||
|                   <Match when={item().media.type === "image"}> | ||||
|                     <img | ||||
|                       ref={(r) => { | ||||
|                         setState(index, { ref: r }); | ||||
|                       }} | ||||
|                       onLoad={(e) => { | ||||
|                         const { naturalWidth: width, naturalHeight: height } = | ||||
|                           e.currentTarget; | ||||
|                         setState(index, { | ||||
|                           osize: [width, height], | ||||
|                         }); | ||||
|                       }} | ||||
|                       src={item().media.url || undefined} | ||||
|                       style={{ | ||||
|                         left: `${item().left}px`, | ||||
|                         top: `${item().top}px`, | ||||
|                         transform: `scale(${item().scale})`, | ||||
|                       }} | ||||
|                       alt={item().media.description || undefined} | ||||
|                     ></img> | ||||
|                   </Match> | ||||
|                 </Switch> | ||||
|               </div> | ||||
|             ); | ||||
|           }} | ||||
|         </Index> | ||||
|       </div> | ||||
|     </dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default MediaViewer; | ||||
							
								
								
									
										121
									
								
								src/timelines/ProfileMenuButton.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/timelines/ProfileMenuButton.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | |||
| import { | ||||
|   Avatar, | ||||
|   ButtonBase, | ||||
|   Divider, | ||||
|   ListItemAvatar, | ||||
|   ListItemIcon, | ||||
|   ListItemText, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
| } from "@suid/material"; | ||||
| import { Show, createSignal, createUniqueId, type ParentComponent } from "solid-js"; | ||||
| import { | ||||
|   Settings as SettingsIcon, | ||||
|   Bookmark as BookmarkIcon, | ||||
|   Star as LikeIcon, | ||||
|   FeaturedPlayList as ListIcon, | ||||
| } from "@suid/icons-material"; | ||||
| 
 | ||||
| const ProfileMenuButton: ParentComponent<{ | ||||
|   profile?: { displayName: string; avatar: string; username: string }; | ||||
|   onClick?: () => void; | ||||
|   onClose?: () => void; | ||||
| }> = (props) => { | ||||
|   const menuId = createUniqueId(); | ||||
|   const buttonId = createUniqueId(); | ||||
| 
 | ||||
|   let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null); | ||||
|   const open = () => !!anchor(); | ||||
| 
 | ||||
|   const onClick = ( | ||||
|     event: MouseEvent & { currentTarget: HTMLButtonElement }, | ||||
|   ) => { | ||||
|     setAnchor(event.currentTarget); | ||||
|     props.onClick?.(); | ||||
|   }; | ||||
| 
 | ||||
|   const onClose = () => { | ||||
|     props.onClick?.(); | ||||
|     setAnchor(null); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <ButtonBase | ||||
|         aria-haspopup="true" | ||||
|         sx={{ borderRadius: "50%" }} | ||||
|         id={buttonId} | ||||
|         onClick={onClick} | ||||
|         aria-controls={open() ? menuId : undefined} | ||||
|         aria-expanded={open() ? "true" : undefined} | ||||
|       > | ||||
|         <Avatar | ||||
|           alt={`${props.profile?.displayName}'s avatar`} | ||||
|           src={props.profile?.avatar} | ||||
|         ></Avatar> | ||||
|       </ButtonBase> | ||||
|       <Menu | ||||
|         id={menuId} | ||||
|         anchorEl={anchor()} | ||||
|         open={open()} | ||||
|         onClose={onClose} | ||||
|         MenuListProps={{ | ||||
|           "aria-labelledby": buttonId, | ||||
|           sx: { | ||||
|             minWidth: "220px", | ||||
|           } | ||||
|         }} | ||||
|         anchorOrigin={{ | ||||
|           vertical: "top", | ||||
|           horizontal: "right", | ||||
|         }} | ||||
|         transformOrigin={{ | ||||
|           vertical: "top", | ||||
|           horizontal: "right", | ||||
|         }} | ||||
|       > | ||||
|         <MenuItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar src={props.profile?.avatar}></Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText | ||||
|             primary={props.profile?.displayName} | ||||
|             secondary={`@${props.profile?.username}`} | ||||
|           ></ListItemText> | ||||
|         </MenuItem> | ||||
| 
 | ||||
|         <MenuItem> | ||||
|           <ListItemIcon> | ||||
|             <BookmarkIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText>Bookmarks</ListItemText> | ||||
|         </MenuItem> | ||||
|         <MenuItem> | ||||
|           <ListItemIcon> | ||||
|             <LikeIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText>Likes</ListItemText> | ||||
|         </MenuItem> | ||||
|         <MenuItem> | ||||
|           <ListItemIcon> | ||||
|             <ListIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText>Lists</ListItemText> | ||||
|         </MenuItem> | ||||
|         <Divider /> | ||||
|         <Show when={props.children}> | ||||
|           {props.children} | ||||
|           <Divider /> | ||||
|         </Show> | ||||
|         <MenuItem> | ||||
|           <ListItemIcon> | ||||
|             <SettingsIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText>Settings</ListItemText> | ||||
|         </MenuItem> | ||||
|       </Menu> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default ProfileMenuButton; | ||||
							
								
								
									
										259
									
								
								src/timelines/RegularToot.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								src/timelines/RegularToot.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,259 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { | ||||
|   splitProps, | ||||
|   type Component, | ||||
|   type JSX, | ||||
|   Show, | ||||
|   createRenderEffect, | ||||
| } from "solid-js"; | ||||
| import tootStyle from "./toot.module.css"; | ||||
| import { formatRelative } from "date-fns"; | ||||
| import Img from "../material/Img.js"; | ||||
| import { Body2 } from "../material/typography.js"; | ||||
| import { css } from "solid-styled"; | ||||
| import { | ||||
|   BookmarkAddOutlined, | ||||
|   Repeat, | ||||
|   ReplyAll, | ||||
|   Star, | ||||
|   StarOutline, | ||||
|   Bookmark, | ||||
|   Reply, | ||||
| } from "@suid/icons-material"; | ||||
| import { useTimeSource } from "../platform/timesrc.js"; | ||||
| import { resolveCustomEmoji } from "../masto/toot.js"; | ||||
| import { Divider } from "@suid/material"; | ||||
| import cardStyle from "../material/cards.module.css"; | ||||
| import Button from "../material/Button.js"; | ||||
| import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; | ||||
| 
 | ||||
| type TootContentViewProps = { | ||||
|   source?: string; | ||||
|   emojis?: mastodon.v1.CustomEmoji[]; | ||||
| } & JSX.HTMLAttributes<HTMLDivElement>; | ||||
| 
 | ||||
| const TootContentView: Component<TootContentViewProps> = (props) => { | ||||
|   const [managed, rest] = splitProps(props, ["source", "emojis"]); | ||||
|   return ( | ||||
|     <div | ||||
|       ref={(ref) => { | ||||
|         createRenderEffect(() => { | ||||
|           ref.innerHTML = managed.source | ||||
|             ? managed.emojis | ||||
|               ? resolveCustomEmoji(managed.source, managed.emojis) | ||||
|               : managed.source | ||||
|             : ""; | ||||
|         }); | ||||
|       }} | ||||
|       {...rest} | ||||
|     ></div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const RetootIcon: Component<JSX.HTMLElementTags["i"]> = (props) => { | ||||
|   const [managed, rest] = splitProps(props, ["class"]); | ||||
|   css` | ||||
|     .retoot-icon { | ||||
|       padding: 0; | ||||
|       display: inline-block; | ||||
|       border-radius: 2px; | ||||
| 
 | ||||
|       > :global(svg) { | ||||
|         color: green; | ||||
|         font-size: 1rem; | ||||
|         vertical-align: middle; | ||||
|       } | ||||
|     } | ||||
|   `;
 | ||||
|   return ( | ||||
|     <i class={["retoot-icon", managed.class].join(" ")} {...rest}> | ||||
|       <Repeat /> | ||||
|     </i> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const ReplyIcon: Component<JSX.HTMLElementTags["i"]> = (props) => { | ||||
|   const [managed, rest] = splitProps(props, ["class"]); | ||||
|   css` | ||||
|     .retoot-icon { | ||||
|       padding: 0; | ||||
|       display: inline-block; | ||||
|       border-radius: 2px; | ||||
| 
 | ||||
|       > :global(svg) { | ||||
|         color: var(--tutu-color-primary); | ||||
|         font-size: 1rem; | ||||
|         vertical-align: middle; | ||||
|       } | ||||
|     } | ||||
|   `;
 | ||||
|   return ( | ||||
|     <i class={["retoot-icon", managed.class].join(" ")} {...rest}> | ||||
|       <Reply /> | ||||
|     </i> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| type TootActionGroupProps<T extends mastodon.v1.Status> = { | ||||
|   onRetoot?: (value: T) => void; | ||||
|   onFavourite?: (value: T) => void; | ||||
|   onBookmark?: (value: T) => void; | ||||
|   onReply?: (value: T) => void; | ||||
| }; | ||||
| 
 | ||||
| type TootCardProps = { | ||||
|   status: mastodon.v1.Status; | ||||
|   actionable?: boolean; | ||||
|   evaluated?: boolean; | ||||
| } & TootActionGroupProps<mastodon.v1.Status> & | ||||
|   JSX.HTMLElementTags["article"]; | ||||
| 
 | ||||
| function isolatedCallback(e: MouseEvent) { | ||||
|   e.stopPropagation(); | ||||
| } | ||||
| 
 | ||||
| function TootActionGroup<T extends mastodon.v1.Status>( | ||||
|   props: TootActionGroupProps<T> & { value: T }, | ||||
| ) { | ||||
|   const toot = () => props.value; | ||||
|   return ( | ||||
|     <div class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback}> | ||||
|       <Button | ||||
|         class={tootStyle.tootActionWithCount} | ||||
|         onClick={() => props.onReply?.(toot())} | ||||
|       > | ||||
|         <ReplyAll /> | ||||
|         <span>{toot().repliesCount}</span> | ||||
|       </Button> | ||||
|       <Button | ||||
|         class={tootStyle.tootActionWithCount} | ||||
|         style={{ | ||||
|           color: toot().reblogged ? "var(--tutu-color-primary)" : undefined, | ||||
|         }} | ||||
|         onClick={() => props.onRetoot?.(toot())} | ||||
|       > | ||||
|         <Repeat /> | ||||
|         <span>{toot().reblogsCount}</span> | ||||
|       </Button> | ||||
|       <Button | ||||
|         class={tootStyle.tootActionWithCount} | ||||
|         style={{ | ||||
|           color: toot().favourited ? "var(--tutu-color-primary)" : undefined, | ||||
|         }} | ||||
|         onClick={() => props.onFavourite?.(toot())} | ||||
|       > | ||||
|         {toot().favourited ? <Star /> : <StarOutline />} | ||||
|         <span>{toot().favouritesCount}</span> | ||||
|       </Button> | ||||
|       <Button | ||||
|         class={tootStyle.tootAction} | ||||
|         style={{ | ||||
|           color: toot().bookmarked ? "var(--tutu-color-primary)" : undefined, | ||||
|         }} | ||||
|         onClick={() => props.onBookmark?.(toot())} | ||||
|       > | ||||
|         {toot().bookmarked ? <Bookmark /> : <BookmarkAddOutlined />} | ||||
|       </Button> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) { | ||||
|   const toot = () => props.status; | ||||
| 
 | ||||
|   return ( | ||||
|     <div class={tootStyle.tootAuthorGrp}> | ||||
|       <Img src={toot().account.avatar} class={tootStyle.tootAvatar} /> | ||||
|       <div class={tootStyle.tootAuthorNameGrp}> | ||||
|         <Body2 | ||||
|           class={tootStyle.tootAuthorNamePrimary} | ||||
|           ref={(e: { innerHTML: string }) => { | ||||
|             createRenderEffect(() => { | ||||
|               e.innerHTML = resolveCustomEmoji( | ||||
|                 toot().account.displayName, | ||||
|                 toot().account.emojis, | ||||
|               ); | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <time datetime={toot().createdAt}> | ||||
|           {formatRelative(toot().createdAt, props.now)} | ||||
|         </time> | ||||
|         <span> | ||||
|           @{toot().account.username}@{new URL(toot().account.url).hostname} | ||||
|         </span> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const RegularToot: Component<TootCardProps> = (props) => { | ||||
|   let rootRef: HTMLElement; | ||||
|   const [managed, managedActionGroup, rest] = splitProps( | ||||
|     props, | ||||
|     ["status", "lang", "class", "actionable", "evaluated"], | ||||
|     ["onRetoot", "onFavourite", "onBookmark", "onReply"], | ||||
|   ); | ||||
|   const now = useTimeSource(); | ||||
|   const status = () => managed.status; | ||||
|   const toot = () => status().reblog ?? status(); | ||||
| 
 | ||||
|   css` | ||||
|     .reply-sep { | ||||
|       margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px); | ||||
|       margin-block: 8px; | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <section | ||||
|         classList={{ | ||||
|           [tootStyle.toot]: true, | ||||
|           [tootStyle.expanded]: managed.evaluated, | ||||
|           [managed.class || ""]: true | ||||
|         }} | ||||
|         ref={rootRef!} | ||||
|         lang={toot().language || managed.lang} | ||||
|         {...rest} | ||||
|       > | ||||
|         <Show when={!!status().reblog}> | ||||
|           <div class={tootStyle.tootRetootGrp}> | ||||
|             <RetootIcon /> | ||||
|             <span> | ||||
|               <Body2 | ||||
|                 ref={(e: { innerHTML: string }) => { | ||||
|                   createRenderEffect(() => { | ||||
|                     e.innerHTML = resolveCustomEmoji( | ||||
|                       status().account.displayName, | ||||
|                       toot().emojis, | ||||
|                     ); | ||||
|                   }); | ||||
|                 }} | ||||
|               ></Body2>{" "} | ||||
|               boosted | ||||
|             </span> | ||||
|           </div> | ||||
|         </Show> | ||||
|         <TootAuthorGroup status={toot()} now={now()} /> | ||||
|         <TootContentView | ||||
|           source={toot().content} | ||||
|           emojis={toot().emojis} | ||||
|           class={tootStyle.tootContent} | ||||
|         /> | ||||
|         <Show when={toot().mediaAttachments.length > 0}> | ||||
|           <MediaAttachmentGrid attachments={toot().mediaAttachments} /> | ||||
|         </Show> | ||||
|         <Show when={managed.actionable}> | ||||
|           <Divider | ||||
|             class={cardStyle.cardNoPad} | ||||
|             style={{ "margin-top": "8px" }} | ||||
|           /> | ||||
|           <TootActionGroup value={toot()} {...managedActionGroup} /> | ||||
|         </Show> | ||||
|       </section> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default RegularToot; | ||||
							
								
								
									
										8
									
								
								src/timelines/TootBottomSheet.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/timelines/TootBottomSheet.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| import type { Component } from "solid-js"; | ||||
| 
 | ||||
| 
 | ||||
| const TootBottomSheet: Component = (props) => { | ||||
|   return <></> | ||||
| } | ||||
| 
 | ||||
| export default TootBottomSheet | ||||
							
								
								
									
										86
									
								
								src/timelines/TootThread.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/timelines/TootThread.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,86 @@ | |||
| import type { mastodon } from "masto"; | ||||
| import { Show, createResource, createSignal, type Component } from "solid-js"; | ||||
| import CompactToot from "./CompactToot"; | ||||
| import { useTimeSource } from "../platform/timesrc"; | ||||
| import RegularToot from "./RegularToot"; | ||||
| import cardStyle from "../material/cards.module.css"; | ||||
| import { css } from "solid-styled"; | ||||
| 
 | ||||
| type TootThreadProps = { | ||||
|   status: mastodon.v1.Status; | ||||
|   client: mastodon.rest.Client; | ||||
|   expanded?: 0 | 1 | 2; | ||||
| 
 | ||||
|   onBoost?(client: mastodon.rest.Client, status: mastodon.v1.Status): void; | ||||
|   onBookmark?(client: mastodon.rest.Client, status: mastodon.v1.Status): void; | ||||
| }; | ||||
| 
 | ||||
| const TootThread: Component<TootThreadProps> = (props) => { | ||||
|   const status = () => props.status; | ||||
|   const now = useTimeSource(); | ||||
|   const [expanded, setExpanded] = createSignal(false); | ||||
| 
 | ||||
|   const [inReplyTo] = createResource( | ||||
|     () => [props.client, status().inReplyToId || null] as const, | ||||
|     async ([client, replyToId]) => { | ||||
|       if (!(client && replyToId)) return null; | ||||
|       return await client.v1.statuses.$select(replyToId).fetch(); | ||||
|     }, | ||||
|   ); | ||||
| 
 | ||||
|   const boost = (status: mastodon.v1.Status) => { | ||||
|     props.onBoost?.(props.client, status); | ||||
|   }; | ||||
| 
 | ||||
|   const bookmark = (status: mastodon.v1.Status) => { | ||||
|     props.onBookmark?.(props.client, status); | ||||
|   }; | ||||
| 
 | ||||
|   css` | ||||
|     article { | ||||
|       transition: margin 90ms var(--tutu-anim-curve-sharp), var(--tutu-transition-shadow); | ||||
|       user-select: none; | ||||
|       cursor: pointer; | ||||
|     } | ||||
| 
 | ||||
|     .thread-line { | ||||
|       position: relative; | ||||
|       &::before { | ||||
|         content: ""; | ||||
|         position: absolute; | ||||
|         left: 36px; | ||||
|         top: 16px; | ||||
|         bottom: 0; | ||||
|         background-color: var(--tutu-color-secondary); | ||||
|         width: 2px; | ||||
|         display: block; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .expanded { | ||||
|       margin-block: 20px; | ||||
|       box-shadow: var(--tutu-shadow-e9); | ||||
|     } | ||||
|   `;
 | ||||
| 
 | ||||
|   return ( | ||||
|     <article classList={{ "thread-line": !!inReplyTo(), "expanded": expanded() }} onClick={() => setExpanded((x) => !x)}> | ||||
|       <Show when={inReplyTo()}> | ||||
|         <CompactToot | ||||
|           status={inReplyTo()!} | ||||
|           now={now()} | ||||
|           class={[cardStyle.card, cardStyle.manualMargin].join(" ")} | ||||
|         /> | ||||
|       </Show> | ||||
|       <RegularToot | ||||
|         status={status()} | ||||
|         class={cardStyle.card} | ||||
|         actionable={expanded()} | ||||
|         onBookmark={(s) => bookmark(s)} | ||||
|         onRetoot={(s) => boost(s)} | ||||
|       /> | ||||
|     </article> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default TootThread; | ||||
							
								
								
									
										209
									
								
								src/timelines/toot.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								src/timelines/toot.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,209 @@ | |||
| .toot { | ||||
|   --card-pad: 16px; | ||||
|   --card-gut: 16px; | ||||
|   --toot-avatar-size: 40px; | ||||
|   margin-block: 0; | ||||
| 
 | ||||
|   &.toot { | ||||
|     /* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */ | ||||
|     transition: margin-block 125ms var(--tutu-anim-curve-std), | ||||
|       height 225ms var(--tutu-anim-curve-std), | ||||
|       var(--tutu-transition-shadow); | ||||
|     border-radius: 0; | ||||
|   } | ||||
| 
 | ||||
|   &>.toot { | ||||
|     box-shadow: none; | ||||
|   } | ||||
| 
 | ||||
|   time { | ||||
|     color: var(--tutu-color-secondary-text-on-surface); | ||||
|   } | ||||
| 
 | ||||
|   & :global(.custom-emoji) { | ||||
|     height: 1em; | ||||
|     object-fit: contain; | ||||
|   } | ||||
| 
 | ||||
|   &.expanded { | ||||
|     margin-block: 20px; | ||||
|     box-shadow: var(--tutu-shadow-e9); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .tootAuthorGrp { | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 8px; | ||||
|   margin-bottom: 8px; | ||||
| 
 | ||||
|   > :not(:first-child) { | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .tootAuthorNameGrp { | ||||
|   display: grid; | ||||
|   grid-template-columns: 1fr auto; | ||||
| 
 | ||||
|   >* { | ||||
|     color: var(--tutu-color-secondary-text-on-surface); | ||||
|   } | ||||
| 
 | ||||
|   >:last-child { | ||||
|     grid-column: 1 /3; | ||||
|   } | ||||
| 
 | ||||
|   > time { | ||||
|     text-align: end; | ||||
|   } | ||||
| 
 | ||||
|   &:hover { | ||||
|     .tootAuthorNamePrimary { | ||||
|       text-decoration: underline; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .tootAuthorNamePrimary { | ||||
|   color: revert; | ||||
| } | ||||
| 
 | ||||
| .tootAvatar { | ||||
|   width: calc(var(--toot-avatar-size, 40px) - 1px); | ||||
|   aspect-ratio: 1/1; | ||||
|   object-fit: contain; | ||||
|   border-radius: 50% 50%; | ||||
|   overflow: hidden; | ||||
|   border: 1px solid var(--tutu-color-surface); | ||||
|   background-color: var(--tutu-color-surface-d); | ||||
| } | ||||
| 
 | ||||
| .tootContent { | ||||
|   composes: cardNoPad from '../material/cards.module.css'; | ||||
|   margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); | ||||
|   margin-right: var(--card-pad, 0); | ||||
|   line-height: 1.5; | ||||
| 
 | ||||
|   & a { | ||||
|     color: var(--tutu-color-primary-d); | ||||
|   } | ||||
| 
 | ||||
|   & :global(a[target="_blank"]) { | ||||
|     > :global(.invisible) { | ||||
|       display: none; | ||||
|     } | ||||
| 
 | ||||
|     > :global(.ellipsis) { | ||||
|       &::after { | ||||
|         display: inline; | ||||
|         content: "..."; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .compact { | ||||
|   display: grid; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   gap: 8px; | ||||
|   row-gap: 0; | ||||
|   padding-block: var(--card-gut, 16px); | ||||
|   padding-inline: var(--card-pad, 16px); | ||||
| 
 | ||||
|   > :first-child { | ||||
|     grid-row: 1/3; | ||||
|   } | ||||
| 
 | ||||
|   > :last-child { | ||||
|     grid-column: 2 /3; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .compactAuthorGroup { | ||||
|   display: flex; | ||||
|   gap: 8px; | ||||
|   align-items: center; | ||||
|   margin-bottom: 8px; | ||||
| 
 | ||||
|   > .compactAuthorUsername { | ||||
|     color: var(--tutu-color-secondary-text-on-surface); | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| 
 | ||||
|   > time { | ||||
|     color: var(--tutu-color-secondary-text-on-surface); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .compactTootContent { | ||||
|   composes: tootContent; | ||||
|   margin-left: 0; | ||||
|   margin-right: 0; | ||||
| } | ||||
| 
 | ||||
| .tootRetootGrp { | ||||
|   display: grid; | ||||
|   grid-template-columns: auto 1fr auto; | ||||
|   gap: 8px; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
| 
 | ||||
| .tootAttachmentGrp { | ||||
|   composes: cardNoPad from '../material/cards.module.css'; | ||||
|   margin-top: 1em; | ||||
|   margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); | ||||
|   margin-right: var(--card-pad, 0); | ||||
|   display: grid; | ||||
|   gap: 4px; | ||||
| 
 | ||||
|   >:where(img) { | ||||
|     max-height: 35vh; | ||||
|     min-height: 40px; | ||||
|     object-fit: none; | ||||
|     width: 100%; | ||||
|     background-color: var(--tutu-color-surface-d); | ||||
|     border-radius: 2px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .tootBottomActionGrp { | ||||
|   composes: cardGutSkip from '../material/cards.module.css'; | ||||
|   padding-block: calc((var(--card-gut) - 10px) / 2); | ||||
| 
 | ||||
|   animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding; | ||||
|   display: flex; | ||||
|   flex-flow: row wrap; | ||||
|   justify-content: space-evenly; | ||||
| 
 | ||||
|   > button{ | ||||
|     color: var(--tutu-color-on-surface); | ||||
|     padding: 10px 8px; | ||||
| 
 | ||||
|     > svg { | ||||
|       font-size: 20px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .tootActionWithCount { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 8px; | ||||
| } | ||||
| 
 | ||||
| .tootAction { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
| 
 | ||||
| @keyframes tootBottomExpanding { | ||||
|   0% { | ||||
|     opacity: 0; | ||||
|   } | ||||
| 
 | ||||
|   100% { | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										26
									
								
								src/utils.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/utils.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import { createRenderEffect, createSignal, onCleanup } from "solid-js"; | ||||
| 
 | ||||
| export function useDocumentTitle(newTitle?: string) { | ||||
|   const capturedTitle = document.title | ||||
|   const [title, setTitle] = createSignal(newTitle ?? capturedTitle) | ||||
| 
 | ||||
|   createRenderEffect(() => { | ||||
|     document.title = title() | ||||
|   }) | ||||
| 
 | ||||
|   onCleanup(() => { | ||||
|     document.title = capturedTitle | ||||
|   }) | ||||
| 
 | ||||
|   return setTitle | ||||
| } | ||||
| 
 | ||||
| export function mergeClass(c1: string | undefined, c2: string | undefined) { | ||||
|   if (!c1) { | ||||
|     return c2 | ||||
|   } | ||||
|   if (!c2) { | ||||
|     return c1 | ||||
|   } | ||||
|   return [c1, c2].join(' ') | ||||
| } | ||||
							
								
								
									
										15
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "strict": true, | ||||
|     "target": "ESNext", | ||||
|     "module": "esnext", | ||||
|     "moduleResolution": "node", | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "esModuleInterop": true, | ||||
|     "jsx": "preserve", | ||||
|     "jsxImportSource": "solid-js", | ||||
|     "types": ["vite/client"], | ||||
|     "noEmit": true, | ||||
|     "isolatedModules": true, | ||||
|   } | ||||
| } | ||||
							
								
								
									
										27
									
								
								vite.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								vite.config.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import { defineConfig } from "vite"; | ||||
| import solid from "vite-plugin-solid"; | ||||
| import solidStyled from "vite-plugin-solid-styled"; | ||||
| import suid from "@suid/vite-plugin"; | ||||
| import { VitePWA } from "vite-plugin-pwa"; | ||||
| 
 | ||||
| export default defineConfig(({ mode }) => ({ | ||||
|   plugins: [ | ||||
|     suid(), | ||||
|     solid(), | ||||
|     solidStyled({ | ||||
|       filter: { | ||||
|         include: "src/**/*.{tsx,jsx}", | ||||
|         exclude: "node_modules/**/*.{ts,js,tsx,jsx}", | ||||
|       }, | ||||
|     }), | ||||
|     VitePWA({ | ||||
|       registerType: "autoUpdate", | ||||
|     }), | ||||
|   ], | ||||
|   css: { | ||||
|     devSourcemap: true, | ||||
|   }, | ||||
|   build: { | ||||
|     target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"], | ||||
|   }, | ||||
| })); | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue