Profile: filter recent toots
- material: new Menu component - material/theme: animation curves - TootFilterButton
This commit is contained in:
parent
08201cccef
commit
f8dc2950d2
5 changed files with 284 additions and 5 deletions
|
@ -149,7 +149,7 @@ const App: Component = () => {
|
|||
return <UnexpectedError error={err} />;
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme()}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<DateFnScope>
|
||||
<ClientProvider value={clients}>
|
||||
<ServiceWorkerProvider
|
||||
|
|
136
src/material/Menu.tsx
Normal file
136
src/material/Menu.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { MenuList } from "@suid/material";
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import { ANIM_CURVE_STD } from "./theme";
|
||||
|
||||
type Props = {
|
||||
open?: boolean;
|
||||
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
|
||||
anchor: () => DOMRect;
|
||||
};
|
||||
|
||||
function adjustMenuPosition(
|
||||
rect: DOMRect,
|
||||
[left, top]: [number, number],
|
||||
{ width, height }: { width: number; height: number },
|
||||
) {
|
||||
const ntop = rect.bottom > height ? top - (rect.bottom - height) : top;
|
||||
const nleft = rect.right > width ? left - (rect.right - width) : left;
|
||||
return [nleft, ntop] as [number, number];
|
||||
}
|
||||
|
||||
const Menu: ParentComponent<Props> = (props) => {
|
||||
let root: HTMLDialogElement;
|
||||
const [pos, setPos] = createSignal<[number, number]>([0, 0]);
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
const a = props.anchor();
|
||||
if (!root.open) {
|
||||
root.showModal();
|
||||
const rend = root.getBoundingClientRect();
|
||||
|
||||
setPos(adjustMenuPosition(rend, [a.left, a.top], windowSize));
|
||||
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
const duration = (rend.height / 1600) * 1000;
|
||||
const easing = ANIM_CURVE_STD;
|
||||
const animation = root.animate(
|
||||
{
|
||||
height: [`${rend.height / 2}px`, `${rend.height}px`],
|
||||
width: [`${rend.width / 4 * 3}px`, `${rend.width}px`],
|
||||
},
|
||||
{
|
||||
duration,
|
||||
easing,
|
||||
},
|
||||
);
|
||||
animation.addEventListener(
|
||||
"finish",
|
||||
() => (root.style.overflow = overflow),
|
||||
);
|
||||
} else {
|
||||
setPos(
|
||||
adjustMenuPosition(
|
||||
root.getBoundingClientRect(),
|
||||
[a.left, a.top],
|
||||
windowSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
animateClose();
|
||||
}
|
||||
});
|
||||
|
||||
const animateClose = () => {
|
||||
const rend = root.getBoundingClientRect();
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
const animation = root.animate(
|
||||
{
|
||||
height: [`${rend.height}px`, `${rend.height / 2}px`],
|
||||
width: [`${rend.width}px`, `${rend.width / 4 * 3}px`],
|
||||
},
|
||||
{
|
||||
duration: (rend.height / 2 / 1600) * 1000,
|
||||
easing: ANIM_CURVE_STD,
|
||||
},
|
||||
);
|
||||
animation.addEventListener("finish", () => {
|
||||
root.style.overflow = overflow;
|
||||
root.close();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={root!}
|
||||
onClose={props.onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === root) {
|
||||
if (props.onClose) {
|
||||
if (Array.isArray(props.onClose)) {
|
||||
props.onClose[0](props.onClose[1], e);
|
||||
} else {
|
||||
(
|
||||
props.onClose as (
|
||||
event: Event & { currentTarget: HTMLDialogElement },
|
||||
) => void
|
||||
)(e);
|
||||
}
|
||||
}
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${pos()[0]}px`,
|
||||
top: `${pos()[1]}px`,
|
||||
border: "none",
|
||||
padding: 0,
|
||||
"max-width": "560px",
|
||||
width: "max-content",
|
||||
/*"min-width": "20vw", */
|
||||
"box-shadow": "var(--tutu-shadow-e8)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--tutu-color-surface)",
|
||||
}}
|
||||
>
|
||||
<MenuList>{props.children}</MenuList>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
|
@ -2,6 +2,9 @@ import { Theme, createTheme } from "@suid/material/styles";
|
|||
import { deepPurple, amber } from "@suid/material/colors";
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
/**
|
||||
* The MUI theme.
|
||||
*/
|
||||
export function useRootTheme(): Accessor<Theme> {
|
||||
return () =>
|
||||
createTheme({
|
||||
|
@ -15,3 +18,8 @@ export function useRootTheme(): Accessor<Theme> {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
export const ANIM_CURVE_DECELERATION = "cubic-bezier(0, 0, 0.2, 1)";
|
||||
export const ANIM_CURVE_ACELERATION = "cubic-bezier(0.4, 0, 1, 1)";
|
||||
export const ANIM_CURVE_SHARP = "cubic-bezier(0.4, 0, 0.6, 1)";
|
||||
|
|
|
@ -19,8 +19,8 @@ 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";
|
||||
import TootFilterButton from "./TootFilterButton";
|
||||
|
||||
const Profile: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
@ -48,7 +48,6 @@ const Profile: Component = () => {
|
|||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
onCleanup(() => obx.disconnect());
|
||||
|
||||
const [profile] = createResource(
|
||||
|
@ -58,9 +57,18 @@ const Profile: Component = () => {
|
|||
},
|
||||
);
|
||||
|
||||
const [recentTootFilter, setRecentTootFilter] = createSignal({
|
||||
boost: true,
|
||||
reply: true,
|
||||
original: true,
|
||||
});
|
||||
|
||||
const [recentToots] = createTimeline(
|
||||
() => session().client.v1.accounts.$select(params.id).statuses,
|
||||
() => ({ limit: 20 }),
|
||||
() => {
|
||||
const { boost, reply } = recentTootFilter();
|
||||
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
|
||||
},
|
||||
);
|
||||
|
||||
const bannerImg = () => profile()?.header;
|
||||
|
@ -126,7 +134,9 @@ const Profile: Component = () => {
|
|||
variant="dense"
|
||||
sx={{
|
||||
display: "flex",
|
||||
color: scrolledPastBanner() ? undefined : bannerSampledColors()?.text,
|
||||
color: scrolledPastBanner()
|
||||
? undefined
|
||||
: bannerSampledColors()?.text,
|
||||
paddingTop: "var(--safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
|
@ -238,6 +248,19 @@ const Profile: Component = () => {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TootFilterButton
|
||||
options={{
|
||||
boost: "Boosteds",
|
||||
reply: "Replies",
|
||||
original: "Originals",
|
||||
}}
|
||||
applied={recentTootFilter()}
|
||||
onApply={setRecentTootFilter}
|
||||
disabledKeys={["original"]}
|
||||
></TootFilterButton>
|
||||
</div>
|
||||
|
||||
<TimeSourceProvider value={time}>
|
||||
<TootList
|
||||
threads={recentToots.list}
|
||||
|
|
112
src/profiles/TootFilterButton.tsx
Normal file
112
src/profiles/TootFilterButton.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
Button,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
For,
|
||||
} from "solid-js";
|
||||
import Menu from "../material/Menu";
|
||||
import { FilterList, FilterListOff } from "@suid/icons-material";
|
||||
|
||||
type Props<Filters extends Record<string, string>> = {
|
||||
options: Filters;
|
||||
applied: Record<keyof Filters, boolean | undefined>;
|
||||
disabledKeys?: (keyof Filters)[];
|
||||
|
||||
onApply(value: Record<keyof Filters, boolean | undefined>): void;
|
||||
};
|
||||
|
||||
function TootFilterButton<F extends Record<string, string>>(props: Props<F>) {
|
||||
const buttonId = createUniqueId();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
const getTextForMultipleEntities = (texts: string[]) => {
|
||||
switch (texts.length) {
|
||||
case 0:
|
||||
return "Nothing";
|
||||
case 1:
|
||||
return texts[0];
|
||||
case 2:
|
||||
return `${texts[0]} and ${texts[1]}`;
|
||||
case 3:
|
||||
return `${texts[0]}, ${texts[1]} and ${texts[2]}`;
|
||||
default:
|
||||
return `${texts[0]} and ${texts.length - 1} other${texts.length > 2 ? "s" : ""}`;
|
||||
}
|
||||
};
|
||||
|
||||
const optionKeys = () => Object.keys(props.options);
|
||||
|
||||
const appliedKeys = createMemo(() => {
|
||||
const applied = props.applied;
|
||||
return optionKeys().filter((k) => applied[k]);
|
||||
});
|
||||
|
||||
const text = () => {
|
||||
const keys = optionKeys();
|
||||
const napplied = appliedKeys().length;
|
||||
switch (napplied) {
|
||||
case keys.length:
|
||||
return "All";
|
||||
default:
|
||||
return getTextForMultipleEntities(
|
||||
appliedKeys().map((k) => props.options[k]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKey = (key: keyof F) => {
|
||||
props.onApply(
|
||||
Object.assign({}, props.applied, {
|
||||
[key]: !props.applied[key],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="large" onClick={[setOpen, true]} id={buttonId}>
|
||||
{appliedKeys().length === optionKeys().length ? (
|
||||
<FilterListOff />
|
||||
) : (
|
||||
<FilterList />
|
||||
)}
|
||||
|
||||
<span style={{ "margin-left": "0.5em" }}>{text()}</span>
|
||||
</Button>
|
||||
<Menu
|
||||
open={open()}
|
||||
onClose={[setOpen, false]}
|
||||
anchor={() =>
|
||||
document.getElementById(buttonId)!.getBoundingClientRect()
|
||||
}
|
||||
>
|
||||
<For each={Object.keys(props.options)}>
|
||||
{(item, idx) => (
|
||||
<>
|
||||
<MenuItem
|
||||
data-sort={idx()}
|
||||
onClick={[toggleKey, item]}
|
||||
disabled={props.disabledKeys?.includes(item)}
|
||||
>
|
||||
<ListItemText>{props.options[item]}</ListItemText>
|
||||
<Checkbox
|
||||
checked={props.applied[item]}
|
||||
sx={{ marginRight: "-8px" }}
|
||||
disabled={props.disabledKeys?.includes(item)}
|
||||
></Checkbox>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TootFilterButton;
|
Loading…
Reference in a new issue