From 65fa4c4fa5f4222f52dd0f6bf33c23bc5f926b40 Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 3 Nov 2024 20:50:31 +0800 Subject: [PATCH 1/5] Profile: add subscribe menu --- src/material/Menu.tsx | 26 +++++-- src/material/Scaffold.css | 22 ++++++ src/material/Scaffold.tsx | 85 ++++++++++---------- src/material/theme.css | 2 + src/profiles/Profile.css | 82 +++++++++++++++++++ src/profiles/Profile.tsx | 160 +++++++++++++------------------------- 6 files changed, 226 insertions(+), 151 deletions(-) create mode 100644 src/material/Scaffold.css create mode 100644 src/profiles/Profile.css diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index a80a423..355cc03 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -3,8 +3,9 @@ import { MenuList } from "@suid/material"; import { createEffect, createSignal, + type Component, type JSX, - type ParentComponent, + type ParentProps, } from "solid-js"; import { ANIM_CURVE_STD } from "./theme"; import "./Menu.css"; @@ -13,13 +14,13 @@ import { animateShrinkToTopRight, } from "../platform/anim"; -type Anchor = Pick +export type Anchor = Pick -type Props = { +export type MenuProps = ParentProps<{ open?: boolean; onClose?: JSX.EventHandlerUnion; anchor: () => Anchor; -}; +}>; function px(n?: number) { if (n) { @@ -29,7 +30,22 @@ function px(n?: number) { } } -const Menu: ParentComponent = (props) => { +export function createManagedMenuState() { + const [anchor, setAnchor] = createSignal(); + + return [ + setAnchor, + { + get open() { + return !!anchor(); + }, + anchor: anchor as () => Anchor, + onClose: () => setAnchor(), + }, + ] as const; +} + +const Menu: Component = (props) => { let root: HTMLDialogElement; const windowSize = useWindowSize(); diff --git a/src/material/Scaffold.css b/src/material/Scaffold.css new file mode 100644 index 0000000..fdcf2bd --- /dev/null +++ b/src/material/Scaffold.css @@ -0,0 +1,22 @@ + +.Scaffold__topbar { + position: sticky; + top: 0px; + z-index: var(--tutu-zidx-nav, auto); +} + +.Scaffold__fab-dock { + position: fixed; + bottom: 40px; + right: 40px; + z-index: var(--tutu-zidx-nav, auto); +} + +.Scaffold__bottom-dock { + position: sticky; + bottom: 0; + left: 0; + right: 0; + z-index: var(--tutu-zidx-nav, auto); + padding-bottom: var(--safe-area-inset-bottom, 0); +} \ No newline at end of file diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index cebcda7..fa9631f 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -1,66 +1,67 @@ import { createElementSize } from "@solid-primitives/resize-observer"; import { + JSX, Show, createRenderEffect, createSignal, - onCleanup, - type JSX, - type ParentComponent, + splitProps, + type Component, + type ParentProps, } from "solid-js"; -import { css } from "solid-styled"; +import "./Scaffold.css"; -interface ScaffoldProps { - topbar?: JSX.Element; - fab?: JSX.Element; - bottom?: JSX.Element; -} +type ScaffoldProps = ParentProps< + { + topbar?: JSX.Element; + fab?: JSX.Element; + bottom?: JSX.Element; + } & JSX.HTMLElementTags["div"] +>; -const Scaffold: ParentComponent = (props) => { +/** + * The passthrough props are passed to the content container. + */ +const Scaffold: Component = (props) => { + const [managed, rest] = splitProps(props, [ + "topbar", + "fab", + "bottom", + "children", + "ref", + ]); const [topbarElement, setTopbarElement] = createSignal(); const topbarSize = createElementSize(topbarElement); - css` - .scaffold-content { - --scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"}; - } - - .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); - } - - .bottom-dock { - position: sticky; - bottom: 0; - left: 0; - right: 0; - z-index: var(--tutu-zidx-nav, auto); - padding-bottom: var(--safe-area-inset-bottom, 0); - - } - `; return ( <> -
+
{props.topbar}
-
{props.fab}
+
{props.fab}
-
{props.children}
+
{ + createRenderEffect(() => { + e.style.setProperty( + "--scaffold-topbar-height", + (topbarSize.height?.toString() ?? 0) + "px", + ); + }); + + if (managed.ref) { + (managed.ref as (val: typeof e) => void)(e); + } + }} + {...rest} + > + {managed.children} +
-
{props.bottom}
+
{props.bottom}
); diff --git a/src/material/theme.css b/src/material/theme.css index 09cc746..5048f98 100644 --- a/src/material/theme.css +++ b/src/material/theme.css @@ -106,6 +106,8 @@ /* Submenu (+1dp for each submenu) */ --tutu-shadow-e9: 0px 9px 18px 0px var(--tutu-color-shadow); + --tutu-shadow-e10: 0px 10px 18px 0px var(--tutu-color-shadow); + --tutu-shadow-e11: 0px 11px 18px 0px var(--tutu-color-shadow-l1); /* (pressed) FAB */ --tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1); diff --git a/src/profiles/Profile.css b/src/profiles/Profile.css new file mode 100644 index 0000000..31e5c78 --- /dev/null +++ b/src/profiles/Profile.css @@ -0,0 +1,82 @@ +.Profile { + .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; + } + + .description { + & a { + color: inherit; + font-style: italic; + } + + word-break: break-all; + } + + .acct-grp { + display: flex; + flex-flow: row wrap; + gap: 16px; + align-items: center; + + & > :nth-child(2) { + flex-grow: 1; + } + + & > :last-child { + flex-grow: 1; + text-align: right; + } + } + + .name-grp { + display: flex; + flex-flow: column nowrap; + } + + table.acct-fields { + word-break: break-all; + + & td > a { + display: inline-flex; + align-items: center; + color: inherit; + min-height: 44px; + } + + & a > .invisible { + display: none; + } + + & svg { + vertical-align: middle; + } + } + + .toot-list-toolbar { + position: sticky; + top: var(--scaffold-topbar-height); + z-index: calc(var(--tutu-zidx-nav, 1) - 1); + background: var(--tutu-color-surface); + border-bottom: 1px solid var(--tutu-color-surface-d); + contain: content; + /* TODO: box-shadow is needed here (same as app bar, e6). + There is no good way to detect if the sticky is "sticked" - + so let's leave it for future. + + For now we use a trick to make it looks better. + */ + box-shadow: 0px -2px 4px 0px var(--tutu-color-shadow); + } +} + +.Profile__page-title { + flex-grow: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index 4becc5b..8a6e6a1 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -5,6 +5,8 @@ import { createSignal, createUniqueId, For, + Switch, + Match, onCleanup, Show, type Component, @@ -42,13 +44,13 @@ 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, createTimelineSnapshot } from "../masto/timelines"; import TootList from "../timelines/TootList"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc"; import TootFilterButton from "./TootFilterButton"; -import Menu from "../material/Menu"; +import Menu, { createManagedMenuState } from "../material/Menu"; import { share } from "../platform/share"; +import "./Profile.css"; const Profile: Component = () => { const navigate = useNavigate(); @@ -65,6 +67,10 @@ const Profile: Component = () => { const menuButId = createUniqueId(); const [menuOpen, setMenuOpen] = createSignal(false); + const [subscribeMenuOpen, setSubscribeMenuOpen] = createSignal(false); + let subcribeMenuAnchor: { top: number; right: number; left: number }; + + const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState(); const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false); const obx = new IntersectionObserver( @@ -132,89 +138,6 @@ const Profile: Component = () => { recentTootChunk.loading || (recentTootFilter().pinned && pinnedTootChunk.loading); - 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; - } - - .description { - & :global(a) { - color: inherit; - font-style: italic; - } - - word-break: break-all; - } - - .acct-grp { - display: flex; - flex-flow: row wrap; - gap: 16px; - align-items: center; - - & > :nth-child(2) { - flex-grow: 1; - } - - & > :last-child { - flex-grow: 1; - text-align: right; - } - } - - .name-grp { - display: flex; - flex-flow: column nowrap; - } - - table.acct-fields { - word-break: break-all; - - & td > :global(a) { - display: inline-flex; - align-items: center; - color: inherit; - min-height: 44px; - } - - & :global(a > .invisible) { - display: none; - } - - & :global(svg) { - vertical-align: middle; - } - } - - .page-title { - flex-grow: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .toot-list-toolbar { - position: sticky; - top: var(--scaffold-topbar-height); - z-index: calc(var(--tutu-zidx-nav, 1) - 1); - background: var(--tutu-color-surface); - border-bottom: 1px solid var(--tutu-color-surface-d); - contain: content; - /* TODO: box-shadow is needed here (same as app bar, e6). - There is no good way to detect if the sticky is "sticked" - - so let's leave it for future. - - For now we use a trick to make it looks better. - */ - box-shadow: 0px -2px 4px 0px var(--tutu-color-shadow); - } - `; - return ( { { </Toolbar> </AppBar> } + class="Profile" > <Menu open={menuOpen()} @@ -269,25 +192,27 @@ const Profile: Component = () => { document.getElementById(menuButId)!.getBoundingClientRect() } > - <Show - when={isCurrentSessionProfile()} - fallback={ + <Show when={session().account}> + <Show + when={isCurrentSessionProfile()} + fallback={ + <MenuItem disabled> + <ListItemIcon> + <PlaylistAdd /> + </ListItemIcon> + <ListItemText>Subscribe...</ListItemText> + </MenuItem> + } + > <MenuItem disabled> <ListItemIcon> - <PlaylistAdd /> + <Edit /> </ListItemIcon> - <ListItemText>Subscribe...</ListItemText> + <ListItemText>Edit...</ListItemText> </MenuItem> - } - > - <MenuItem disabled> - <ListItemIcon> - <Edit /> - </ListItemIcon> - <ListItemText>Edit...</ListItemText> - </MenuItem> + </Show> + <Divider /> </Show> - <Divider /> <MenuItem disabled> <ListItemIcon> <Group /> @@ -360,6 +285,15 @@ const Profile: Component = () => { ></img> </div> + <Menu {...subscribeMenuState}> + <MenuItem disabled> + <ListItemText> + <span>{session().account?.inf?.displayName || ""}</span> + <span>'s timeline</span> + </ListItemText> + </MenuItem> + </Menu> + <div class="intro" style={{ @@ -385,9 +319,27 @@ const Profile: Component = () => { <span>{fullUsername()}</span> </div> <div> - <Button variant="contained" color="secondary"> - Subscribe - </Button> + <Switch> + <Match when={!session().account}>{<></>}</Match> + <Match when={isCurrentSessionProfile()}> + <IconButton color="inherit"> + <Edit /> + </IconButton> + </Match> + <Match when={true}> + <Button + variant="contained" + color="secondary" + onClick={(event) => { + openSubscribeMenu( + event.currentTarget.getBoundingClientRect(), + ); + }} + > + Subscribe + </Button> + </Match> + </Switch> </div> </div> <div From 92af897590b31099b4dd541abcc68027e297d652 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sun, 3 Nov 2024 23:18:41 +0800 Subject: [PATCH 2/5] TootPreviewCard: remove containment --- src/timelines/toot.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/timelines/toot.module.css b/src/timelines/toot.module.css index 85d9b04..d93a508 100644 --- a/src/timelines/toot.module.css +++ b/src/timelines/toot.module.css @@ -123,7 +123,6 @@ overflow: hidden; z-index: 1; position: relative; - contain: content; >img { max-width: 100%; From 898575637efec4fa7678c8642fb81602d1a9a803 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sun, 3 Nov 2024 23:33:37 +0800 Subject: [PATCH 3/5] Menu: support submenu --- src/material/Menu.css | 16 ++++++++++++++++ src/material/Menu.tsx | 25 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/material/Menu.css b/src/material/Menu.css index 2be4bcf..5c88653 100644 --- a/src/material/Menu.css +++ b/src/material/Menu.css @@ -7,6 +7,22 @@ width: max-content; box-shadow: var(--tutu-shadow-e8); contain: content; + + &.e1 { + box-shadow: var(--tutu-shadow-e9); + } + + &.e2 { + box-shadow: var(--tutu-shadow-e10); + } + + &.e3 { + box-shadow: var(--tutu-shadow-e11); + } + + &.e4 { + box-shadow: var(--tutu-shadow-e12); + } } dialog.Menu::backdrop { diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index 355cc03..5cae4e2 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -14,7 +14,7 @@ import { animateShrinkToTopRight, } from "../platform/anim"; -export type Anchor = Pick<DOMRect, "top" | "left" | "right"> +export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number }; export type MenuProps = ParentProps<{ open?: boolean; @@ -52,8 +52,23 @@ const Menu: Component<MenuProps> = (props) => { const [anchorPos, setAnchorPos] = createSignal<{ left?: number; top?: number; + e?: number; }>({}); + if (import.meta.env.DEV) { + createEffect(() => { + switch (anchorPos().e) { + case 1: + case 2: + case 3: + case 3: + return; + default: + console.warn('value %s is invalid for param "e"', anchorPos().e); + } + }); + } + let openAnimationOrigin: "lt" | "rt" = "lt"; createEffect(() => { @@ -65,18 +80,19 @@ const Menu: Component<MenuProps> = (props) => { const rend = root.getBoundingClientRect(); const { width } = windowSize; - const { left, top, right } = a; + const { left, top, right, e } = a; if (left > width / 2) { openAnimationOrigin = "rt"; setAnchorPos({ left: right - rend.width, top, + e, }); animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); } else { openAnimationOrigin = "lt"; - setAnchorPos({ left, top }); + setAnchorPos({ left, top, e }); const overflow = root.style.overflow; root.style.overflow = "hidden"; @@ -154,7 +170,7 @@ const Menu: Component<MenuProps> = (props) => { e.stopPropagation(); } }} - class="Menu" + class={`Menu e${anchorPos().e || 0}`} style={{ left: px(anchorPos().left), top: px(anchorPos().top), @@ -164,6 +180,7 @@ const Menu: Component<MenuProps> = (props) => { <div style={{ background: "var(--tutu-color-surface)", + display: "contents" }} > <MenuList>{props.children}</MenuList> From 376f97c5cd5ebcecfdcc8fa45f7c37885bb900e0 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sun, 3 Nov 2024 23:51:54 +0800 Subject: [PATCH 4/5] Menu: add document --- src/material/Menu.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index 5cae4e2..916fc76 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -30,6 +30,24 @@ function px(n?: number) { } } +/** + * Create managed state for {@link Menu}. This function + * expose an "open" closure for you to open the menu. The + * opening and closing is automatically managed internally. + * + * @returns The first element is the "open" closure, calls + * with anchor infomation to open the menu. + * The second element is the state props for {@link Menu}, use + * spread syntax to set the props. + * @example + * ````tsx + * const [openMenu, menuState] = createManagedMenuState(); + * + * <Menu {...menuState}></Menu> + * + * <Button onClick={event => openMenu(event.currectTarget.getBoundingClientRect())} /> + * ```` + */ export function createManagedMenuState() { const [anchor, setAnchor] = createSignal<Anchor>(); @@ -45,6 +63,14 @@ export function createManagedMenuState() { ] as const; } +/** + * Material Menu Component. This component is + * implemented with dialog and {@link MenuList} from SUID. + * + * Notes: + * - Use {@link createManagedMenuState} and you don't need to manage the open and close. + * - Use {@link MenuItem} from SUID as children. + */ const Menu: Component<MenuProps> = (props) => { let root: HTMLDialogElement; const windowSize = useWindowSize(); From 4a46a0fbc907923d2501107b9f44a2c62c94f888 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sun, 3 Nov 2024 23:52:26 +0800 Subject: [PATCH 5/5] Profile: subscribe menu show entries --- src/profiles/Profile.tsx | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index 8a6e6a1..f3a5823 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -19,6 +19,7 @@ import { CircularProgress, Divider, IconButton, + ListItemAvatar, ListItemIcon, ListItemText, MenuItem, @@ -67,8 +68,6 @@ const Profile: Component = () => { const menuButId = createUniqueId(); const [menuOpen, setMenuOpen] = createSignal(false); - const [subscribeMenuOpen, setSubscribeMenuOpen] = createSignal(false); - let subcribeMenuAnchor: { top: number; right: number; left: number }; const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState(); @@ -192,11 +191,22 @@ const Profile: Component = () => { document.getElementById(menuButId)!.getBoundingClientRect() } > - <Show when={session().account}> + <Show when={session().account && profile()}> <Show when={isCurrentSessionProfile()} fallback={ - <MenuItem disabled> + <MenuItem + onClick={(event) => { + const { left, right, top } = + event.currentTarget.getBoundingClientRect(); + openSubscribeMenu({ + left, + right, + top, + e: 1, + }); + }} + > <ListItemIcon> <PlaylistAdd /> </ListItemIcon> @@ -287,9 +297,21 @@ const Profile: Component = () => { <Menu {...subscribeMenuState}> <MenuItem disabled> + <ListItemAvatar> + <Avatar src={session().account?.inf?.avatar}></Avatar> + </ListItemAvatar> <ListItemText> - <span>{session().account?.inf?.displayName || ""}</span> - <span>'s timeline</span> + <span + ref={(e) => + createRenderEffect(() => { + e.innerHTML = resolveCustomEmoji( + session().account?.inf?.displayName || "", + session().account?.inf?.emojis ?? [], + ); + }) + } + ></span> + <span>'s Home</span> </ListItemText> </MenuItem> </Menu> @@ -320,7 +342,9 @@ const Profile: Component = () => { </div> <div> <Switch> - <Match when={!session().account}>{<></>}</Match> + <Match when={!session().account || profileErrorUncaught.loading}> + {<></>} + </Match> <Match when={isCurrentSessionProfile()}> <IconButton color="inherit"> <Edit />