Compare commits

...

5 commits

Author SHA1 Message Date
thislight
4a46a0fbc9
Profile: subscribe menu show entries
All checks were successful
/ depoly (push) Successful in 1m21s
2024-11-03 23:52:26 +08:00
thislight
376f97c5cd
Menu: add document 2024-11-03 23:51:54 +08:00
thislight
898575637e
Menu: support submenu 2024-11-03 23:33:37 +08:00
thislight
92af897590
TootPreviewCard: remove containment 2024-11-03 23:18:41 +08:00
thislight
65fa4c4fa5
Profile: add subscribe menu 2024-11-03 20:50:31 +08:00
8 changed files with 312 additions and 155 deletions

View file

@ -7,6 +7,22 @@
width: max-content; width: max-content;
box-shadow: var(--tutu-shadow-e8); box-shadow: var(--tutu-shadow-e8);
contain: content; 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 { dialog.Menu::backdrop {

View file

@ -3,8 +3,9 @@ import { MenuList } from "@suid/material";
import { import {
createEffect, createEffect,
createSignal, createSignal,
type Component,
type JSX, type JSX,
type ParentComponent, type ParentProps,
} from "solid-js"; } from "solid-js";
import { ANIM_CURVE_STD } from "./theme"; import { ANIM_CURVE_STD } from "./theme";
import "./Menu.css"; import "./Menu.css";
@ -13,13 +14,13 @@ import {
animateShrinkToTopRight, animateShrinkToTopRight,
} from "../platform/anim"; } from "../platform/anim";
type Anchor = Pick<DOMRect, "top" | "left" | "right"> export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
type Props = { export type MenuProps = ParentProps<{
open?: boolean; open?: boolean;
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>; onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
anchor: () => Anchor; anchor: () => Anchor;
}; }>;
function px(n?: number) { function px(n?: number) {
if (n) { if (n) {
@ -29,15 +30,71 @@ function px(n?: number) {
} }
} }
const Menu: ParentComponent<Props> = (props) => { /**
* 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>();
return [
setAnchor,
{
get open() {
return !!anchor();
},
anchor: anchor as () => Anchor,
onClose: () => setAnchor(),
},
] 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; let root: HTMLDialogElement;
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [anchorPos, setAnchorPos] = createSignal<{ const [anchorPos, setAnchorPos] = createSignal<{
left?: number; left?: number;
top?: 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"; let openAnimationOrigin: "lt" | "rt" = "lt";
createEffect(() => { createEffect(() => {
@ -49,18 +106,19 @@ const Menu: ParentComponent<Props> = (props) => {
const rend = root.getBoundingClientRect(); const rend = root.getBoundingClientRect();
const { width } = windowSize; const { width } = windowSize;
const { left, top, right } = a; const { left, top, right, e } = a;
if (left > width / 2) { if (left > width / 2) {
openAnimationOrigin = "rt"; openAnimationOrigin = "rt";
setAnchorPos({ setAnchorPos({
left: right - rend.width, left: right - rend.width,
top, top,
e,
}); });
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD }); animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
} else { } else {
openAnimationOrigin = "lt"; openAnimationOrigin = "lt";
setAnchorPos({ left, top }); setAnchorPos({ left, top, e });
const overflow = root.style.overflow; const overflow = root.style.overflow;
root.style.overflow = "hidden"; root.style.overflow = "hidden";
@ -138,7 +196,7 @@ const Menu: ParentComponent<Props> = (props) => {
e.stopPropagation(); e.stopPropagation();
} }
}} }}
class="Menu" class={`Menu e${anchorPos().e || 0}`}
style={{ style={{
left: px(anchorPos().left), left: px(anchorPos().left),
top: px(anchorPos().top), top: px(anchorPos().top),
@ -148,6 +206,7 @@ const Menu: ParentComponent<Props> = (props) => {
<div <div
style={{ style={{
background: "var(--tutu-color-surface)", background: "var(--tutu-color-surface)",
display: "contents"
}} }}
> >
<MenuList>{props.children}</MenuList> <MenuList>{props.children}</MenuList>

22
src/material/Scaffold.css Normal file
View file

@ -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);
}

View file

@ -1,66 +1,67 @@
import { createElementSize } from "@solid-primitives/resize-observer"; import { createElementSize } from "@solid-primitives/resize-observer";
import { import {
JSX,
Show, Show,
createRenderEffect, createRenderEffect,
createSignal, createSignal,
onCleanup, splitProps,
type JSX, type Component,
type ParentComponent, type ParentProps,
} from "solid-js"; } from "solid-js";
import { css } from "solid-styled"; import "./Scaffold.css";
interface ScaffoldProps { type ScaffoldProps = ParentProps<
topbar?: JSX.Element; {
fab?: JSX.Element; topbar?: JSX.Element;
bottom?: JSX.Element; fab?: JSX.Element;
} bottom?: JSX.Element;
} & JSX.HTMLElementTags["div"]
>;
const Scaffold: ParentComponent<ScaffoldProps> = (props) => { /**
* The passthrough props are passed to the content container.
*/
const Scaffold: Component<ScaffoldProps> = (props) => {
const [managed, rest] = splitProps(props, [
"topbar",
"fab",
"bottom",
"children",
"ref",
]);
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>(); const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
const topbarSize = createElementSize(topbarElement); 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 ( return (
<> <>
<Show when={props.topbar}> <Show when={props.topbar}>
<div class="topbar" ref={setTopbarElement}> <div class="Scaffold__topbar" ref={setTopbarElement}>
{props.topbar} {props.topbar}
</div> </div>
</Show> </Show>
<Show when={props.fab}> <Show when={props.fab}>
<div class="fab-dock">{props.fab}</div> <div class="Scaffold__fab-dock">{props.fab}</div>
</Show> </Show>
<div class="scaffold-content">{props.children}</div> <div
ref={(e) => {
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}
</div>
<Show when={props.bottom}> <Show when={props.bottom}>
<div class="bottom-dock">{props.bottom}</div> <div class="Scaffold__bottom-dock">{props.bottom}</div>
</Show> </Show>
</> </>
); );

View file

@ -106,6 +106,8 @@
/* Submenu (+1dp for each submenu) */ /* Submenu (+1dp for each submenu) */
--tutu-shadow-e9: 0px 9px 18px 0px var(--tutu-color-shadow); --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 */ /* (pressed) FAB */
--tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1); --tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1);

82
src/profiles/Profile.css Normal file
View file

@ -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;
}

View file

@ -5,6 +5,8 @@ import {
createSignal, createSignal,
createUniqueId, createUniqueId,
For, For,
Switch,
Match,
onCleanup, onCleanup,
Show, Show,
type Component, type Component,
@ -17,6 +19,7 @@ import {
CircularProgress, CircularProgress,
Divider, Divider,
IconButton, IconButton,
ListItemAvatar,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
MenuItem, MenuItem,
@ -42,13 +45,13 @@ import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import { FastAverageColor } from "fast-average-color"; import { FastAverageColor } from "fast-average-color";
import { useWindowSize } from "@solid-primitives/resize-observer"; import { useWindowSize } from "@solid-primitives/resize-observer";
import { css } from "solid-styled";
import { createTimeline, createTimelineSnapshot } from "../masto/timelines"; import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
import TootList from "../timelines/TootList"; import TootList from "../timelines/TootList";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootFilterButton from "./TootFilterButton"; import TootFilterButton from "./TootFilterButton";
import Menu from "../material/Menu"; import Menu, { createManagedMenuState } from "../material/Menu";
import { share } from "../platform/share"; import { share } from "../platform/share";
import "./Profile.css";
const Profile: Component = () => { const Profile: Component = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -66,6 +69,8 @@ const Profile: Component = () => {
const [menuOpen, setMenuOpen] = createSignal(false); const [menuOpen, setMenuOpen] = createSignal(false);
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false); const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
const obx = new IntersectionObserver( const obx = new IntersectionObserver(
(entries) => { (entries) => {
@ -132,89 +137,6 @@ const Profile: Component = () => {
recentTootChunk.loading || recentTootChunk.loading ||
(recentTootFilter().pinned && pinnedTootChunk.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 ( return (
<Scaffold <Scaffold
topbar={ topbar={
@ -241,8 +163,7 @@ const Profile: Component = () => {
<Close /> <Close />
</IconButton> </IconButton>
<Title <Title
use:solid-styled class="Profile__page-title"
class="page-title"
style={{ style={{
visibility: scrolledPastBanner() ? undefined : "hidden", visibility: scrolledPastBanner() ? undefined : "hidden",
}} }}
@ -261,6 +182,7 @@ const Profile: Component = () => {
</Toolbar> </Toolbar>
</AppBar> </AppBar>
} }
class="Profile"
> >
<Menu <Menu
open={menuOpen()} open={menuOpen()}
@ -269,25 +191,38 @@ const Profile: Component = () => {
document.getElementById(menuButId)!.getBoundingClientRect() document.getElementById(menuButId)!.getBoundingClientRect()
} }
> >
<Show <Show when={session().account && profile()}>
when={isCurrentSessionProfile()} <Show
fallback={ when={isCurrentSessionProfile()}
fallback={
<MenuItem
onClick={(event) => {
const { left, right, top } =
event.currentTarget.getBoundingClientRect();
openSubscribeMenu({
left,
right,
top,
e: 1,
});
}}
>
<ListItemIcon>
<PlaylistAdd />
</ListItemIcon>
<ListItemText>Subscribe...</ListItemText>
</MenuItem>
}
>
<MenuItem disabled> <MenuItem disabled>
<ListItemIcon> <ListItemIcon>
<PlaylistAdd /> <Edit />
</ListItemIcon> </ListItemIcon>
<ListItemText>Subscribe...</ListItemText> <ListItemText>Edit...</ListItemText>
</MenuItem> </MenuItem>
} </Show>
> <Divider />
<MenuItem disabled>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText>Edit...</ListItemText>
</MenuItem>
</Show> </Show>
<Divider />
<MenuItem disabled> <MenuItem disabled>
<ListItemIcon> <ListItemIcon>
<Group /> <Group />
@ -360,6 +295,27 @@ const Profile: Component = () => {
></img> ></img>
</div> </div>
<Menu {...subscribeMenuState}>
<MenuItem disabled>
<ListItemAvatar>
<Avatar src={session().account?.inf?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText>
<span
ref={(e) =>
createRenderEffect(() => {
e.innerHTML = resolveCustomEmoji(
session().account?.inf?.displayName || "",
session().account?.inf?.emojis ?? [],
);
})
}
></span>
<span>'s Home</span>
</ListItemText>
</MenuItem>
</Menu>
<div <div
class="intro" class="intro"
style={{ style={{
@ -385,9 +341,29 @@ const Profile: Component = () => {
<span>{fullUsername()}</span> <span>{fullUsername()}</span>
</div> </div>
<div> <div>
<Button variant="contained" color="secondary"> <Switch>
Subscribe <Match when={!session().account || profileErrorUncaught.loading}>
</Button> {<></>}
</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> </div>
<div <div

View file

@ -123,7 +123,6 @@
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
position: relative; position: relative;
contain: content;
>img { >img {
max-width: 100%; max-width: 100%;