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