initial commit
This commit is contained in:
		
						commit
						4a80c8552b
					
				
					 46 changed files with 8309 additions and 0 deletions
				
			
		
							
								
								
									
										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(' ')
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue