Compare commits

...

5 commits

Author SHA1 Message Date
thislight
af9f111b27
Settings: add translations
All checks were successful
/ depoly (push) Successful in 1m21s
2025-01-02 18:20:44 +08:00
thislight
0de2b91abc
Settings: add tutu's mastodon link 2025-01-02 18:12:14 +08:00
thislight
7153a1fec1
Profile: supports webfinger
* timelines: add emptyTimeline
2025-01-02 18:11:35 +08:00
thislight
f860baa376
AppTopBar: remove title visual adjustment 2025-01-02 17:14:44 +08:00
thislight
0eef74f25f
Tabs: fix misplaced indicator 2025-01-02 17:13:58 +08:00
11 changed files with 219 additions and 94 deletions

View file

@ -248,6 +248,64 @@ export type TimelineResource<R> = [
{ refetch(info?: TimelineFetchDirection): void }, { refetch(info?: TimelineFetchDirection): void },
]; ];
export const emptyTimeline = {
list() {
return emptyTimeline;
},
setDirection() {
return emptyTimeline;
},
async next(): Promise<IteratorResult<any, undefined>> {
return {
value: undefined,
done: true,
};
},
getDirection(): TimelineFetchDirection {
return "next";
},
clone() {
return emptyTimeline;
},
async return(): Promise<IteratorResult<any, undefined>> {
return {
value: undefined,
done: true,
};
},
async throw(e?: unknown) {
throw e;
},
async *values() {},
async *[Symbol.asyncIterator](): AsyncIterator<any[], undefined> {
return undefined;
},
async then<TNext, ENext>(
onresolve?: null | ((value: any[]) => TNext | PromiseLike<TNext>),
onrejected?: null | ((reason: unknown) => ENext | PromiseLike<ENext>),
) {
try {
if (!onresolve) {
throw new TypeError("no onresolve");
}
return await onresolve([]);
} catch (reason) {
if (!onrejected) {
throw reason;
}
return await onrejected(reason);
}
},
};
/** /**
* Create auto managed timeline controls. * Create auto managed timeline controls.
* *

View file

@ -23,9 +23,5 @@
&>.MuiButtonBase-root:last-child { &>.MuiButtonBase-root:last-child {
margin-right: -0.15em; margin-right: -0.15em;
} }
&>.title {
margin-top: -0.2ch;
}
} }
} }

23
src/material/Tab.css Normal file
View file

@ -0,0 +1,23 @@
.Tab {
cursor: pointer;
background: none;
border: none;
height: 100%;
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);
}
.MuiToolbar-root .Tab {
color: rgba(255, 255, 255, 0.7);
&:hover,
&:focus,
&.focus,
&.Tabs-focus {
color: white;
}
}

View file

@ -1,26 +1,18 @@
import { import {
Component,
createEffect, createEffect,
splitProps, splitProps,
type JSX, type JSX,
type ParentComponent, type ParentComponent,
} from "solid-js"; } from "solid-js";
import { css } from "solid-styled";
import { useTabListContext } from "./Tabs"; import { useTabListContext } from "./Tabs";
import "./Tab.css";
const Tab: ParentComponent< const Tab: ParentComponent<
{ {
focus?: boolean; focus?: boolean;
large?: boolean;
} & JSX.ButtonHTMLAttributes<HTMLButtonElement> } & JSX.ButtonHTMLAttributes<HTMLButtonElement>
> = (props) => { > = (props) => {
const [managed, rest] = splitProps(props, [ const [managed, rest] = splitProps(props, ["focus", "type", "role", "ref"]);
"focus",
"large",
"type",
"role",
"ref",
]);
let self: HTMLButtonElement; let self: HTMLButtonElement;
const { const {
focusOn: [, setFocusOn], focusOn: [, setFocusOn],
@ -35,32 +27,7 @@ const Tab: ParentComponent<
} }
return managed.focus; 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 ( return (
<button <button
ref={(x) => { ref={(x) => {
@ -68,7 +35,7 @@ const Tab: ParentComponent<
(managed.ref as (e: HTMLButtonElement) => void)?.(x); (managed.ref as (e: HTMLButtonElement) => void)?.(x);
}} }}
type={managed.type ?? "button"} type={managed.type ?? "button"}
classList={{ tab: true, focus: managed.focus }} classList={{ Tab: true, focus: managed.focus }}
role={managed.role ?? "tab"} role={managed.role ?? "tab"}
{...rest} {...rest}
> >

21
src/material/Tabs.css Normal file
View file

@ -0,0 +1,21 @@
.Tabs {
width: 100%;
position: relative;
white-space: nowrap;
overflow-x: auto;
align-self: stretch;
&::after {
transition:
left var(--tabs-indkt-movspeed-offset, 0) var(--tutu-anim-curve-std),
width var(--tabs-indkt-movspeed-width, 0) var(--tutu-anim-curve-std);
position: absolute;
content: "";
display: block;
background-color: white;
height: 2px;
width: var(--tabs-indkt-width, 0);
left: var(--tabs-indkt-offset, 0);
bottom: 0;
}
}

View file

@ -1,14 +1,12 @@
import { import {
ParentComponent, ParentComponent,
createContext, createContext,
createEffect,
createMemo,
createRenderEffect, createRenderEffect,
createSignal, createSignal,
useContext, useContext,
type Signal, type Signal,
} from "solid-js"; } from "solid-js";
import { css } from "solid-styled"; import "./Tabs.css"
const TabListContext = /* @__PURE__ */ createContext<{ const TabListContext = /* @__PURE__ */ createContext<{
focusOn: Signal<HTMLElement[]>; focusOn: Signal<HTMLElement[]>;
@ -24,7 +22,7 @@ export function useTabListContext() {
const ANIM_SPEED = 160 / 110; // 160px/110ms const ANIM_SPEED = 160 / 110; // 160px/110ms
const TABLIST_FOCUS_CLASS = "tablist-focus"; const TABS_FOCUS_CLASS = "Tabs-focus";
const Tabs: ParentComponent<{ const Tabs: ParentComponent<{
offset?: number; offset?: number;
@ -37,11 +35,11 @@ const Tabs: ParentComponent<{
const current = focusOn(); const current = focusOn();
if (lastFocusElement) { if (lastFocusElement) {
for (const e of lastFocusElement) { for (const e of lastFocusElement) {
e.classList.remove(TABLIST_FOCUS_CLASS); e.classList.remove(TABS_FOCUS_CLASS);
} }
} }
for (const e of current) { for (const e of current) {
e.classList.add("tablist-focus"); e.classList.add(TABS_FOCUS_CLASS);
} }
return current; return current;
}); });
@ -109,7 +107,7 @@ const Tabs: ParentComponent<{
return ["0px", "0px", "110ms", "110ms"] as const; return ["0px", "0px", "110ms", "110ms"] as const;
} }
const rect = focusBoundingClientRect(); const rect = focusBoundingClientRect();
const rootRect = self.getBoundingClientRect(); const rootRect = self!.getBoundingClientRect();
const left = rect.x - rootRect.x; const left = rect.x - rootRect.x;
const width = rect.width; const width = rect.width;
const [prevEl, nextEl] = focusSiblings(); const [prevEl, nextEl] = focusSiblings();
@ -130,32 +128,14 @@ const Tabs: ParentComponent<{
return result; 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 ( return (
<TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}> <TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}>
<div ref={self!} class="tablist" role="tablist"> <div ref={self!} class="Tabs" style={{
"--tabs-indkt-width": indicator()[1],
"--tabs-indkt-offset": indicator()[0],
"--tabs-indkt-movspeed-offset": indicator()[2],
"--tabs-indkt-movspeed-width": indicator()[3]
}} role="tablist">
{props.children} {props.children}
</div> </div>
</TabListContext.Provider> </TabListContext.Provider>

View file

@ -47,7 +47,11 @@ 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 { createTimeline, createTimelineSnapshot } from "../masto/timelines"; import {
createTimeline,
createTimelineSnapshot,
emptyTimeline,
} 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";
@ -102,6 +106,9 @@ const Profile: Component = () => {
const [profileUncaught] = createResource( const [profileUncaught] = createResource(
() => [session().client, params.id] as const, () => [session().client, params.id] as const,
async ([client, id]) => { async ([client, id]) => {
if (id.startsWith("@")) {
return await client.v1.accounts.lookup({ acct: id.slice(1) });
}
return await client.v1.accounts.$select(id).fetch(); return await client.v1.accounts.$select(id).fetch();
}, },
); );
@ -114,6 +121,15 @@ const Profile: Component = () => {
} }
}; };
const profileAcctId = () => {
if (params.id.startsWith("@")) {
// Webfinger
return profile()?.id;
} else {
return params.id;
}
};
const isCurrentSessionProfile = () => { const isCurrentSessionProfile = () => {
return (session().account as Account).inf?.url === profile()?.url; return (session().account as Account).inf?.url === profile()?.url;
}; };
@ -125,26 +141,33 @@ const Profile: Component = () => {
original: true, original: true,
}); });
const recentTimeline = () => {
const id = profileAcctId();
if (id) {
return session().client.v1.accounts.$select(id).statuses;
} else {
return emptyTimeline;
}
};
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] = const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
createTimeline( createTimeline(recentTimeline, () => {
() => session().client.v1.accounts.$select(params.id).statuses, const { boost, reply } = recentTootFilter();
() => { return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
const { boost, reply } = recentTootFilter(); });
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
},
);
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot( const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
() => session().client.v1.accounts.$select(params.id).statuses, recentTimeline,
() => { () => {
return { limit: 20, pinned: true }; return { limit: 20, pinned: true };
}, },
); );
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource( const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
() => [session(), params.id] as const, () => [session(), profileAcctId()] as const,
async ([sess, id]) => { async ([sess, id]) => {
if (!sess.account) return; // No account, no relation if (!sess.account || !id) return; // No account, no relation
const relations = await session().client.v1.accounts.relationships.fetch({ const relations = await session().client.v1.accounts.relationships.fetch({
id: [id], id: [id],
}); });
@ -177,16 +200,17 @@ const Profile: Component = () => {
const toggleSubscribeHome = async (event: Event) => { const toggleSubscribeHome = async (event: Event) => {
const client = session().client; const client = session().client;
if (!session().account) return; const acctId = profileAcctId();
if (!session().account || !acctId) return;
const isSubscribed = relationship()?.following ?? false; const isSubscribed = relationship()?.following ?? false;
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x)); mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
subscribeMenuState.onClose(event); subscribeMenuState.onClose(event);
if (isSubscribed) { if (isSubscribed) {
const nrel = await client.v1.accounts.$select(params.id).unfollow(); const nrel = await client.v1.accounts.$select(acctId).unfollow();
mutateRelationship(nrel); mutateRelationship(nrel);
} else { } else {
const nrel = await client.v1.accounts.$select(params.id).follow(); const nrel = await client.v1.accounts.$select(acctId).follow();
mutateRelationship(nrel); mutateRelationship(nrel);
} }
}; };

View file

@ -0,0 +1,35 @@
import { SvgIcon } from "@suid/material";
export default function () {
return (
<SvgIcon
width="75"
height="79"
viewBox="0 0 75 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M73.8393 17.4898C72.6973 9.00165 65.2994 2.31235 56.5296 1.01614C55.05 0.797115 49.4441 0 36.4582 0H36.3612C23.3717 0 20.585 0.797115 19.1054 1.01614C10.5798 2.27644 2.79399 8.28712 0.904997 16.8758C-0.00358524 21.1056 -0.100549 25.7949 0.0682394 30.0965C0.308852 36.2651 0.355538 42.423 0.91577 48.5665C1.30307 52.6474 1.97872 56.6957 2.93763 60.6812C4.73325 68.042 12.0019 74.1676 19.1233 76.6666C26.7478 79.2728 34.9474 79.7055 42.8039 77.9162C43.6682 77.7151 44.5217 77.4817 45.3645 77.216C47.275 76.6092 49.5123 75.9305 51.1571 74.7385C51.1797 74.7217 51.1982 74.7001 51.2112 74.6753C51.2243 74.6504 51.2316 74.6229 51.2325 74.5948V68.6416C51.2321 68.6154 51.2259 68.5896 51.2142 68.5661C51.2025 68.5426 51.1858 68.522 51.1651 68.5058C51.1444 68.4896 51.1204 68.4783 51.0948 68.4726C51.0692 68.4669 51.0426 68.467 51.0171 68.4729C45.9835 69.675 40.8254 70.2777 35.6502 70.2682C26.7439 70.2682 24.3486 66.042 23.6626 64.2826C23.1113 62.762 22.7612 61.1759 22.6212 59.5646C22.6197 59.5375 22.6247 59.5105 22.6357 59.4857C22.6466 59.4609 22.6633 59.4391 22.6843 59.422C22.7053 59.4048 22.73 59.3929 22.7565 59.3871C22.783 59.3813 22.8104 59.3818 22.8367 59.3886C27.7864 60.5826 32.8604 61.1853 37.9522 61.1839C39.1768 61.1839 40.3978 61.1839 41.6224 61.1516C46.7435 61.008 52.1411 60.7459 57.1796 59.7621C57.3053 59.7369 57.431 59.7154 57.5387 59.6831C65.4861 58.157 73.0493 53.3672 73.8178 41.2381C73.8465 40.7606 73.9184 36.2364 73.9184 35.7409C73.9219 34.0569 74.4606 23.7949 73.8393 17.4898Z"
fill="url(#paint0_linear_549_34)"
/>
<path
d="M61.2484 27.0263V48.114H52.8916V27.6475C52.8916 23.3388 51.096 21.1413 47.4437 21.1413C43.4287 21.1413 41.4177 23.7409 41.4177 28.8755V40.0782H33.1111V28.8755C33.1111 23.7409 31.0965 21.1413 27.0815 21.1413C23.4507 21.1413 21.6371 23.3388 21.6371 27.6475V48.114H13.2839V27.0263C13.2839 22.7176 14.384 19.2946 16.5843 16.7572C18.8539 14.2258 21.8311 12.926 25.5264 12.926C29.8036 12.926 33.0357 14.5705 35.1905 17.8559L37.2698 21.346L39.3527 17.8559C41.5074 14.5705 44.7395 12.926 49.0095 12.926C52.7013 12.926 55.6784 14.2258 57.9553 16.7572C60.1531 19.2922 61.2508 22.7152 61.2484 27.0263Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_549_34"
x1="37.0692"
y1="0"
x2="37.0692"
y2="79"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6364FF" />
<stop offset="1" stop-color="#563ACC" />
</linearGradient>
</defs>
</SvgIcon>
);
}

View file

@ -1,7 +1,6 @@
import { For, Show, type Component } from "solid-js"; import { For, Show, type Component } from "solid-js";
import Scaffold from "~material/Scaffold.js"; import Scaffold from "~material/Scaffold.js";
import { import {
AppBar,
Divider, Divider,
IconButton, IconButton,
List, List,
@ -13,7 +12,6 @@ import {
ListSubheader, ListSubheader,
NativeSelect, NativeSelect,
Switch, Switch,
Toolbar,
} from "@suid/material"; } from "@suid/material";
import { import {
Animation as AnimationIcon, Animation as AnimationIcon,
@ -39,9 +37,10 @@ import {
} from "~platform/i18n.jsx"; } from "~platform/i18n.jsx";
import { type Template } from "@solid-primitives/i18n"; import { type Template } from "@solid-primitives/i18n";
import { useServiceWorker } from "~platform/host.js"; import { useServiceWorker } from "~platform/host.js";
import { useSessions } from "../masto/clients.js"; import { makeAcctText, useSessions } from "../masto/clients.js";
import { useNavigator } from "~platform/StackedRouter.jsx"; import { useNavigator } from "~platform/StackedRouter.jsx";
import AppTopBar from "~material/AppTopBar.jsx"; import AppTopBar from "~material/AppTopBar.jsx";
import MastodonLogo from "./MastodonLogo.jsx";
type Inset = { type Inset = {
top?: number; top?: number;
@ -273,13 +272,13 @@ const Settings: Component = () => {
<Divider /> <Divider />
</li> </li>
<li> <li>
<ListSubheader>Storage</ListSubheader> <ListSubheader>{t("storage")}</ListSubheader>
<ListItemButton disabled> <ListItemButton disabled>
<ListItemIcon> <ListItemIcon>
<DeleteForever /> <DeleteForever />
</ListItemIcon> </ListItemIcon>
<ListItemText secondary={"The cache is managed by your browser."}> <ListItemText secondary={t("storage.cache.UA-managed")}>
Clear cache... {t("storage.cache.clear")}
</ListItemText> </ListItemText>
</ListItemButton> </ListItemButton>
</li> </li>
@ -321,7 +320,17 @@ const Settings: Component = () => {
</ListItemText> </ListItemText>
</ListItemButton> </ListItemButton>
<Divider /> <Divider />
<ListItem> <ListItem
secondaryAction={
<IconButton
component={A}
aria-label={t("mastodonlink.open")}
href={`/${encodeURIComponent(profiles().length > 0 ? makeAcctText(profiles()[0]) : "@")}/profile/@tutu@indieweb.social`}
>
<MastodonLogo />
</IconButton>
}
>
<ListItemText secondary={t("About Tutu.2nd")}> <ListItemText secondary={t("About Tutu.2nd")}>
{t("About Tutu")} {t("About Tutu")}
</ListItemText> </ListItemText>

View file

@ -33,5 +33,11 @@
"motions.gifs": "GIFs", "motions.gifs": "GIFs",
"motions.gifs.autoplay": "Auto-play GIFs", "motions.gifs.autoplay": "Auto-play GIFs",
"motions.vids": "Videos", "motions.vids": "Videos",
"motions.vids.autoplay": "Auto-play Videos" "motions.vids.autoplay": "Auto-play Videos",
"storage": "Storage",
"storage.cache.clear": "Clear Cache",
"storage.cache.UA-managed": "Cache is managed by your browser.",
"mastodonlink.open": "Open Tutu's Mastodon"
} }

View file

@ -33,5 +33,11 @@
"motions.gifs": "动图", "motions.gifs": "动图",
"motions.gifs.autoplay": "自动播放动图", "motions.gifs.autoplay": "自动播放动图",
"motions.vids": "视频", "motions.vids": "视频",
"motions.vids.autoplay": "自动播放视频" "motions.vids.autoplay": "自动播放视频",
"storage": "存储空间",
"storage.cache.clear": "清除缓存",
"storage.cache.UA-managed": "缓存由你的浏览器管理。",
"mastodonlink.open": "打开图图的Mastodon账户"
} }