tutu/src/profiles/Profile.tsx
2024-10-18 19:15:35 +08:00

253 lines
6.9 KiB
TypeScript

import {
createRenderEffect,
createResource,
createSignal,
For,
onCleanup,
Show,
type Component,
} from "solid-js";
import Scaffold from "../material/Scaffold";
import { AppBar, Avatar, Button, IconButton, Toolbar } from "@suid/material";
import { Close, MoreVert, Verified } from "@suid/icons-material";
import { Title } from "../material/typography";
import { useNavigate, useParams } from "@solidjs/router";
import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot";
import { FastAverageColor } from "fast-average-color";
import { useWindowSize } from "@solid-primitives/resize-observer";
import { css } from "solid-styled";
import { createTimeline } from "../masto/timelines";
import TootList from "../timelines/TootList";
import { createIntersectionObserver } from "@solid-primitives/intersection-observer";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
const Profile: Component = () => {
const navigate = useNavigate();
const params = useParams<{ acct: string; id: string }>();
const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText);
const [bannerSampledColors, setBannerSampledColors] = createSignal<{
average: string;
text: string;
}>();
const windowSize = useWindowSize();
const time = createTimeSource();
const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
const obx = new IntersectionObserver(
(entries) => {
const ent = entries[0];
if (ent.intersectionRatio < 0.1) {
setScrolledPastBanner(true);
} else {
setScrolledPastBanner(false);
}
},
{
threshold: 0.1,
},
);
onCleanup(() => obx.disconnect());
const [profile] = createResource(
() => [session().client, params.id] as const,
async ([client, id]) => {
return await client.v1.accounts.$select(id).fetch();
},
);
const [recentToots] = createTimeline(
() => session().client.v1.accounts.$select(params.id).statuses,
() => 20,
);
const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar;
const displayName = () =>
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
const fullUsername = () => `@${profile()?.acct ?? "..."}`; // TODO: full user name
const description = () => profile()?.note;
css`
.intro {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface);
padding: 16px 12px;
display: flex;
flex-flow: column nowrap;
gap: 16px;
}
.acct-grp {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px;
align-items: center;
}
.name-grp {
display: flex;
flex-flow: column nowrap;
}
table.acct-fields {
& td > :global(a) {
display: inline-flex;
min-height: 44px;
align-items: center;
color: inherit;
}
& :global(a > .invisible) {
display: none;
}
& :global(svg) {
vertical-align: middle;
}
}
.page-title {
flex-grow: 1;
}
`;
return (
<Scaffold
topbar={
<AppBar
position="static"
color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0}
>
<Toolbar
variant="dense"
sx={{
display: "flex",
color: bannerSampledColors()?.text,
paddingTop: "var(--safe-area-inset-top)",
}}
>
<IconButton color="inherit" onClick={[navigate, -1]}>
<Close />
</IconButton>
<Title
use:solid-styled
class="page-title"
style={{
visibility: scrolledPastBanner() ? undefined : "hidden",
}}
ref={(e: HTMLElement) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></Title>
<IconButton color="inherit">
<MoreVert />
</IconButton>
</Toolbar>
</AppBar>
}
>
<div
style={{
width: "100%",
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
"margin-top":
"calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)))",
}}
>
<img
ref={(e) => obx.observe(e)}
src={bannerImg()}
style={{
"object-fit": "contain",
width: "100%",
height: "100%",
}}
crossOrigin="anonymous"
onLoad={async (event) => {
const ins = new FastAverageColor();
const colors = ins.getColor(event.currentTarget);
setBannerSampledColors({
average: colors.hex,
text: colors.isDark ? "white" : "black",
});
}}
></img>
</div>
<div
class="intro"
style={{
"background-color": bannerSampledColors()?.average,
color: bannerSampledColors()?.text,
}}
>
<div class="acct-grp">
<Avatar
src={avatarImg()}
sx={{
marginTop: "calc(-16px - 72px / 2)",
width: "72px",
height: "72px",
}}
></Avatar>
<div class="name-grp">
<span
ref={(e) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></span>
<span>{fullUsername()}</span>
</div>
<div>
<Button variant="contained" color="secondary">
Subscribe
</Button>
</div>
</div>
<div
ref={(e) =>
createRenderEffect(() => (e.innerHTML = description() || ""))
}
></div>
<table class="acct-fields">
<tbody>
<For each={profile()?.fields ?? []}>
{(item, index) => {
return (
<tr data-field-index={index()}>
<td>{item.name}</td>
<td>
<Show when={item.verifiedAt}>
<Verified />
</Show>
</td>
<td
ref={(e) => {
createRenderEffect(() => (e.innerHTML = item.value));
}}
></td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
<TimeSourceProvider value={time}>
<TootList
threads={recentToots.list}
onUnknownThread={recentToots.getPath}
onChangeToot={recentToots.set}
/>
</TimeSourceProvider>
</Scaffold>
);
};
export default Profile;