Rewrite timelines #25
147 changed files with 3356 additions and 8784 deletions
1
.browserlist
Normal file
1
.browserlist
Normal file
|
@ -0,0 +1 @@
|
|||
>0.3% and not dead, firefox>=98, safari>=15.4, chrome>=84
|
5
.env
5
.env
|
@ -1,5 +0,0 @@
|
|||
DEV_SERVER_HTTPS_CERT_BASE=
|
||||
DEV_SERVER_HTTPS_CERT_PASS=
|
||||
DEV_LOCATOR_EDITOR=vscode
|
||||
VITE_DEVTOOLS_OVERLAY=true
|
||||
VITE_PLATFORM_MASONRY_ALWAYS_COMPAT=
|
|
@ -1,34 +0,0 @@
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
checkpr:
|
||||
runs-on: fedora-41
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: 'package.json'
|
||||
|
||||
- name: Cache Dependencies
|
||||
id: dependencies-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Validate types
|
||||
run: bun typecheck
|
||||
|
||||
- name: Build Dist
|
||||
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist
|
|
@ -8,7 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
depoly:
|
||||
runs-on: fedora-41
|
||||
runs-on: fedora-40
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
@ -30,15 +30,12 @@ jobs:
|
|||
- name: Install Dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Validate types
|
||||
run: bun typecheck
|
||||
|
||||
- name: Build Dist (Staging)
|
||||
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist -m staging
|
||||
run: bun dist -m staging
|
||||
if: env.GITHUB_REF_NAME == 'master'
|
||||
|
||||
- name: Build Dist
|
||||
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist
|
||||
run: bun dist
|
||||
if: env.GITHUB_REF_NAME != 'master'
|
||||
|
||||
- name: Depoly to Preview
|
||||
|
|
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -1 +0,0 @@
|
|||
*.lockb binary diff=lockb
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,5 +1,3 @@
|
|||
node_modules
|
||||
dist/
|
||||
dev-dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
dev-dist/
|
16
README.md
16
README.md
|
@ -2,24 +2,16 @@
|
|||
|
||||
Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet.
|
||||
|
||||
[Launch Tutu](https://tutu.lightstands.xyz)
|
||||
|
||||
## Compatibility
|
||||
|
||||
The code is built against those targets and Tutu must run on those platforms:
|
||||
|
||||
| Firefox | Safari | iOS | Chrome & Edge |
|
||||
| ------- | ------ | ----- | ------------- |
|
||||
| 115 | 15.6 | 15.6 | 108 |
|
||||
| Firefox | Safari | iOS | Chrome | Edge |
|
||||
| ------- | ------ | ----- | ------ | ---- |
|
||||
| 98 | 15.4 | 15.4 | 84 | 87 |
|
||||
|
||||
Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement.
|
||||
|
||||
## The "Nightly" Branch
|
||||
|
||||
Tutu built on the latest code, called the nightly version. You can tatse latest change but risks your data.
|
||||
|
||||
[Launch Tutu (Nightly)](https://master.tututheapp.pages.dev)
|
||||
|
||||
## Build & Depoly
|
||||
|
||||
Tutu uses [bun](https://bun.sh) as the package manager. Run
|
||||
|
@ -32,6 +24,6 @@ to build the distribution, saved at "dist" directory.
|
|||
|
||||
Tutu must be loaded in a secure context - that means serving under localhost (`127.0.0.1` or `::1`) or HTTPS.
|
||||
|
||||
Use `bun dev` to run the dev server on localhost. If you need HTTPS dev server, see [*Set up HTTPS for the dev server*](docs/dev-https.md).
|
||||
Use `bun dev` to run the dev server. By default, the dev server is run on the domain <https://localhost.direct> and the port you defined. The cert is provided by localhost.direct, see <https://get.localhost.direct> for detail.
|
||||
|
||||
You can also change the config to make it run on localhost - but localhost.direct can be visted from the other device on the same network (as you do a little DNS magic).
|
||||
|
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -1,27 +0,0 @@
|
|||
# Set up HTTPS for the dev server
|
||||
|
||||
With a valid HTTP server, you can let the other devices access your dev server, with features only available to HTTPS.
|
||||
|
||||
You can use [localhost.direct](https://get.localhost.direct) certificate for set up local HTTPS server. Any vaild certificate is also allowed.
|
||||
|
||||
Download the certs and unpack them. In this document we put them under "tools/cert/".
|
||||
|
||||
Create or edit the file ".env.local", this file is ignored by git. Copy the content from ".env":
|
||||
|
||||
```env
|
||||
DEV_SERVER_HTTPS_CERT_BASE=
|
||||
DEV_SERVER_HTTPS_CERT_PASS=
|
||||
```
|
||||
|
||||
The `DEV_SERVER_HTTPS_CERT_BASE` is the basename for your cert. The cert includes two files to work: one's suffix is `.key`, the another is `.crt`. The base is the common part of them.
|
||||
|
||||
If you have files "tools/cert/localhost.direct.key" and "tools/cert/localhost.direct.crt", the value you need is "tools/cert/localhost.direct".
|
||||
|
||||
The `DEV_SERVER_HTTPS_CERT_PASS` is the password to unlock the key. For the localhost.direct, it's `localhost`.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```env
|
||||
DEV_SERVER_HTTPS_CERT_BASE=tools/cert/localhost.direct
|
||||
DEV_SERVER_HTTPS_CERT_PASS=localhost
|
||||
```
|
112
docs/devnotes.md
112
docs/devnotes.md
|
@ -10,6 +10,10 @@ You can debug on the Safari on iOS only if you have mac (and run macOS). The cer
|
|||
- For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice.
|
||||
- You can use network debugging apps like "Shadowrocket" to do such thing.
|
||||
|
||||
## Hero Animation won't work (after hot reload)
|
||||
|
||||
That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work.
|
||||
|
||||
## The components don't react to the change as I setting the store, until the page reloaded
|
||||
|
||||
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
|
||||
|
@ -37,111 +41,3 @@ export function updateAcctInf(idx: number) {
|
|||
```
|
||||
|
||||
Ja, the code is weird, but that's the best we know. Anyway, you need new object on the path of your changed value.
|
||||
|
||||
## `transition: *-block or *-inline` does not work on WebKit
|
||||
|
||||
Idk why, but transition on logical directions may not work on WebKit - sometimes they work.
|
||||
|
||||
Use physical directions to avoid trouble, like "margin-top, margin-bottom".
|
||||
|
||||
## Safe area insets
|
||||
|
||||
For isolating control of the UI effect, we already setup css variables `--safe-area-inset-*`. In components, you should use the variables unless you have reasons to use `env()`.
|
||||
|
||||
Using `--safe-area-inset-*`, you can control the global value in settings (under dev mode).
|
||||
|
||||
## Module Isolation
|
||||
|
||||
> Write the code that can be easily removed.
|
||||
|
||||
To limit the code impact, we organize the code based on **"topic modules"** (modules in short). Each module focus on a specific topic described by the name. Like the "accounts" contains the code about the accounts, "masto" contains the code about the masto (a library used to access mastodon) helpers.
|
||||
|
||||
> Sidenote: This also helps easing "the landing problem". If you need something about accounts, no longer "common/accounts" and "hooks/accounts" and "helpers/accounts" and "components/accounts". Someone says this is clean - is it even if you need to jump between 6 directories for how one simple feature works?
|
||||
> And you no longer needs to think about "where to place this file (between six directories, usually)". People often optimize their code structure too early - just like how they treat the runtime performance.
|
||||
> The worse is, it's very hard to solve this problem later, because you had sent your code to different places.
|
||||
|
||||
There are **two special modules** in this project:
|
||||
|
||||
One is the *platform*. This module provides foundation of this app: deals with the host platform (like SizedTextarea - auto resized textarea), provides custom platform feature (like StackedRouter - provides mobile-native navigation experience).
|
||||
|
||||
The another is the *material*. This module provides Material styling toolkit, the stylesheets, MUI Theme, constants and components.
|
||||
|
||||
They (and only them) can be accessed by special aliases: `~{module name}`, like the `~platform`.
|
||||
|
||||
We discourage cross referencings between two topics. Reuse is not better than duplication. Cross referencing is still possible if required.
|
||||
|
||||
When a tool, a file or a component is required every-elsewhere, **promoting** is required to reduce the cross referencing. Thanksfully, it's usually automated process for moving files.
|
||||
|
||||
But, sometimes you need a redesigned (sometimes better) tool for the generic usage. Follow the idea:
|
||||
|
||||
- Move slowly or crash. Only make the change if it's required.
|
||||
- Try to make the original part depends on your new tool, and keep the original for awhile.
|
||||
- Mark deprecated only if you think the original won't worth an existence. Reasons:
|
||||
- Migrate to the new code only needs minor change.
|
||||
- The original code has critical problems, like performance or compatibility.
|
||||
- Make notes. Communication is important, even with the future you.
|
||||
- *Why* this move is decided?
|
||||
- *What* this new tool does?
|
||||
- *How* this tool works?
|
||||
- Clean up code regularly. Don't keep the unused code forever.
|
||||
|
||||
## Managing CSS
|
||||
|
||||
Two techniques are still:
|
||||
|
||||
- Styled compoenent (solid-styled)
|
||||
- Native CSS with CSS layering
|
||||
|
||||
The second is recommended for massive use. A stylesheet for a component can be placed alongside
|
||||
the component's file. The stylesheet must use the same name as the component's file name, but replace the extension with
|
||||
`.css`. Say there is a component file "PreviewCard.tsx", the corresponding stylesheet is "PreviewCard.css". They are imported
|
||||
by the component file, so the side effect will be applied by the bundler.
|
||||
|
||||
The speicifc component uses a root class to scope the rulesets' scope. This convention allows the component's style can be influenced
|
||||
by the other stylesheets. It works because Tutu is an end-user application, we gain the control of all stylesheets in the app (kind of).
|
||||
Keep in mind that the native stylesheets will be applied globally at any time, you must carefully craft the stylesheet to avoid leaking
|
||||
of style.
|
||||
|
||||
Three additional CSS layers are declared as:
|
||||
|
||||
- compat: Compatibility rules, like normalize.css
|
||||
- theme: The theme rules
|
||||
- material: The internal material styles
|
||||
|
||||
When working on the material package, if the style is intended to work with the user styles,
|
||||
it must be declared under the material layer. Otherwise the unlayer, which has the
|
||||
highest priority in the author's, can be used.
|
||||
|
||||
Styled component is still existing. Though styled component, using attributes for scoping,
|
||||
may not be as performant as the techniques with CSS class names;
|
||||
it's still provided in the code infrastructure for its ease.
|
||||
|
||||
The following is an example of the recommended usage of solid-styled:
|
||||
|
||||
```tsx
|
||||
// An example of using solid-styled
|
||||
import { css } from "solid-styled";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
const Component = () => {
|
||||
const [width, setWidth] = createSignal(100);
|
||||
|
||||
css`
|
||||
.root {
|
||||
width: ${width()}%;
|
||||
}
|
||||
`
|
||||
return <div class="root"></div>
|
||||
};
|
||||
```
|
||||
|
||||
When developing new component, you can use styled component at first, and migrate
|
||||
to native css slowly.
|
||||
|
||||
Before v2.0.0, there are CSS modules in use, but they are removed:
|
||||
|
||||
- Duplicated loads
|
||||
- Unaware of order (failed composing)
|
||||
- Not-ready for hot reload
|
||||
|
||||
In short, CSS module does not works well if the stylesheet will be accessed from more than one component.
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
# Optimizing Tutu
|
||||
|
||||
Topic Index:
|
||||
|
||||
- Time to first byte
|
||||
- Time to first draw: [Load size](#load-size)
|
||||
- CLS
|
||||
- Framerate: [Algorithm](#algorithm), [CSS containment](#css-containment)
|
||||
|
||||
## Load size
|
||||
|
||||
The baseline for the load size is lowest 3G download bandwidth in 2s, typically 1.1Mbps (or ~137 kilobytes/s) * 2s = 274 kilobytes.
|
||||
|
||||
In another words there is 274 kilobytes budget for an interaction without further notice. Notice and progress are needed if the interaction needs than that.
|
||||
|
||||
The service worker can use 1 chunk of size.
|
||||
|
||||
## Algorithm
|
||||
|
||||
Don't choose algorithm solely on the time complexity. GUI app needs smooth, not fast. The priority:
|
||||
|
||||
- On the main thread: batching. Batching is usually required to spread the work to multiple frames.
|
||||
- Think in Map-Reduce framework if you don't have any idea.
|
||||
- On the worker thread: balance the speed and the memory usage.
|
||||
- Arrays are usually faster and use less memory.
|
||||
- Worker is always available on our target platforms, but workers introduce latency in the starting and the communication. And, even it's available on targets, it's not always
|
||||
available on user's platform, so fallback is always required.
|
||||
|
||||
## CSS containment
|
||||
|
||||
`contain` property is a powerful tool that hints user agents to utilise specific conditions can only be easily identified by developers. [This article from MDN can help you undetstand this property](https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work).
|
||||
|
||||
But it comes with cost. Modern browsers are already very smart on rendering. Using `contain`, you are trading onething off for another:
|
||||
|
||||
- `layout` affects the reflow. This property usually won't make large change: mainline browsers already can incrementally reflow.
|
||||
- `style` affects the style computation, is automatically enabled when using `container` property. Usually won't make large change too, unless you frequently change the styles (and/or your stylesheet is large and/or with complex selectors), containing the computation may help performance.
|
||||
- `paint` affects the shading, the pixel-filling process. This is useful - the shading is resource-heavy - but the browser may need more buffers and more time to compose the final frame.
|
||||
- This containment may increase memory usage.
|
||||
- `size` says the size is not affected by outside elements and is defined. It hints the user agent can use the pre-defined size and/or cache the computed size (with `auto` keyword).
|
||||
- Must be used with `contain-intrinsic-size`.
|
||||
- You can use `content-visibility: auto`, a stonger hint for browsers to skip elements if possible. You can see this like built-in "virtual list", which is used for rendering infinite size of dataset.
|
|
@ -1,32 +0,0 @@
|
|||
# Versioning & Development Cycle
|
||||
|
||||
The versioning policy follows the [Semantic Versioning](https://semver.org/).
|
||||
Since Tutu is an app for the end user, we redefine the some words in the policy:
|
||||
|
||||
- API changes: the app is no longer available on certain platforms.
|
||||
|
||||
## Development Cycle
|
||||
|
||||
Dependency Freeze -> Development -> Release
|
||||
|
||||
### Dependency Freeze
|
||||
|
||||
This step is for:
|
||||
|
||||
- Update dependencies
|
||||
- Prepare the new version (like, bump the version number).
|
||||
|
||||
New dependencies should not be added in this step.
|
||||
|
||||
### Development
|
||||
|
||||
In this step, dependencies can only be updated if it's required to fix bugs.
|
||||
|
||||
New dependencies should be added as their use, in this step.
|
||||
|
||||
### Release
|
||||
|
||||
The version is released to production in this step.
|
||||
|
||||
Before the next development step, new versions can still be released to
|
||||
fix bugs.
|
|
@ -1,10 +0,0 @@
|
|||
import { ManifestOptions } from "vite-plugin-pwa";
|
||||
|
||||
const manifest: Partial<ManifestOptions> = {
|
||||
name: "Tutu for Mastodon",
|
||||
short_name: "Tutu",
|
||||
description: "Tutu is an app to read, post, dog and cat on the mastodon.",
|
||||
theme_color: "#673ab7"
|
||||
};
|
||||
|
||||
export default manifest;
|
64
package.json
64
package.json
|
@ -1,77 +1,55 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "tutu",
|
||||
"version": "2.0.0",
|
||||
"version": "1.0.8",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"preview": "vite preview",
|
||||
"dist": "vite build",
|
||||
"count-source-lines": "exec scripts/src-lc.sh",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck",
|
||||
"wdio": "wdio run ./wdio.conf.ts"
|
||||
"dist": "vite build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Rubicon",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@solid-devtools/overlay": "^0.33.0",
|
||||
"@suid/vite-plugin": "^0.3.1",
|
||||
"@testing-library/webdriverio": "^3.2.1",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/masonry-layout": "^4.2.8",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@wdio/cli": "^9.5.1",
|
||||
"@wdio/lighthouse-service": "^9.5.1",
|
||||
"@wdio/local-runner": "^9.5.1",
|
||||
"@wdio/mocha-framework": "^9.5.0",
|
||||
"@wdio/spec-reporter": "^9.5.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"@suid/vite-plugin": "^0.3.0",
|
||||
"@types/hammerjs": "^2.0.45",
|
||||
"postcss": "^8.4.45",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.5",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-solid": "^2.11.0",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-solid": "^2.10.2",
|
||||
"vite-plugin-solid-styled": "^0.11.1",
|
||||
"wdio-vite-service": "^2.0.0",
|
||||
"wdio-wait-for": "^3.0.11",
|
||||
"workbox-build": "^7.3.0",
|
||||
"wrangler": "^3.99.0"
|
||||
"wrangler": "^3.78.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.10",
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@nanostores/persistent": "^0.10.2",
|
||||
"@nanostores/solid": "^0.5.0",
|
||||
"@nanostores/solid": "^0.4.2",
|
||||
"@solid-primitives/event-listener": "^2.3.3",
|
||||
"@solid-primitives/i18n": "^2.1.1",
|
||||
"@solid-primitives/intersection-observer": "^2.1.6",
|
||||
"@solid-primitives/map": "^0.4.13",
|
||||
"@solid-primitives/page-visibility": "^2.0.17",
|
||||
"@solid-primitives/resize-observer": "^2.0.26",
|
||||
"@solidjs/router": "^0.15.2",
|
||||
"@suid/icons-material": "^0.8.1",
|
||||
"@suid/material": "^0.18.0",
|
||||
"@solidjs/router": "^0.14.5",
|
||||
"@suid/icons-material": "^0.8.0",
|
||||
"@suid/material": "^0.17.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"fast-average-color": "^9.4.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"iso-639-1": "^3.1.3",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"masto": "^6.10.1",
|
||||
"masto": "^6.8.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"normalize.css": "^8.0.1",
|
||||
"solid-devtools": "^0.33.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-js": "^1.8.22",
|
||||
"solid-styled": "^0.11.1",
|
||||
"solid-transition-group": "^0.2.3",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0"
|
||||
"web-animations-js": "^2.3.2"
|
||||
},
|
||||
"packageManager": "bun@1.1.34"
|
||||
"packageManager": "bun@1.1.21"
|
||||
}
|
||||
|
|
133
public/logo.svg
133
public/logo.svg
|
@ -1,133 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="448.06656"
|
||||
height="443.66376"
|
||||
viewBox="0 0 448.06656 443.66376"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<title
|
||||
id="title6">Tutu's Icon</title>
|
||||
<defs
|
||||
id="defs1">
|
||||
<linearGradient
|
||||
id="linearGradient4">
|
||||
<stop
|
||||
style="stop-color:#fac8a3;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop4" />
|
||||
<stop
|
||||
style="stop-color:#fac8a3;stop-opacity:1;"
|
||||
offset="0.72011906"
|
||||
id="stop6" />
|
||||
<stop
|
||||
style="stop-color:#f48d8a;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
xlink:href="#linearGradient4"
|
||||
id="linearGradient5"
|
||||
x1="244.74585"
|
||||
y1="430.05423"
|
||||
x2="281.31232"
|
||||
y2="82.147469"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
xlink:href="#linearGradient4"
|
||||
id="linearGradient9"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="244.74585"
|
||||
y1="430.05423"
|
||||
x2="281.31232"
|
||||
y2="82.147469" />
|
||||
</defs>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="matrix(0,1.4786237,-1.4786237,0,611.53939,-132.05234)"
|
||||
style="display:none">
|
||||
<path
|
||||
style="fill:url(#linearGradient5);stroke:#000000;stroke-width:2;stroke-dasharray:none"
|
||||
d="m 142.83262,404.32618 c 82.40343,-32.96137 141.81198,28.9112 141.81198,28.9112 0,0 21.1442,-23.1068 65.90935,-81.91599 51.82799,-68.08783 41.673,-112.8227 41.673,-112.8227 0,0 2.09565,-72.92272 -51.48333,-125.1428 C 299.2268,72.892041 192.11851,96.043081 192.11851,96.043081 c 0,0 -84.44469,24.815289 -100.925375,105.021299 -16.480686,80.20601 51.639485,203.2618 51.639485,203.2618 z"
|
||||
id="path2" />
|
||||
<g
|
||||
id="g6"
|
||||
style="stroke-width:2;stroke-dasharray:none">
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:2.3;stroke-dasharray:none"
|
||||
d="m 210.50439,396.31338 c 0,0 -29.4511,-105.3688 -28.96641,-141.50635 0.34942,-26.05202 -0.47344,-34.47943 3.80644,-54.3634 5.96795,-27.72652 12.70659,-35.21402 12.70659,-35.21402"
|
||||
id="path3" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:1.8;stroke-dasharray:none"
|
||||
d="m 185.10879,292.14608 c 2.88624,-0.57725 6.83376,-30.94178 44.70393,-42.39467"
|
||||
id="path4" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g9"
|
||||
transform="matrix(0,1.5553086,-1.5553086,0,637.18063,-128.31863)">
|
||||
<path
|
||||
style="fill:url(#linearGradient9);stroke:#000000;stroke-width:15.431;stroke-dasharray:none"
|
||||
d="m 117.92992,367.86152 c 25.48298,38.39529 93.78536,33.94838 93.78536,33.94838 0,0 58.98001,-7.82594 105.93153,-57.60357 51.75,-54.86494 41.67301,-98.59259 41.67301,-98.59259 0,0 0.85581,-49.23349 -57.70901,-95.79319 -44.18497,-35.12756 -115.71798,-13.75528 -115.71798,-13.75528 0,0 -87.11283,22.14714 -94.699695,109.46821 -7.087568,81.5744 26.736785,122.32804 26.736785,122.32804 z"
|
||||
id="path6" />
|
||||
<g
|
||||
id="g8"
|
||||
style="stroke-width:2.00025;stroke-dasharray:none"
|
||||
transform="translate(-1.7787639,5.3362916)">
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:15.431;stroke-dasharray:none"
|
||||
d="m 210.50439,396.31338 c 0,0 -29.4511,-105.3688 -28.96641,-141.50635 0.34942,-26.05202 -0.47344,-34.47943 3.80644,-54.3634 5.96795,-27.72652 12.70659,-35.21402 12.70659,-35.21402"
|
||||
id="path7" />
|
||||
<path
|
||||
style="fill:none;stroke:#000000;stroke-width:12.8592;stroke-dasharray:none"
|
||||
d="m 185.10879,292.14608 c 2.88624,-0.57725 6.83376,-30.94178 44.70393,-42.39467"
|
||||
id="path8" />
|
||||
</g>
|
||||
</g>
|
||||
<metadata
|
||||
id="metadata6">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>Tutu's Icon</dc:title>
|
||||
<dc:date>2024/10/22</dc:date>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>Rubicon</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
<dc:rights>
|
||||
<cc:Agent>
|
||||
<dc:title>Rubicon</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:rights>
|
||||
<cc:license
|
||||
rdf:resource="http://creativecommons.org/licenses/by-nc-sa/4.0/" />
|
||||
</cc:Work>
|
||||
<cc:License
|
||||
rdf:about="http://creativecommons.org/licenses/by-nc-sa/4.0/">
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction" />
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution" />
|
||||
<cc:prohibits
|
||||
rdf:resource="http://creativecommons.org/ns#CommercialUse" />
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#ShareAlike" />
|
||||
</cc:License>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
Before Width: | Height: | Size: 5.1 KiB |
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
|
@ -1,12 +0,0 @@
|
|||
import {
|
||||
defineConfig,
|
||||
minimal2023Preset as preset
|
||||
} from '@vite-pwa/assets-generator/config'
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: '2023'
|
||||
},
|
||||
preset,
|
||||
images: ['public/logo.svg']
|
||||
})
|
|
@ -1,10 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Count the source lines.
|
||||
|
||||
find . '(' ! -path "./node_modules/**" ')' \
|
||||
-and '(' ! -path "./.git/**" ')' \
|
||||
-and '(' ! -path "./*dist/**" ')' \
|
||||
-and '(' ! -path "./bun.lockb" ')' \
|
||||
-and '(' ! -path "./docs/**" ')' \
|
||||
-type f -print0 \
|
||||
| wc -l --files0-from=-
|
50
src/App.css
50
src/App.css
|
@ -1,46 +1,12 @@
|
|||
@layer compat, theme, material;
|
||||
|
||||
@import "normalize.css/normalize.css" layer(compat);
|
||||
@import "./material/theme.css" layer(theme);
|
||||
@import "./material/material.css" layer(material);
|
||||
|
||||
@layer compat {
|
||||
:root {
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-left: env(safe-area-inset-left);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
background-color: var(--tutu-color-surface, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
Fix the bottom gap on iOS standalone.
|
||||
https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-100-height-gap-on-bottom
|
||||
*/
|
||||
@media screen and (display-mode: standalone) {
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
:root {
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-left: env(safe-area-inset-left);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
background-color: var(--tutu-color-surface, transparent);
|
||||
overscroll-behavior-block: none;
|
||||
}
|
||||
|
||||
.custom-emoji {
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
|
|
114
src/App.tsx
114
src/App.tsx
|
@ -1,38 +1,22 @@
|
|||
import { Route } from "@solidjs/router";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { ThemeProvider } from "@suid/material";
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
lazy,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { createRootTheme } from "./material/theme.js";
|
||||
import { useRootTheme } from "./material/mui.js";
|
||||
import {
|
||||
Provider as ClientProvider,
|
||||
createMastoClientFor,
|
||||
} from "./masto/clients.js";
|
||||
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import {
|
||||
AppLocaleProvider,
|
||||
createCurrentLanguage,
|
||||
createCurrentRegion,
|
||||
createDateFnLocaleResource,
|
||||
} from "./platform/i18n.jsx";
|
||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||
import {
|
||||
isJSONRPCResult,
|
||||
ResultDispatcher,
|
||||
type JSONRPC,
|
||||
} from "./serviceworker/workerrpc.js";
|
||||
import { Service } from "./serviceworker/services.js";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { ServiceWorkerProvider } from "./platform/host.js";
|
||||
import StackedRouter from "./platform/StackedRouter.js";
|
||||
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
|
||||
|
||||
const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
|
||||
const AccountMastodonOAuth2Callback = lazy(
|
||||
|
@ -43,21 +27,22 @@ const Settings = lazy(() => import("./settings/Settings.js"));
|
|||
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
|
||||
const MotionSettings = lazy(() => import("./settings/Motions.js"));
|
||||
const LanguageSettings = lazy(() => import("./settings/Language.js"));
|
||||
const RegionSettings = lazy(() => import("./settings/Region.js"));
|
||||
const RegionSettings = lazy(() => import("./settings/Region.jsx"));
|
||||
const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
|
||||
const Profile = lazy(() => import("./profiles/Profile.js"));
|
||||
|
||||
const Routing: Component = () => {
|
||||
return (
|
||||
<StackedRouter>
|
||||
<Route path="/" component={TimelineHome} />
|
||||
<Route path="/settings/language" component={LanguageSettings} />
|
||||
<Route path="/settings/region" component={RegionSettings} />
|
||||
<Route path="/settings/motions" component={MotionSettings} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/:acct/toot/:id" component={TootBottomSheet} />
|
||||
<Route path="/:acct/profile/:id" component={Profile} />
|
||||
|
||||
<Router>
|
||||
<Route path="/" component={TimelineHome}>
|
||||
<Route path=""></Route>
|
||||
<Route path="/settings" component={Settings}>
|
||||
<Route path=""></Route>
|
||||
<Route path="/language" component={LanguageSettings}></Route>
|
||||
<Route path="/region" component={RegionSettings}></Route>
|
||||
<Route path="/motions" component={MotionSettings}></Route>
|
||||
</Route>
|
||||
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
|
||||
</Route>
|
||||
<Route path={"/accounts"}>
|
||||
<Route path={"/sign-in"} component={AccountSignIn} />
|
||||
<Route
|
||||
|
@ -65,55 +50,14 @@ const Routing: Component = () => {
|
|||
component={AccountMastodonOAuth2Callback}
|
||||
/>
|
||||
</Route>
|
||||
</StackedRouter>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
const App: Component = () => {
|
||||
const theme = createRootTheme();
|
||||
const theme = useRootTheme();
|
||||
const accts = useStore($accounts);
|
||||
const lang = createCurrentLanguage();
|
||||
const region = createCurrentRegion();
|
||||
const dateFnLocale = createDateFnLocaleResource(region);
|
||||
const [serviceWorker, setServiceWorker] = createSignal<
|
||||
ServiceWorker | undefined
|
||||
>(undefined, { name: "serviceWorker" });
|
||||
const dispatcher = new ResultDispatcher();
|
||||
|
||||
let checkAge = 0;
|
||||
const untilServiceWorkerAlive = async (
|
||||
worker: ServiceWorker,
|
||||
expectedAge: number,
|
||||
) => {
|
||||
const [call, ret] = dispatcher.createTypedCall<Service>("ping");
|
||||
worker.postMessage(await call);
|
||||
const result = await ret;
|
||||
console.assert(!result.error, result);
|
||||
if (expectedAge === checkAge) {
|
||||
setServiceWorker(worker);
|
||||
}
|
||||
};
|
||||
|
||||
makeEventListener(window, "message", (event: MessageEvent<JSONRPC>) => {
|
||||
if (isJSONRPCResult(event.data)) {
|
||||
dispatcher.dispatch(event.data.id, event.data);
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
offlineReady: [offlineReady],
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(scriptUrl, reg) {
|
||||
console.info("service worker is registered from %s", scriptUrl);
|
||||
const active = reg?.active;
|
||||
if (!active) {
|
||||
console.warn("No service is in activating or activated");
|
||||
return;
|
||||
}
|
||||
untilServiceWorkerAlive(active, checkAge++);
|
||||
},
|
||||
});
|
||||
const lang = useLanguage();
|
||||
|
||||
const clients = createMemo(() => {
|
||||
return accts().map((x) => ({
|
||||
|
@ -156,26 +100,12 @@ const App: Component = () => {
|
|||
return <UnexpectedError error={err} />;
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AppLocaleProvider
|
||||
value={{
|
||||
language: lang,
|
||||
region: region,
|
||||
dateFn: dateFnLocale,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme()}>
|
||||
<DateFnScope>
|
||||
<ClientProvider value={clients}>
|
||||
<ServiceWorkerProvider
|
||||
value={{
|
||||
needRefresh,
|
||||
offlineReady,
|
||||
serviceWorker,
|
||||
}}
|
||||
>
|
||||
<Routing />
|
||||
</ServiceWorkerProvider>
|
||||
<Routing />
|
||||
</ClientProvider>
|
||||
</AppLocaleProvider>
|
||||
</DateFnScope>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
@ -18,16 +18,7 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
.join("\n");
|
||||
return `${err.name}: ${err.message}\n${strackMsg}`;
|
||||
} catch (reason) {
|
||||
return `<failed to build the stacktrace of "${err}"...>\n${reason}\n${JSON.stringify(
|
||||
{
|
||||
name: err.name,
|
||||
stack: err.stack,
|
||||
cause: err.cause,
|
||||
message: err.message,
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
)}`;
|
||||
return `<failed to build the stacktrace of "${err}"...>\n${reason}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,29 +33,6 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
calc(var(--safe-area-inset-bottom) + 20px)
|
||||
calc(var(--safe-area-inset-left) + 20px);
|
||||
}
|
||||
|
||||
details {
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
overflow: auto;
|
||||
|
||||
& * {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
|
||||
summary {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
|
@ -72,25 +40,17 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
|||
<h1>Oh, it is our fault.</h1>
|
||||
<p>There is an unexpected error in our app, and it's not your fault.</p>
|
||||
<p>
|
||||
You can restart the app to see if this guy is gone. If you meet this guy
|
||||
You can reload to see if this guy is gone. If you meet this guy
|
||||
repeatly, please report to us.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<Button
|
||||
onClick={() => window.location.replace("/")}
|
||||
variant="contained"
|
||||
>
|
||||
Restart App
|
||||
</Button>
|
||||
<div>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
||||
</summary>
|
||||
<pre>
|
||||
On: {window.location.href} <br />
|
||||
{errorMsg()}
|
||||
</pre>
|
||||
<pre>{errorMsg()}</pre>
|
||||
</details>
|
||||
</main>
|
||||
);
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
.MastodonOAuth2Callback {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 448px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& {
|
||||
position: static;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: none;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useSearchParams } from "@solidjs/router";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
Show,
|
||||
|
@ -8,13 +8,12 @@ import {
|
|||
} from "solid-js";
|
||||
import { acceptAccountViaAuthCode } from "./stores";
|
||||
import { $settings } from "../settings/stores";
|
||||
import "~material/cards.css";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import cards from "../material/cards.module.css";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import Img from "~material/Img";
|
||||
import Img from "../material/Img";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { Title } from "~material/typography";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
import { Title } from "../material/typography";
|
||||
|
||||
type OAuth2CallbackParams = {
|
||||
code?: string;
|
||||
|
@ -26,13 +25,14 @@ const MastodonOAuth2Callback: Component = () => {
|
|||
const progressId = createUniqueId();
|
||||
const titleId = createUniqueId();
|
||||
const [params] = useSearchParams<OAuth2CallbackParams>();
|
||||
const { push: navigate } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
|
||||
const [siteImg, setSiteImg] = createSignal<{
|
||||
src: string;
|
||||
srcset?: string;
|
||||
blurhash: string;
|
||||
}>();
|
||||
const [siteTitle, setSiteTitle] = createSignal("Mastodon");
|
||||
const [siteTitle, setSiteTitle] = createSignal("the Mastodon server");
|
||||
|
||||
onMount(async () => {
|
||||
const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
|
||||
|
@ -41,6 +41,7 @@ const MastodonOAuth2Callback: Component = () => {
|
|||
url: onGoingOAuth2Process,
|
||||
});
|
||||
const ins = await client.v2.instance.fetch();
|
||||
setDocumentTitle(`Back from ${ins.title}...`);
|
||||
setSiteTitle(ins.title);
|
||||
|
||||
const srcset = [];
|
||||
|
@ -91,45 +92,42 @@ const MastodonOAuth2Callback: Component = () => {
|
|||
});
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>Back from {siteTitle()}</DocumentTitle>
|
||||
<main class="MastodonOAuth2Callback">
|
||||
<div class="card card-auto-margin" aria-busy="true" aria-describedby={progressId}>
|
||||
<LinearProgress
|
||||
class="card-no-pad card-gut-skip"
|
||||
id={progressId}
|
||||
aria-labelledby={titleId}
|
||||
/>
|
||||
<Show
|
||||
when={siteImg()}
|
||||
fallback={
|
||||
<i
|
||||
aria-busy="true"
|
||||
aria-label="Preparing image..."
|
||||
style={{ height: "235px", display: "block" }}
|
||||
></i>
|
||||
}
|
||||
>
|
||||
<Img
|
||||
src={siteImg()?.src}
|
||||
srcset={siteImg()?.srcset}
|
||||
blurhash={siteImg()?.blurhash}
|
||||
class="card-no-pad card-gut-skip"
|
||||
alt={`Banner image for ${siteTitle()}`}
|
||||
<div class={cards.layoutCentered}>
|
||||
<div class={cards.card} aria-busy="true" aria-describedby={progressId}>
|
||||
<LinearProgress
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
|
||||
id={progressId}
|
||||
aria-labelledby={titleId}
|
||||
/>
|
||||
<Show
|
||||
when={siteImg()}
|
||||
fallback={
|
||||
<i
|
||||
aria-busy="true"
|
||||
aria-label="Preparing image..."
|
||||
style={{ height: "235px", display: "block" }}
|
||||
/>
|
||||
</Show>
|
||||
></i>
|
||||
}
|
||||
>
|
||||
<Img
|
||||
src={siteImg()?.src}
|
||||
srcset={siteImg()?.srcset}
|
||||
blurhash={siteImg()?.blurhash}
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
|
||||
alt={`Banner image for ${siteTitle()}`}
|
||||
style={{ height: "235px", display: "block" }}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Title component="h6" id={titleId}>
|
||||
Contracting {siteTitle}...
|
||||
</Title>
|
||||
<p>
|
||||
If this page stays too long, you can close this page and sign in
|
||||
again.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
<Title component="h6" id={titleId}>
|
||||
Contracting {siteTitle}...
|
||||
</Title>
|
||||
<p>
|
||||
If this page stays too long, you can close this page and sign in
|
||||
again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
.SignIn {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 448px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& {
|
||||
position: static;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
>.key-content {
|
||||
height: 100%;
|
||||
|
||||
>form {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,21 +2,22 @@ import {
|
|||
Component,
|
||||
Show,
|
||||
createEffect,
|
||||
createSelector,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import "~material/cards.css";
|
||||
import TextField from "~material/TextField.js";
|
||||
import Button from "~material/Button.js";
|
||||
import { Title } from "~material/typography";
|
||||
import cards from "../material/cards.module.css";
|
||||
import TextField from "../material/TextField.js";
|
||||
import Button from "../material/Button.js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { Title } from "../material/typography";
|
||||
import { css } from "solid-styled";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { getOrRegisterApp } from "./stores";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { $settings } from "../settings/stores";
|
||||
import "./SignIn.css";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
|
||||
type ErrorParams = {
|
||||
error: string;
|
||||
|
@ -34,6 +35,15 @@ const SignIn: Component = () => {
|
|||
const [serverUrlError, setServerUrlError] = createSignal(false);
|
||||
const [targetSiteTitle, setTargetSiteTitle] = createSignal("");
|
||||
|
||||
useDocumentTitle("Sign In");
|
||||
css`
|
||||
form {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const serverUrl = () => {
|
||||
const url = rawServerUrl();
|
||||
if (url.length === 0 || /^%w:/.test(url)) {
|
||||
|
@ -111,58 +121,55 @@ const SignIn: Component = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>Sign In</DocumentTitle>
|
||||
<main class="SignIn">
|
||||
<Show when={params.error || params.errorDescription}>
|
||||
<div class="card card-auto-margin" style={{ "margin-bottom": "20px" }}>
|
||||
<p>Authorization is failed.</p>
|
||||
<p>{params.errorDescription}</p>
|
||||
<p>
|
||||
Please try again later. If the problem persists, you can ask for
|
||||
help from the server administrator.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="card card-auto-margin key-content"
|
||||
aria-busy={currentState() !== "inactive" ? "true" : "false"}
|
||||
aria-describedby={
|
||||
currentState() !== "inactive" ? progressId : undefined
|
||||
}
|
||||
style={{
|
||||
padding: `var(--safe-area-inset-top) var(--safe-area-inset-right) var(--safe-area-inset-bottom) var(--safe-area-inset-left)`,
|
||||
}}
|
||||
>
|
||||
<LinearProgress
|
||||
class={"card-no-pad card-gut-skip"}
|
||||
id={progressId}
|
||||
sx={currentState() === "inactive" ? { display: "none" } : undefined}
|
||||
/>
|
||||
<form onSubmit={onStartOAuth2}>
|
||||
<Title component="h6">Sign in with Your Mastodon Account</Title>
|
||||
<TextField
|
||||
label="Mastodon Server"
|
||||
name="serverUrl"
|
||||
onInput={setRawServerUrl}
|
||||
required
|
||||
helperText={serverUrlHelperText()}
|
||||
error={!!serverUrlError()}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", "justify-content": "end" }}>
|
||||
<Button type="submit" disabled={currentState() !== "inactive"}>
|
||||
{currentState() == "inactive"
|
||||
? "Continue"
|
||||
: currentState() == "contracting"
|
||||
? `Contracting ${new URL(serverUrl()).host}...`
|
||||
: `Moving to ${targetSiteTitle}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class={cards.layoutCentered}>
|
||||
<Show when={params.error || params.errorDescription}>
|
||||
<div class={cards.card} style={{ "margin-bottom": "20px" }}>
|
||||
<p>Authorization is failed.</p>
|
||||
<p>{params.errorDescription}</p>
|
||||
<p>
|
||||
Please try again later. If the problem persist, you can seek for
|
||||
help from the server administrator.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
</Show>
|
||||
<div
|
||||
class={/*once*/ cards.card}
|
||||
aria-busy={currentState() !== "inactive" ? "true" : "false"}
|
||||
aria-describedby={
|
||||
currentState() !== "inactive" ? progressId : undefined
|
||||
}
|
||||
style={{
|
||||
padding: `var(--safe-area-inset-top) var(--safe-area-inset-right) var(--safe-area-inset-bottom) var(--safe-area-inset-left)`,
|
||||
}}
|
||||
>
|
||||
<LinearProgress
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
|
||||
id={progressId}
|
||||
sx={currentState() === "inactive" ? { display: "none" } : undefined}
|
||||
/>
|
||||
<form onSubmit={onStartOAuth2}>
|
||||
<Title component="h6">Sign in with Your Mastodon Account</Title>
|
||||
<TextField
|
||||
label="Mastodon Server"
|
||||
name="serverUrl"
|
||||
onInput={setRawServerUrl}
|
||||
required
|
||||
helperText={serverUrlHelperText()}
|
||||
error={!!serverUrlError()}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", "justify-content": "end" }}>
|
||||
<Button type="submit" disabled={currentState() !== "inactive"}>
|
||||
{currentState() == "inactive"
|
||||
? "Continue"
|
||||
: currentState() == "contracting"
|
||||
? `Contracting ${new URL(serverUrl()).host}...`
|
||||
: `Moving to ${targetSiteTitle}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -6,19 +6,10 @@ import {
|
|||
} from "masto";
|
||||
import { createMastoClientFor } from "../masto/clients";
|
||||
|
||||
export type RemoteServer = {
|
||||
export type Account = {
|
||||
site: string;
|
||||
};
|
||||
|
||||
export type AccountKey = RemoteServer & {
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
export function isAccountKey(object: RemoteServer): object is AccountKey {
|
||||
return !!(object as Record<string, unknown>)["accessToken"];
|
||||
}
|
||||
|
||||
export type Account = AccountKey & {
|
||||
tokenType: string;
|
||||
scope: string;
|
||||
createdAt: number;
|
||||
|
@ -26,10 +17,6 @@ export type Account = AccountKey & {
|
|||
inf?: mastodon.v1.AccountCredentials;
|
||||
};
|
||||
|
||||
export function isAccount(object: RemoteServer) {
|
||||
return isAccountKey(object) && !!(object as Record<string, unknown>)["tokenType"];
|
||||
}
|
||||
|
||||
export const $accounts = persistentAtom<Account[]>("accounts", [], {
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
|
@ -180,7 +167,7 @@ export async function getOrRegisterApp(site: string, redirectUrl: string) {
|
|||
});
|
||||
const app = await client.v1.apps.create({
|
||||
clientName: "TuTu",
|
||||
website: "https://code.lightstands.xyz/Rubicon/tutu",
|
||||
website: "https://github.com/thislight/tutu",
|
||||
redirectUris: redirectUrl,
|
||||
scopes: "read write push",
|
||||
});
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import { render } from "solid-js/web";
|
||||
import App from "./App.js";
|
||||
import "solid-devtools";
|
||||
import { attachDevtoolsOverlay } from "@solid-devtools/overlay";
|
||||
import "./material/theme.css";
|
||||
|
||||
render(() => <App />, document.getElementById("root")!);
|
||||
|
||||
if (import.meta.env.VITE_DEVTOOLS_OVERLAY === "true") {
|
||||
attachDevtoolsOverlay();
|
||||
}
|
||||
|
|
28
src/masto/acct.ts
Normal file
28
src/masto/acct.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Accessor, createResource } from "solid-js";
|
||||
import type { mastodon } from "masto";
|
||||
import { useSessions } from "./clients";
|
||||
import { updateAcctInf } from "../accounts/stores";
|
||||
|
||||
export function useSignedInProfiles() {
|
||||
const sessions = useSessions();
|
||||
const [accessor, tools] = createResource(sessions, async (all) => {
|
||||
return Promise.all(
|
||||
all.map(async (x, i) => ({ ...x, inf: await updateAcctInf(i) })),
|
||||
);
|
||||
});
|
||||
return [
|
||||
() => {
|
||||
try {
|
||||
const value = accessor();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
} catch (reason) {
|
||||
console.error("useSignedInProfiles: update acct info failed", reason);
|
||||
}
|
||||
|
||||
return sessions().map((x) => ({ ...x, inf: x.account.inf }));
|
||||
},
|
||||
tools,
|
||||
] as const;
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
import { createCacheBucket } from "~platform/cache";
|
||||
import { MastoHttpError, MastoTimeoutError, MastoUnexpectedError } from "masto";
|
||||
|
||||
export const CACHE_BUCKET_NAME = "mastodon";
|
||||
|
||||
export const cacheBucket = /* @__PURE__ */ createCacheBucket(CACHE_BUCKET_NAME);
|
||||
|
||||
export function toSmallCamelCase<T>(object: T): T {
|
||||
if (!object || typeof object !== "object") {
|
||||
return object;
|
||||
} else if (Array.isArray(object)) {
|
||||
return object.map(toSmallCamelCase) as T;
|
||||
}
|
||||
|
||||
const result = {} as Record<keyof any, unknown>;
|
||||
for (const k in object) {
|
||||
const value = toSmallCamelCase(object[k]);
|
||||
const nk =
|
||||
typeof k === "string"
|
||||
? k.replace(/_(.)/g, (_, match) => match.toUpperCase())
|
||||
: k;
|
||||
result[nk] = value;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function contentTypeOf(headers: Headers) {
|
||||
const raw = headers.get("Content-Type")?.replace(/\s*;.*$/, "");
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrpas the error reason into masto's errors:
|
||||
* {@link MastoHttpError} if the reason is a {@link Response},
|
||||
* {@link MastoTimeoutError} if the reason is a timeout error.
|
||||
*
|
||||
* @throws If the reason is unexpected, {@link MastoUnexpectedError} will be thrown.
|
||||
*/
|
||||
export async function wrapsError(reason: Response): Promise<MastoHttpError>;
|
||||
export async function wrapsError(reason: {
|
||||
name: "TimeoutError";
|
||||
}): Promise<MastoTimeoutError>;
|
||||
export async function wrapsError<T>(reason: T): Promise<T>;
|
||||
|
||||
export async function wrapsError(reason: unknown) {
|
||||
if (reason instanceof Response) {
|
||||
const contentType = contentTypeOf(reason.headers);
|
||||
if (!contentType) {
|
||||
throw new MastoUnexpectedError(
|
||||
"The server returned data with an unknown encoding. The server may be down",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await reason.json();
|
||||
const {
|
||||
error: message,
|
||||
errorDescription,
|
||||
details,
|
||||
...additionalProperties
|
||||
} = data;
|
||||
|
||||
return new MastoHttpError(
|
||||
{
|
||||
statusCode: reason.status,
|
||||
message,
|
||||
description: errorDescription,
|
||||
details,
|
||||
additionalProperties,
|
||||
},
|
||||
{ cause: reason },
|
||||
);
|
||||
}
|
||||
|
||||
if (reason && (reason as { name?: string }).name === "TimeoutError") {
|
||||
return new MastoTimeoutError("Request timed out", { cause: reason });
|
||||
}
|
||||
|
||||
return reason;
|
||||
}
|
|
@ -1,16 +1,14 @@
|
|||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
untrack,
|
||||
Signal,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { Account, type RemoteServer } from "../accounts/stores";
|
||||
import { Account } from "../accounts/stores";
|
||||
import { createRestAPIClient, mastodon } from "masto";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
|
||||
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
||||
|
||||
|
@ -51,22 +49,21 @@ export type Session = {
|
|||
client: mastodon.rest.Client;
|
||||
};
|
||||
|
||||
const Context =
|
||||
/* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
|
||||
const Context = /* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
|
||||
|
||||
export const Provider = Context.Provider;
|
||||
|
||||
export function useSessions() {
|
||||
const sessions = useSessionsRaw();
|
||||
const { push } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
createRenderEffect(() => {
|
||||
if (untrack(() => sessions().length) > 0) return;
|
||||
|
||||
push("/accounts/sign-in?back=" + encodeURIComponent(location.pathname), {
|
||||
replace: true,
|
||||
});
|
||||
if (sessions().length > 0) return;
|
||||
navigate(
|
||||
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
return sessions;
|
||||
|
@ -79,64 +76,3 @@ function useSessionsRaw() {
|
|||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
const DefaultSessionContext = /* @__PURE__ */ createContext<Accessor<number>>(
|
||||
() => 0,
|
||||
);
|
||||
|
||||
export const DefaultSessionProvider = DefaultSessionContext.Provider;
|
||||
|
||||
/**
|
||||
* Return the default session (the first session).
|
||||
*
|
||||
* This function may return `undefined`, but it will try to redirect the user to the sign in.
|
||||
*/
|
||||
export function useDefaultSession() {
|
||||
const sessions = useSessions();
|
||||
const sessionIndex = useContext(DefaultSessionContext);
|
||||
|
||||
return () => {
|
||||
if (sessions().length > 0) {
|
||||
return sessions()[sessionIndex()];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session for the specific acct string.
|
||||
*
|
||||
* Acct string is a string in the pattern of `{username}@{site_with_protocol}`,
|
||||
* like `@thislight@https://mastodon.social`, can be used to identify (tempoarily)
|
||||
* an session on the tutu instance.
|
||||
*
|
||||
* The `site_with_protocol` is required.
|
||||
*
|
||||
* - If the username is present, the session matches the username and the site is returned; or,
|
||||
* - If the username is not present, any session on the site is returned; or,
|
||||
* - If no available session available for the pattern, an unauthorised session is returned.
|
||||
*
|
||||
* In an unauthorised session, the `.account` is {@link RemoteServer} and the `client` is an
|
||||
* unauthorised client for the site. This client may not available for some operations.
|
||||
*/
|
||||
export function useSessionForAcctStr(acct: Accessor<string>) {
|
||||
const allSessions = useSessions();
|
||||
|
||||
return createMemo(() => {
|
||||
const [inputUsername, inputSite] = acct().split("@", 2);
|
||||
const authedSession = allSessions().find(
|
||||
(x) =>
|
||||
x.account.site === inputSite &&
|
||||
x.account.inf?.username === inputUsername,
|
||||
);
|
||||
return (
|
||||
authedSession ?? {
|
||||
client: createUnauthorizedClient(inputSite),
|
||||
account: { site: inputSite } as RemoteServer, // TODO: we need some security checks here?
|
||||
} as const
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function makeAcctText(session: Session) {
|
||||
return `${session.account.inf?.username}@${session.account.site}`;
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { CachedFetch } from "~platform/cache";
|
||||
import { cacheBucket, toSmallCamelCase, wrapsError } from "./base";
|
||||
import { isAccountKey, type RemoteServer } from "../accounts/stores";
|
||||
import { type mastodon } from "masto";
|
||||
|
||||
export const fetchStatus = /* @__PURE__ */ new CachedFetch(
|
||||
cacheBucket,
|
||||
(session: RemoteServer, id: string) => {
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
});
|
||||
if (isAccountKey(session)) {
|
||||
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
return {
|
||||
url: new URL(`./api/v1/statuses/${id}`, session.site).toString(),
|
||||
headers,
|
||||
};
|
||||
},
|
||||
async (response) => {
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
return toSmallCamelCase(
|
||||
await response.json(),
|
||||
) as unknown as mastodon.v1.Status;
|
||||
} catch (reason) {
|
||||
throw wrapsError(reason);
|
||||
}
|
||||
},
|
||||
);
|
|
@ -7,132 +7,86 @@ import {
|
|||
createEffect,
|
||||
createResource,
|
||||
untrack,
|
||||
type Resource,
|
||||
type ResourceFetcherInfo,
|
||||
} from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
|
||||
type Timeline<T extends mastodon.DefaultPaginationParams> = {
|
||||
list(params?: T): mastodon.Paginator<mastodon.v1.Status[], unknown>;
|
||||
type Timeline = {
|
||||
list(params: {
|
||||
/** Return results older than this ID. */
|
||||
readonly maxId?: string;
|
||||
/** Return results newer than this ID. */
|
||||
readonly sinceId?: string;
|
||||
/** Get a list of items with ID greater than this value excluding this ID */
|
||||
readonly minId?: string;
|
||||
/** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */
|
||||
readonly limit?: number;
|
||||
}): mastodon.Paginator<mastodon.v1.Status[], unknown>;
|
||||
};
|
||||
|
||||
type TimelineParamsOf<T> = T extends Timeline<infer P> ? P : never;
|
||||
|
||||
export type ThreadNode = TreeNode<mastodon.v1.Status>;
|
||||
|
||||
function createControlsForLookup(lookup: ReactiveMap<string, ThreadNode>) {
|
||||
return {
|
||||
get(id: string) {
|
||||
return lookup.get(id);
|
||||
},
|
||||
getPath(id: string) {
|
||||
const node = lookup.get(id);
|
||||
if (!node) return;
|
||||
const path = collectPath(node);
|
||||
for (const sym of path) {
|
||||
lookup.get(sym.value.id); // Track every node on the path
|
||||
}
|
||||
return path;
|
||||
},
|
||||
set(id: string, value: mastodon.v1.Status) {
|
||||
const node = untrack(() => lookup.get(id));
|
||||
if (!node) return;
|
||||
lookup.set(id, { ...node, value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineControlsForArray(
|
||||
status: () => mastodon.v1.Status[] | undefined,
|
||||
): TimelineControls {
|
||||
const lookup = new ReactiveMap<string, ThreadNode>();
|
||||
|
||||
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
|
||||
|
||||
createEffect(() => {
|
||||
const nls = catchError(status, (e) => {
|
||||
console.error(e);
|
||||
});
|
||||
if (!nls) return;
|
||||
|
||||
batch(() => {
|
||||
setThreads([]);
|
||||
lookup.clear();
|
||||
|
||||
for (const status of nls) {
|
||||
lookup.set(status.id, {
|
||||
value: status,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
untrack(() => {
|
||||
for (const status of nls) {
|
||||
const node = lookup.get(status.id)!;
|
||||
const parent = status.inReplyToId
|
||||
? lookup.get(status.inReplyToId)
|
||||
: undefined;
|
||||
|
||||
if (parent) {
|
||||
const children = parent.children ?? [];
|
||||
if (!children.find((x) => x.value.id == status.id)) {
|
||||
children.push(node);
|
||||
}
|
||||
parent.children = children;
|
||||
node.parent = parent;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const newThreads = untrack(() =>
|
||||
nls
|
||||
.map((x) => x.id)
|
||||
.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
|
||||
);
|
||||
|
||||
setThreads(newThreads);
|
||||
});
|
||||
|
||||
return {
|
||||
list: threads,
|
||||
...createControlsForLookup(lookup),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineSnapshot<
|
||||
T extends Timeline<mastodon.DefaultPaginationParams>,
|
||||
>(
|
||||
timeline: Accessor<T>,
|
||||
params: Accessor<TimelineParamsOf<T>>,
|
||||
): TimelineResource<mastodon.v1.Status[] | undefined> {
|
||||
export function createTimelineSnapshot(
|
||||
timeline: Accessor<Timeline>,
|
||||
limit: Accessor<number>,
|
||||
) {
|
||||
const [shot, { refetch }] = createResource(
|
||||
() => [timeline(), params()] as const,
|
||||
() => [timeline(), limit()] as const,
|
||||
async ([tl, limit]) => {
|
||||
const ls = await tl.list(limit).next();
|
||||
return ls.value;
|
||||
const ls = await tl.list({ limit }).next();
|
||||
return ls.value?.map((x) => [x]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const controls = createTimelineControlsForArray(shot);
|
||||
const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]);
|
||||
|
||||
createEffect(() => {
|
||||
const nls = catchError(shot, (e) => console.error(e));
|
||||
if (!nls) return;
|
||||
const ols = Array.from(snapshot);
|
||||
// The algorithm below assumes the snapshot is not changing
|
||||
for (let i = 0; i < nls.length; i++) {
|
||||
if (i >= ols.length) {
|
||||
setSnapshot(i, nls[i]);
|
||||
} else {
|
||||
if (nls[i].length !== ols[i].length) {
|
||||
setSnapshot(i, nls[i]);
|
||||
} else {
|
||||
const oth = ols[i],
|
||||
nth = nls[i];
|
||||
for (let j = 0; j < oth.length; j++) {
|
||||
const ost = oth[j],
|
||||
nst = nth[j];
|
||||
for (const key of Object.keys(
|
||||
nst,
|
||||
) as unknown as (keyof mastodon.v1.Status)[]) {
|
||||
if (ost[key] !== nst[key]) {
|
||||
setSnapshot(i, j, key, nst[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
controls,
|
||||
snapshot,
|
||||
shot,
|
||||
{
|
||||
refetch,
|
||||
mutate: setSnapshot,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
export type TimelineFetchDirection = mastodon.Direction;
|
||||
|
||||
export type TimelineChunk<T extends mastodon.DefaultPaginationParams> = {
|
||||
tl: Timeline<T>;
|
||||
export type TimelineChunk = {
|
||||
tl: Timeline;
|
||||
rebuilt: boolean;
|
||||
chunk: readonly mastodon.v1.Status[];
|
||||
done?: boolean;
|
||||
direction: TimelineFetchDirection;
|
||||
params: T;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type TreeNode<T> = {
|
||||
|
@ -154,20 +108,21 @@ function collectPath<T>(node: TreeNode<T>) {
|
|||
return path;
|
||||
}
|
||||
|
||||
function createTimelineChunk<
|
||||
T extends Timeline<mastodon.DefaultPaginationParams>,
|
||||
>(timeline: Accessor<T>, params: Accessor<TimelineParamsOf<T>>) {
|
||||
function createTimelineChunk(
|
||||
timeline: Accessor<Timeline>,
|
||||
limit: Accessor<number>,
|
||||
) {
|
||||
let vpMaxId: string | undefined, vpMinId: string | undefined;
|
||||
|
||||
const fetchExtendingPage = async (
|
||||
tl: T,
|
||||
tl: Timeline,
|
||||
direction: TimelineFetchDirection,
|
||||
params: TimelineParamsOf<T>,
|
||||
limit: number,
|
||||
) => {
|
||||
switch (direction) {
|
||||
case "next": {
|
||||
const page = await tl
|
||||
.list({ ...params, sinceId: vpMaxId })
|
||||
.list({ limit, sinceId: vpMaxId })
|
||||
.setDirection(direction)
|
||||
.next();
|
||||
if ((page.value?.length ?? 0) > 0) {
|
||||
|
@ -178,7 +133,7 @@ function createTimelineChunk<
|
|||
|
||||
case "prev": {
|
||||
const page = await tl
|
||||
.list({ ...params, maxId: vpMinId })
|
||||
.list({ limit, maxId: vpMinId })
|
||||
.setDirection(direction)
|
||||
.next();
|
||||
if ((page.value?.length ?? 0) > 0) {
|
||||
|
@ -190,11 +145,11 @@ function createTimelineChunk<
|
|||
};
|
||||
|
||||
return createResource(
|
||||
() => [timeline(), params()] as const,
|
||||
() => [timeline(), limit()] as const,
|
||||
async (
|
||||
[tl, params],
|
||||
[tl, limit],
|
||||
info: ResourceFetcherInfo<
|
||||
Readonly<TimelineChunk<TimelineParamsOf<T>>>,
|
||||
Readonly<TimelineChunk>,
|
||||
TimelineFetchDirection
|
||||
>,
|
||||
) => {
|
||||
|
@ -205,124 +160,27 @@ function createTimelineChunk<
|
|||
vpMaxId = undefined;
|
||||
vpMinId = undefined;
|
||||
}
|
||||
const posts = await fetchExtendingPage(tl, direction, params);
|
||||
const posts = await fetchExtendingPage(tl, direction, limit);
|
||||
return {
|
||||
tl,
|
||||
rebuilt: rebuildTimeline,
|
||||
chunk: posts.value ?? [],
|
||||
done: posts.done,
|
||||
direction,
|
||||
params,
|
||||
limit,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type TimelineControls = {
|
||||
/**
|
||||
* The threads.
|
||||
*
|
||||
* The identifiers here is the most-bottom toot id in the thread.
|
||||
*
|
||||
* @see You can use {@link TimelineControls.get} and {@link TimelineControls.getPath} to resolve them if
|
||||
* the context is needed.
|
||||
*/
|
||||
list: readonly mastodon.v1.Status["id"][];
|
||||
/**
|
||||
* Get the single node.
|
||||
*/
|
||||
get(id: string): TreeNode<mastodon.v1.Status> | undefined;
|
||||
/**
|
||||
* Collect the path from the node to the most-top node.
|
||||
*/
|
||||
getPath(id: string): TreeNode<mastodon.v1.Status>[] | undefined;
|
||||
/**
|
||||
* Set the node value.
|
||||
*/
|
||||
set(id: string, value: mastodon.v1.Status): void;
|
||||
};
|
||||
|
||||
export type TimelineResource<R> = [
|
||||
TimelineControls,
|
||||
Resource<R>,
|
||||
{ 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.
|
||||
*
|
||||
* The error from the resource is not thrown in the
|
||||
* {@link TimelineControls["list"]} and {@link TimelineControls}.get*.
|
||||
* Use the second value from {@link TimelineResource} to catch the error.
|
||||
*/
|
||||
export function createTimeline<
|
||||
T extends Timeline<mastodon.DefaultPaginationParams>,
|
||||
>(
|
||||
timeline: Accessor<T>,
|
||||
params: Accessor<TimelineParamsOf<T>>,
|
||||
): TimelineResource<TimelineChunk<TimelineParamsOf<T>> | undefined> {
|
||||
export function createTimeline(
|
||||
timeline: Accessor<Timeline>,
|
||||
limit: Accessor<number>,
|
||||
) {
|
||||
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
|
||||
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
|
||||
|
||||
const [chunk, { refetch }] = createTimelineChunk(timeline, params);
|
||||
const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
|
||||
|
||||
createEffect(() => {
|
||||
const chk = catchError(chunk, (e) => console.error(e));
|
||||
|
@ -330,29 +188,24 @@ export function createTimeline<
|
|||
return;
|
||||
}
|
||||
|
||||
if (chk.rebuilt) {
|
||||
lookup.clear();
|
||||
setThreads([]);
|
||||
}
|
||||
|
||||
const existence = [] as boolean[];
|
||||
|
||||
batch(() => {
|
||||
if (chk.rebuilt) {
|
||||
lookup.clear();
|
||||
setThreads([]);
|
||||
}
|
||||
|
||||
for (const [idx, status] of chk.chunk.entries()) {
|
||||
existence[idx] = !!untrack(() => lookup.get(status.id));
|
||||
lookup.set(status.id, {
|
||||
value: status,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
untrack(() => {
|
||||
for (const status of chk.chunk) {
|
||||
const node = lookup.get(status.id)!;
|
||||
const parent = status.inReplyToId
|
||||
? lookup.get(status.inReplyToId)
|
||||
: undefined;
|
||||
for (const [idx, status] of chk.chunk.entries()) {
|
||||
existence[idx] = !!untrack(() => lookup.get(status.id));
|
||||
lookup.set(status.id, {
|
||||
value: status,
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of chk.chunk) {
|
||||
const node = untrack(() => lookup.get(status.id))!;
|
||||
if (status.inReplyToId) {
|
||||
const parent = lookup.get(status.inReplyToId);
|
||||
if (parent) {
|
||||
const children = parent.children ?? [];
|
||||
if (!children.find((x) => x.value.id == status.id)) {
|
||||
|
@ -362,7 +215,7 @@ export function createTimeline<
|
|||
node.parent = parent;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const nThreadIds = chk.chunk
|
||||
.filter((x, i) => !existence[i])
|
||||
|
@ -375,18 +228,29 @@ export function createTimeline<
|
|||
setThreads((threads) => [...nThreadIds, ...threads]);
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
setThreads((threads) =>
|
||||
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
|
||||
);
|
||||
});
|
||||
setThreads((threads) =>
|
||||
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
list: threads,
|
||||
...createControlsForLookup(lookup),
|
||||
get(id: string) {
|
||||
return lookup.get(id);
|
||||
},
|
||||
getPath(id: string) {
|
||||
const node = lookup.get(id);
|
||||
if (!node) return;
|
||||
return collectPath(node);
|
||||
},
|
||||
set(id: string, value: mastodon.v1.Status) {
|
||||
const node = untrack(() => lookup.get(id));
|
||||
if (!node) return;
|
||||
node.value = value;
|
||||
lookup.set(id, node);
|
||||
},
|
||||
},
|
||||
chunk,
|
||||
{ refetch },
|
||||
|
|
|
@ -20,6 +20,17 @@ export function resolveCustomEmoji(
|
|||
});
|
||||
}
|
||||
|
||||
export function appliedCustomEmoji(
|
||||
target: { innerHTML: string },
|
||||
content: string,
|
||||
emojis?: mastodon.v1.CustomEmoji[],
|
||||
) {
|
||||
createRenderEffect(() => {
|
||||
const result = emojis ? resolveCustomEmoji(content, emojis) : content;
|
||||
target.innerHTML = result;
|
||||
});
|
||||
}
|
||||
|
||||
export function hasCustomEmoji(s: string) {
|
||||
return CUSTOM_EMOJI_REGEX.test(s);
|
||||
}
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
.AppTopBar {
|
||||
&::before {
|
||||
contain: strict;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--safe-area-inset-top, 0);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&>.MuiToolbar-root {
|
||||
padding-top: var(--safe-area-inset-top, 0px);
|
||||
gap: 8px;
|
||||
|
||||
&>button:first-child,
|
||||
&>.MuiButtonBase-root:first-child {
|
||||
margin-left: -0.15em;
|
||||
}
|
||||
|
||||
&>button:last-child,
|
||||
&>.MuiButtonBase-root:last-child {
|
||||
margin-right: -0.15em;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import { AppBar, Toolbar } from "@suid/material";
|
||||
import { splitProps, type JSX, type ParentComponent } from "solid-js";
|
||||
import "./AppTopBar.css";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import type { AppBarProps } from "@suid/material/AppBar";
|
||||
|
||||
const AppTopBar: ParentComponent<{
|
||||
class?: string;
|
||||
style?: JSX.HTMLAttributes<HTMLElement>["style"];
|
||||
} & AppBarProps> = (oprops) => {
|
||||
const [props, rest] = splitProps(oprops, ["children", "class"]);
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
class={`AppTopBar ${props.class || ""}`}
|
||||
elevation={1}
|
||||
position="static"
|
||||
{...rest}
|
||||
>
|
||||
<Toolbar
|
||||
variant={windowSize.width > windowSize.height ? "dense" : "regular"}
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
{props.children}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTopBar;
|
|
@ -1,6 +1,9 @@
|
|||
.BottomSheet {
|
||||
.bottomSheet {
|
||||
composes: surface from "./material.module.css";
|
||||
composes: cardGutSkip from "./cards.module.css";
|
||||
composes: cardNoPad from "./cards.module.css";
|
||||
border: none;
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
@ -11,20 +14,20 @@
|
|||
overscroll-behavior: none;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
height: 95%;
|
||||
contain: strict;
|
||||
contain-intrinsic-size: auto 560px auto 95vh;
|
||||
|
||||
|
||||
&::backdrop {
|
||||
background: transparent;
|
||||
|
||||
transition: background-color 120ms var(--tutu-anim-curve-std);
|
||||
transition-behavior: allow-discrete;
|
||||
background-color: black;
|
||||
opacity: 0.5;
|
||||
transition: opacity 220ms var(--tutu-anim-curve-std);
|
||||
}
|
||||
|
||||
box-shadow: var(--tutu-shadow-e16);
|
||||
|
||||
:global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
|
||||
margin-left: -0.5em;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
left: 0;
|
||||
|
@ -33,38 +36,42 @@
|
|||
bottom: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
contain-intrinsic-size: auto 100vw 100vh;
|
||||
contain-intrinsic-size: auto 100dvw 100dvh;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
&.animated {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
overflow: hidden;
|
||||
will-change: width, height, top, left;
|
||||
|
||||
&::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
& * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
top: unset;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
contain: content;
|
||||
contain-intrinsic-size: unset;
|
||||
|
||||
&[open]::backdrop {
|
||||
background: var(--tutu-color-shadow-l1);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
transform: none;
|
||||
height: auto;
|
||||
height: unset;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,58 +1,49 @@
|
|||
import {
|
||||
children,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useTransition,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
type ResolvedChildren,
|
||||
} from "solid-js";
|
||||
import "./BottomSheet.css";
|
||||
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme";
|
||||
import {
|
||||
animateSlideInFromRight,
|
||||
animateSlideOutToRight,
|
||||
} from "~platform/anim";
|
||||
import { isPointNotInRect } from "~platform/dom";
|
||||
import styles from "./BottomSheet.module.css";
|
||||
import { useHeroSignal } from "../platform/anim";
|
||||
|
||||
export type BottomSheetProps = {
|
||||
open?: boolean;
|
||||
bottomUp?: boolean;
|
||||
class?: JSX.HTMLAttributes<HTMLElement>["class"];
|
||||
onClose?(reason: "backdrop"): void;
|
||||
};
|
||||
|
||||
const MOVE_SPEED = 1600;
|
||||
export const HERO = Symbol("BottomSheet Hero Symbol");
|
||||
|
||||
function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
element.classList.add("animated");
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const distance = Math.abs(rect.top - window.innerHeight);
|
||||
const duration = (distance / MOVE_SPEED) * 1000;
|
||||
|
||||
const animation = element.animate(
|
||||
{
|
||||
top: reverse
|
||||
? [`${rect.top}px`, `${window.innerHeight}px`]
|
||||
: [`${window.innerHeight}px`, `${rect.top}px`],
|
||||
},
|
||||
{ easing, duration },
|
||||
);
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
document.body.style.overflow = oldOverflow;
|
||||
function composeAnimationFrame(
|
||||
{
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
}: Record<"top" | "left" | "height" | "width", number>,
|
||||
x: Record<string, unknown>,
|
||||
) {
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
...x,
|
||||
};
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
return animation;
|
||||
}
|
||||
|
||||
const MOVE_SPEED = 1200;
|
||||
|
||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||
let element!: HTMLDialogElement;
|
||||
let element: HTMLDialogElement;
|
||||
let animation: Animation | undefined;
|
||||
const child = children(() => props.children);
|
||||
const [hero, setHero] = useHeroSignal(HERO);
|
||||
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
||||
const ochildren = children(() => props.children);
|
||||
|
||||
const [pending] = useTransition();
|
||||
|
||||
|
@ -60,54 +51,154 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
if (props.open) {
|
||||
if (!element.open && !pending()) {
|
||||
requestAnimationFrame(animatedOpen);
|
||||
setCache(ochildren());
|
||||
}
|
||||
} else {
|
||||
if (element.open) {
|
||||
animatedClose();
|
||||
setCache(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
const srcElement = hero();
|
||||
if (srcElement) {
|
||||
srcElement.style.visibility = "unset";
|
||||
}
|
||||
|
||||
element.close();
|
||||
setHero();
|
||||
};
|
||||
|
||||
const animatedClose = () => {
|
||||
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||
onClose();
|
||||
return;
|
||||
const srcElement = hero();
|
||||
const endRect = srcElement?.getBoundingClientRect();
|
||||
if (endRect) {
|
||||
const startRect = element.getBoundingClientRect();
|
||||
const animation = animateHero(startRect, endRect, element, true);
|
||||
animation.addEventListener("finish", onClose);
|
||||
animation.addEventListener("cancel", onClose);
|
||||
} else {
|
||||
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const animation = props.bottomUp
|
||||
? animateSlideInFromBottom(element, true)
|
||||
: animateSlideInFromRight(element, true);
|
||||
animation.addEventListener("finish", onClose);
|
||||
animation.addEventListener("cancel", onClose);
|
||||
}
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
animation = undefined;
|
||||
onClose();
|
||||
};
|
||||
element.classList.add("animated");
|
||||
animation = props.bottomUp
|
||||
? animateSlideInFromBottom(element, true)
|
||||
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
};
|
||||
|
||||
const animatedOpen = () => {
|
||||
element.showModal();
|
||||
if (props.bottomUp) {
|
||||
const srcElement = hero();
|
||||
const startRect = srcElement?.getBoundingClientRect();
|
||||
if (startRect) {
|
||||
srcElement!.style.visibility = "hidden";
|
||||
const endRect = element.getBoundingClientRect();
|
||||
animateHero(startRect, endRect, element);
|
||||
} else if (props.bottomUp) {
|
||||
animateSlideInFromBottom(element);
|
||||
} else if (window.innerWidth <= 560) {
|
||||
element.classList.add("animated");
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove("animated");
|
||||
animation = undefined;
|
||||
};
|
||||
animation = animateSlideInFromRight(element, {
|
||||
easing: ANIM_CURVE_DECELERATION,
|
||||
});
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
animateSlideInFromRight(element);
|
||||
}
|
||||
};
|
||||
|
||||
const animateSlideInFromRight = (element: HTMLElement, reserve?: boolean) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
element.classList.add(styles.animated);
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const distance = Math.abs(rect.left - window.innerWidth);
|
||||
const duration = (distance / MOVE_SPEED) * 1000;
|
||||
|
||||
animation = element.animate(
|
||||
{
|
||||
top: [`${rect.top}px`, `${rect.top}px`],
|
||||
left: reserve
|
||||
? [`${rect.left}px`, `${window.innerWidth}px`]
|
||||
: [`${window.innerWidth}px`, `${rect.left}px`],
|
||||
width: [`${rect.width}px`, `${rect.width}px`],
|
||||
height: [`${rect.height}px`, `${rect.height}px`],
|
||||
},
|
||||
{ easing, duration },
|
||||
);
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove(styles.animated);
|
||||
document.body.style.overflow = oldOverflow;
|
||||
animation = undefined;
|
||||
};
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
return animation;
|
||||
};
|
||||
|
||||
const animateSlideInFromBottom = (
|
||||
element: HTMLElement,
|
||||
reserve?: boolean,
|
||||
) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
element.classList.add(styles.animated);
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const distance = Math.abs(rect.top - window.innerHeight);
|
||||
const duration = (distance / MOVE_SPEED) * 1000;
|
||||
|
||||
animation = element.animate(
|
||||
{
|
||||
left: [`${rect.left}px`, `${rect.left}px`],
|
||||
top: reserve
|
||||
? [`${rect.top}px`, `${window.innerHeight}px`]
|
||||
: [`${window.innerHeight}px`, `${rect.top}px`],
|
||||
width: [`${rect.width}px`, `${rect.width}px`],
|
||||
height: [`${rect.height}px`, `${rect.height}px`],
|
||||
},
|
||||
{ easing, duration },
|
||||
);
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove(styles.animated);
|
||||
document.body.style.overflow = oldOverflow;
|
||||
animation = undefined;
|
||||
};
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
return animation;
|
||||
};
|
||||
|
||||
const animateHero = (
|
||||
startRect: DOMRect,
|
||||
endRect: DOMRect,
|
||||
element: HTMLElement,
|
||||
reserve?: boolean,
|
||||
) => {
|
||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
element.classList.add(styles.animated);
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(Math.abs(startRect.top - endRect.top), 2) +
|
||||
Math.pow(Math.abs(startRect.left - startRect.top), 2),
|
||||
);
|
||||
const duration = (distance / MOVE_SPEED) * 1000;
|
||||
animation = element.animate(
|
||||
[
|
||||
composeAnimationFrame(startRect, { transform: "none" }),
|
||||
composeAnimationFrame(endRect, { transform: "none" }),
|
||||
],
|
||||
{ easing, duration },
|
||||
);
|
||||
const onAnimationEnd = () => {
|
||||
element.classList.remove(styles.animated);
|
||||
animation = undefined;
|
||||
};
|
||||
animation.addEventListener("finish", onAnimationEnd);
|
||||
animation.addEventListener("cancel", onAnimationEnd);
|
||||
return animation;
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
if (animation) {
|
||||
animation.cancel();
|
||||
|
@ -117,33 +208,27 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
|||
const onDialogClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (isPointNotInRect(rect, event.clientX, event.clientY)) {
|
||||
const isInDialog =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.top + rect.height &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
props.onClose?.("backdrop");
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogCancel = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
props.onClose?.("backdrop");
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
class={`BottomSheet surface ${props.class || ""}`}
|
||||
classList={{
|
||||
["bottom"]: props.bottomUp,
|
||||
[styles.bottomSheet]: true,
|
||||
[styles.bottom]: props.bottomUp,
|
||||
}}
|
||||
onClick={onDialogClick}
|
||||
onCancel={onDialogCancel}
|
||||
ref={element!}
|
||||
tabIndex={-1}
|
||||
role="presentation"
|
||||
>
|
||||
{child()}
|
||||
{ochildren() ?? cache()}
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, JSX, splitProps } from "solid-js";
|
||||
import "./typography.css";
|
||||
import materialStyles from "./material.module.css";
|
||||
|
||||
/**
|
||||
* Material-styled button.
|
||||
|
@ -9,15 +9,13 @@ import "./typography.css";
|
|||
const Button: Component<JSX.ButtonHTMLAttributes<HTMLButtonElement>> = (
|
||||
props,
|
||||
) => {
|
||||
const [managed, passthough] = splitProps(props, [ "type"]);
|
||||
const [managed, passthough] = splitProps(props, ["class", "type"]);
|
||||
const classes = () =>
|
||||
managed.class
|
||||
? [materialStyles.button, managed.class].join(" ")
|
||||
: materialStyles.button;
|
||||
const type = () => managed.type ?? "button";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type()}
|
||||
{...passthough}
|
||||
></button>
|
||||
);
|
||||
return <button type={type()} class={classes()} {...passthough}></button>;
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
|
|
@ -3,12 +3,14 @@ import {
|
|||
splitProps,
|
||||
Component,
|
||||
createSignal,
|
||||
createEffect,
|
||||
onMount,
|
||||
createRenderEffect,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { css } from "solid-styled";
|
||||
import { decode } from "blurhash";
|
||||
import { mergeClass } from "../utils";
|
||||
|
||||
type ImgProps = {
|
||||
blurhash?: string;
|
||||
|
@ -22,7 +24,6 @@ const Img: Component<ImgProps> = (props) => {
|
|||
"blurhash",
|
||||
"keepBlur",
|
||||
"class",
|
||||
"classList",
|
||||
"style",
|
||||
]);
|
||||
const [isImgLoaded, setIsImgLoaded] = createSignal(false);
|
||||
|
@ -60,21 +61,21 @@ const Img: Component<ImgProps> = (props) => {
|
|||
const onImgLoaded = () => {
|
||||
setIsImgLoaded(true);
|
||||
setImgSize({
|
||||
width: imgE!.width,
|
||||
height: imgE!.height,
|
||||
width: imgE.width,
|
||||
height: imgE.height,
|
||||
});
|
||||
};
|
||||
|
||||
const onMetadataLoaded = () => {
|
||||
setImgSize({
|
||||
width: imgE!.width,
|
||||
height: imgE!.height,
|
||||
width: imgE.width,
|
||||
height: imgE.height,
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setImgSize((x) => {
|
||||
const parent = imgE!.parentElement;
|
||||
const parent = imgE.parentElement;
|
||||
if (!parent) return x;
|
||||
return x
|
||||
? x
|
||||
|
@ -86,14 +87,7 @@ const Img: Component<ImgProps> = (props) => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
...managed.classList,
|
||||
[managed.class ?? ""]: true,
|
||||
"img-root": true,
|
||||
}}
|
||||
style={managed.style}
|
||||
>
|
||||
<div class={mergeClass(managed.class, "img-root")} style={managed.style}>
|
||||
<Show when={managed.blurhash}>
|
||||
<canvas
|
||||
ref={(canvas) => {
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
.Menu {
|
||||
position: fixed;
|
||||
border: 1px solid var(--tutu-color-surface-d);
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
max-width: 560px;
|
||||
width: max-content;
|
||||
box-shadow: var(--tutu-shadow-e8);
|
||||
contain: content;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&.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);
|
||||
}
|
||||
|
||||
&>.container {
|
||||
background: var(--tutu-color-surface);
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
dialog.Menu::backdrop {
|
||||
background: none;
|
||||
}
|
|
@ -1,281 +0,0 @@
|
|||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { MenuList } from "@suid/material";
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createSignal,
|
||||
splitProps,
|
||||
type Component,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js";
|
||||
import { ANIM_CURVE_STD } from "./theme";
|
||||
import "./Menu.css";
|
||||
import {
|
||||
animateGrowFromTopRight,
|
||||
animateShrinkToTopRight,
|
||||
} from "~platform/anim";
|
||||
import type { MenuListProps } from "@suid/material/MenuList";
|
||||
|
||||
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
|
||||
|
||||
export type MenuProps = ParentProps<
|
||||
{
|
||||
open?: boolean;
|
||||
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
|
||||
anchor: () => Anchor;
|
||||
MenuListProps?: MenuListProps;
|
||||
|
||||
id?: string;
|
||||
} & JSX.AriaAttributes
|
||||
>;
|
||||
|
||||
function px(n?: number) {
|
||||
if (n) {
|
||||
return `${n}px`;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: (event: Event) => {
|
||||
event.preventDefault();
|
||||
return setAnchor();
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
function animateGrowFromTopLeft(
|
||||
element: HTMLElement,
|
||||
opts?: Omit<KeyframeAnimationOptions, "duration">,
|
||||
) {
|
||||
const rend = element.getBoundingClientRect();
|
||||
const overflow = element.style.overflow;
|
||||
element.style.overflow = "hidden";
|
||||
const duration = (rend.height / 1600) * 1000;
|
||||
const animation = element.animate(
|
||||
{
|
||||
height: [`${rend.height / 2}px`, `${rend.height}px`],
|
||||
width: [`${(rend.width / 4) * 3}px`, `${rend.width}px`],
|
||||
},
|
||||
{
|
||||
duration,
|
||||
...opts,
|
||||
},
|
||||
);
|
||||
animation.addEventListener(
|
||||
"finish",
|
||||
() => (element.style.overflow = overflow),
|
||||
);
|
||||
return animation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> = (oprops) => {
|
||||
let root!: HTMLDialogElement;
|
||||
const windowSize = useWindowSize();
|
||||
const [props, rest] = splitProps(oprops, [
|
||||
"open",
|
||||
"onClose",
|
||||
"anchor",
|
||||
"MenuListProps",
|
||||
"children",
|
||||
]);
|
||||
|
||||
const [anchorPos, setAnchorPos] = createSignal<{
|
||||
left?: number;
|
||||
top?: number;
|
||||
e?: number;
|
||||
}>({});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
createEffect(() => {
|
||||
if (anchorPos().e)
|
||||
switch (anchorPos().e) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
case 4:
|
||||
return;
|
||||
default:
|
||||
console.warn('value %s is invalid for param "e"', anchorPos().e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let openAnimationOrigin: "lt" | "rt" = "lt";
|
||||
|
||||
const animateOpen = () => {
|
||||
const a = props.anchor();
|
||||
const { width } = windowSize;
|
||||
const { left, top, right, e } = a;
|
||||
const isOpened = root.open;
|
||||
|
||||
// There are incomplete animations.
|
||||
// For `getBoundingClientRect()`, WebKit reports the initial state
|
||||
// of the element, whilst Firefox reports the final state.
|
||||
//
|
||||
// We skip if animations are still on the element
|
||||
// to avoid the problem on WebKit.
|
||||
// Here use the final state.
|
||||
//
|
||||
// This is a dirty workaround. It's here because the feature is still
|
||||
// works with it.
|
||||
// I am curious that why the ones on the other parts are works. (Rubicon)
|
||||
if (root.getAnimations().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.showModal();
|
||||
const rend = root.getBoundingClientRect();
|
||||
|
||||
if (left > width / 2) {
|
||||
openAnimationOrigin = "rt";
|
||||
setAnchorPos({
|
||||
left: right - rend.width,
|
||||
top,
|
||||
e,
|
||||
});
|
||||
} else {
|
||||
openAnimationOrigin = "lt";
|
||||
setAnchorPos({ left, top, e });
|
||||
}
|
||||
|
||||
if (!isOpened) {
|
||||
switch (openAnimationOrigin) {
|
||||
case "lt":
|
||||
animateGrowFromTopLeft(root, { easing: ANIM_CURVE_STD });
|
||||
break;
|
||||
case "rt":
|
||||
animateGrowFromTopRight(root, { easing: ANIM_CURVE_STD });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
animateOpen();
|
||||
} else {
|
||||
animateClose();
|
||||
}
|
||||
});
|
||||
|
||||
const animateClose = () => {
|
||||
const rend = root.getBoundingClientRect();
|
||||
if (openAnimationOrigin === "lt") {
|
||||
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();
|
||||
});
|
||||
} else {
|
||||
const animation = animateShrinkToTopRight(root, {
|
||||
easing: ANIM_CURVE_STD,
|
||||
});
|
||||
animation.addEventListener("finish", () => {
|
||||
root.close();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (!event.currentTarget.open) return;
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isNotInDialog =
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom ||
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right;
|
||||
|
||||
if (isNotInDialog) {
|
||||
if (props.onClose) {
|
||||
if (Array.isArray(props.onClose)) {
|
||||
props.onClose[0](props.onClose[1], event);
|
||||
} else {
|
||||
(
|
||||
props.onClose as (
|
||||
event: Event & { currentTarget: HTMLDialogElement },
|
||||
) => void
|
||||
)(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={root!}
|
||||
onClose={props.onClose}
|
||||
onCancel={props.onClose}
|
||||
onClick={onDialogClick}
|
||||
class={`Menu e${anchorPos().e || "0"}`}
|
||||
style={{
|
||||
left: px(anchorPos().left),
|
||||
top: px(anchorPos().top),
|
||||
/* FIXME: the content may be overflow */
|
||||
}}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
>
|
||||
<div class="container" role="presentation">
|
||||
<MenuList {...props.MenuListProps}>{props.children}</MenuList>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
|
@ -1,32 +0,0 @@
|
|||
.Scaffold>.topbar {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: var(--tutu-zidx-nav, auto);
|
||||
|
||||
.MuiToolbar-root {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.Scaffold {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: contents;
|
||||
background-color: var(--tutu-color-surface);
|
||||
}
|
|
@ -1,80 +1,68 @@
|
|||
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||
import {
|
||||
JSX,
|
||||
Show,
|
||||
children,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
splitProps,
|
||||
type Component,
|
||||
type ParentProps,
|
||||
onCleanup,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import "./Scaffold.css";
|
||||
import { css } from "solid-styled";
|
||||
|
||||
type ScaffoldProps = ParentProps<
|
||||
{
|
||||
topbar?: JSX.Element;
|
||||
fab?: JSX.Element;
|
||||
bottom?: JSX.Element;
|
||||
} & JSX.HTMLElementTags["div"]
|
||||
>;
|
||||
interface ScaffoldProps {
|
||||
topbar?: JSX.Element;
|
||||
fab?: JSX.Element;
|
||||
bottom?: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* The passthrough props are passed to the content container.
|
||||
*/
|
||||
const Scaffold: Component<ScaffoldProps> = (oprops) => {
|
||||
const [props, rest] = splitProps(oprops, [
|
||||
"topbar",
|
||||
"fab",
|
||||
"bottom",
|
||||
"children",
|
||||
"ref",
|
||||
"class",
|
||||
]);
|
||||
const Scaffold: ParentComponent<ScaffoldProps> = (props) => {
|
||||
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
|
||||
|
||||
const topbarSize = createElementSize(topbarElement);
|
||||
|
||||
const topbar = children(() => props.topbar)
|
||||
const fab = children(() => props.fab)
|
||||
const bottom = children(() => props.bottom)
|
||||
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 (
|
||||
<div
|
||||
class={`Scaffold ${props.class || ""}`}
|
||||
ref={(e) => {
|
||||
createRenderEffect(() => {
|
||||
e.style.setProperty(
|
||||
"--scaffold-topbar-height",
|
||||
(topbarSize.height?.toString() ?? 0) + "px",
|
||||
);
|
||||
});
|
||||
|
||||
if (props.ref) {
|
||||
(props.ref as (val: typeof e) => void)(e);
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Show when={topbar()}>
|
||||
<div class="topbar" ref={setTopbarElement} role="presentation">
|
||||
{topbar()}
|
||||
<>
|
||||
<Show when={props.topbar}>
|
||||
<div class="topbar" ref={setTopbarElement}>
|
||||
{props.topbar}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fab()}>
|
||||
<div class="fab-dock" role="presentation">
|
||||
{fab()}
|
||||
</div>
|
||||
<Show when={props.fab}>
|
||||
<div class="fab-dock">{props.fab}</div>
|
||||
</Show>
|
||||
|
||||
{props.children}
|
||||
|
||||
<Show when={bottom()}>
|
||||
<div class="bottom-dock" role="presentation">
|
||||
{bottom()}
|
||||
</div>
|
||||
<div class="scaffold-content">{props.children}</div>
|
||||
<Show when={props.bottom}>
|
||||
<div class="bottom-dock">{props.bottom}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
.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);
|
||||
|
||||
:root:where([lang^="zh"],
|
||||
[lang="zh"],
|
||||
[lang^="kr"],
|
||||
[lang="kr"],
|
||||
[lang^="ja"],
|
||||
[lang="ja"]) & {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.MuiToolbar-root .Tab {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus,
|
||||
&.Tabs-focus {
|
||||
color: white;
|
||||
}
|
||||
}
|
|
@ -1,18 +1,26 @@
|
|||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
splitProps,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import { css } from "solid-styled";
|
||||
import { useTabListContext } from "./Tabs";
|
||||
import "./Tab.css";
|
||||
|
||||
const Tab: ParentComponent<
|
||||
{
|
||||
focus?: boolean;
|
||||
large?: boolean;
|
||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
> = (props) => {
|
||||
const [managed, rest] = splitProps(props, ["focus", "type", "role", "ref"]);
|
||||
const [managed, rest] = splitProps(props, [
|
||||
"focus",
|
||||
"large",
|
||||
"type",
|
||||
"role",
|
||||
"ref",
|
||||
]);
|
||||
let self: HTMLButtonElement;
|
||||
const {
|
||||
focusOn: [, setFocusOn],
|
||||
|
@ -27,7 +35,32 @@ const Tab: ParentComponent<
|
|||
}
|
||||
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 (
|
||||
<button
|
||||
ref={(x) => {
|
||||
|
@ -35,7 +68,7 @@ const Tab: ParentComponent<
|
|||
(managed.ref as (e: HTMLButtonElement) => void)?.(x);
|
||||
}}
|
||||
type={managed.type ?? "button"}
|
||||
classList={{ Tab: true, focus: managed.focus }}
|
||||
classList={{ tab: true, focus: managed.focus }}
|
||||
role={managed.role ?? "tab"}
|
||||
{...rest}
|
||||
>
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
import {
|
||||
ParentComponent,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
useContext,
|
||||
type Signal,
|
||||
} from "solid-js";
|
||||
import "./Tabs.css"
|
||||
import { css } from "solid-styled";
|
||||
|
||||
const TabListContext = /* @__PURE__ */ createContext<{
|
||||
focusOn: Signal<HTMLElement[]>;
|
||||
|
@ -22,7 +24,7 @@ export function useTabListContext() {
|
|||
|
||||
const ANIM_SPEED = 160 / 110; // 160px/110ms
|
||||
|
||||
const TABS_FOCUS_CLASS = "Tabs-focus";
|
||||
const TABLIST_FOCUS_CLASS = "tablist-focus";
|
||||
|
||||
const Tabs: ParentComponent<{
|
||||
offset?: number;
|
||||
|
@ -35,11 +37,11 @@ const Tabs: ParentComponent<{
|
|||
const current = focusOn();
|
||||
if (lastFocusElement) {
|
||||
for (const e of lastFocusElement) {
|
||||
e.classList.remove(TABS_FOCUS_CLASS);
|
||||
e.classList.remove(TABLIST_FOCUS_CLASS);
|
||||
}
|
||||
}
|
||||
for (const e of current) {
|
||||
e.classList.add(TABS_FOCUS_CLASS);
|
||||
e.classList.add("tablist-focus");
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
@ -107,7 +109,7 @@ const Tabs: ParentComponent<{
|
|||
return ["0px", "0px", "110ms", "110ms"] as const;
|
||||
}
|
||||
const rect = focusBoundingClientRect();
|
||||
const rootRect = self!.getBoundingClientRect();
|
||||
const rootRect = self.getBoundingClientRect();
|
||||
const left = rect.x - rootRect.x;
|
||||
const width = rect.width;
|
||||
const [prevEl, nextEl] = focusSiblings();
|
||||
|
@ -128,14 +130,32 @@ const Tabs: ParentComponent<{
|
|||
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 (
|
||||
<TabListContext.Provider value={{ focusOn: [focusOn, setFocusOn] }}>
|
||||
<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">
|
||||
<div ref={self!} class="tablist" role="tablist">
|
||||
{props.children}
|
||||
</div>
|
||||
</TabListContext.Provider>
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
onMount,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import "./TextField.css";
|
||||
import formStyles from "./form.module.css";
|
||||
|
||||
export type TextFieldProps = {
|
||||
label?: string;
|
||||
|
@ -28,14 +28,14 @@ const TextField: Component<TextFieldProps> = (props) => {
|
|||
|
||||
createEffect(() => {
|
||||
if (hasContent()) {
|
||||
field!.classList.add("float-label");
|
||||
field.classList.add("float-label");
|
||||
} else {
|
||||
field!.classList.remove("float-label");
|
||||
field.classList.remove("float-label");
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
setHasContent(input!.value.length > 0);
|
||||
setHasContent(input.value.length > 0);
|
||||
});
|
||||
|
||||
const onInputChange = (e: { currentTarget: HTMLInputElement }) => {
|
||||
|
@ -47,12 +47,12 @@ const TextField: Component<TextFieldProps> = (props) => {
|
|||
const inputId = () => props.inputId ?? altInputId;
|
||||
|
||||
const fieldClass = () => {
|
||||
const cls = ["TextField"];
|
||||
const cls = [formStyles.textfield];
|
||||
if (typeof props.helperText !== "undefined") {
|
||||
cls.push("withHelperText");
|
||||
cls.push(formStyles.withHelperText);
|
||||
}
|
||||
if (props.error) {
|
||||
cls.push("error");
|
||||
cls.push(formStyles.error);
|
||||
}
|
||||
return cls.join(" ");
|
||||
};
|
||||
|
@ -71,7 +71,7 @@ const TextField: Component<TextFieldProps> = (props) => {
|
|||
name={props.name}
|
||||
/>
|
||||
<Show when={typeof props.helperText !== "undefined"}>
|
||||
<span class="helperText">{props.helperText}</span>
|
||||
<span class={formStyles.helperText}>{props.helperText}</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
@layer material {
|
||||
.card {
|
||||
--card-pad: 20px;
|
||||
--card-gut: 20px;
|
||||
|
||||
background-color: var(--tutu-color-surface);
|
||||
color: var(--tutu-color-on-surface);
|
||||
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--tutu-shadow-e2);
|
||||
transition: var(--tutu-transition-shadow);
|
||||
overflow: hidden;
|
||||
background-color: var(--tutu-color-surface-l);
|
||||
|
||||
&:focus-within,
|
||||
&:focus-visible {
|
||||
box-shadow: var(--tutu-shadow-e8);
|
||||
}
|
||||
|
||||
&>.card-pad {
|
||||
margin-left: var(--card-pad);
|
||||
margin-right: var(--card-pad);
|
||||
}
|
||||
|
||||
&>.card-gut {
|
||||
&:first-child {
|
||||
margin-top: var(--card-gut);
|
||||
}
|
||||
|
||||
&+.card-gut {
|
||||
margin-top: var(--card-gut);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: var(--card-gut);
|
||||
}
|
||||
}
|
||||
|
||||
&.card-auto-margin {
|
||||
&> :not(.card-no-pad) {
|
||||
margin-inline: var(--card-pad, 20px);
|
||||
}
|
||||
|
||||
> :not(.card-gut-skip):first-child {
|
||||
margin-top: var(--card-gut, 20px);
|
||||
}
|
||||
|
||||
>.card-gut-skip+*:not(.card-gut-skip) {
|
||||
margin-top: var(--card-gut, 20px);
|
||||
}
|
||||
|
||||
> :not(.card-gut-skip):last-child {
|
||||
margin-bottom: var(--card-gut, 20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
src/material/cards.module.css
Normal file
54
src/material/cards.module.css
Normal file
|
@ -0,0 +1,54 @@
|
|||
.card {
|
||||
composes: surface from "material.module.css";
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--tutu-shadow-e2);
|
||||
transition: var(--tutu-transition-shadow);
|
||||
overflow: hidden;
|
||||
background-color: var(--tutu-color-surface-l);
|
||||
|
||||
&:focus-within,
|
||||
&:focus-visible {
|
||||
box-shadow: var(--tutu-shadow-e8);
|
||||
}
|
||||
|
||||
&:not(.manualMargin) {
|
||||
&> :not(.cardNoPad) {
|
||||
margin-inline: var(--card-pad, 20px);
|
||||
}
|
||||
|
||||
> :not(.cardGutSkip):first-child {
|
||||
margin-top: var(--card-gut, 20px);
|
||||
}
|
||||
|
||||
>.cardGutSkip+*:not(.cardGutSkip) {
|
||||
margin-top: var(--card-gut, 20px);
|
||||
}
|
||||
|
||||
> :not(.cardGutSkip):last-child {
|
||||
margin-bottom: var(--card-gut, 20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layoutCentered {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 448px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& {
|
||||
position: static;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: none;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
.TextField {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
.textfield {
|
||||
composes: touchTarget from "material.module.css";
|
||||
|
||||
--border-color: var(--tutu-color-inactive-on-surface);
|
||||
--active-border-color: var(--tutu-color-primary);
|
|
@ -1,14 +1,17 @@
|
|||
@import "./typography.css";
|
||||
|
||||
.surface {
|
||||
background-color: var(--tutu-color-surface);
|
||||
color: var(--tutu-color-on-surface);
|
||||
}
|
||||
|
||||
button {
|
||||
.touchTarget {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: buttonText from "./typography.module.css";
|
||||
composes: touchTarget;
|
||||
|
||||
border: none;
|
||||
background-color: transparent;
|
17
src/material/mui.ts
Normal file
17
src/material/mui.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Theme, createTheme } from "@suid/material/styles";
|
||||
import { deepPurple, amber } from "@suid/material/colors";
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
export function useRootTheme(): Accessor<Theme> {
|
||||
return () =>
|
||||
createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: deepPurple[500],
|
||||
},
|
||||
secondary: {
|
||||
main: amber.A200,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -82,44 +82,27 @@
|
|||
--tutu-color-error-on-surface: #d32f2f;
|
||||
--tutu-color-inactive-on-surface: #757575;
|
||||
|
||||
--tutu-color-shadow: rgba(0, 0, 0, 0.45);
|
||||
--tutu-color-shadow-l1: rgba(0, 0, 0, 0.4);
|
||||
--tutu-color-shadow-l2: rgba(0, 0, 0, 0.35);
|
||||
|
||||
--tutu-shadow-e1: 0px 1px 2px 0px #9e9e9e;
|
||||
/* Switch */
|
||||
--tutu-shadow-e1: 0px 1px 2px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e2: 0px 2px 4px 0px #9e9e9e;
|
||||
/* (Resting) cards, raised button, quick entry / search bar */
|
||||
--tutu-shadow-e2: 0px 2px 4px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e3: 0px 3px 6px 0px #9e9e9e;
|
||||
/* Refresh indicator, quick entry / search bar (scrolled) */
|
||||
--tutu-shadow-e3: 0px 3px 6px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e4: 0px 4px 8px 0px #9e9e9e;
|
||||
/* App bar */
|
||||
--tutu-shadow-e4: 0px 4px 8px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e6: 0px 6px 12px 0px #9e9e9e;
|
||||
/* Snack bar, FAB (resting) */
|
||||
--tutu-shadow-e6: 0px 6px 12px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e8: 0px 8px 16px 0px #9e9e9e;
|
||||
/* Menu, (picked-up) cards, (pressed) raise button */
|
||||
--tutu-shadow-e8: 0px 8px 16px 0px var(--tutu-color-shadow);
|
||||
|
||||
--tutu-shadow-e9: 0px 9px 18px 0px #9e9e9e;
|
||||
/* 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);
|
||||
|
||||
--tutu-shadow-e12: 0px 12px 24px 0px #9e9e9e;
|
||||
/* (pressed) FAB */
|
||||
--tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1);
|
||||
|
||||
--tutu-shadow-e16: 0px 16px 32px 0px #9e9e9e;
|
||||
/* Nav drawer, right drawer, modal bottom sheet */
|
||||
--tutu-shadow-e16: 0px 16px 32px 0px var(--tutu-color-shadow-l1);
|
||||
|
||||
--tutu-shadow-e24: 0px 24px 48px 0px #9e9e9e;
|
||||
/* Dialog, picker */
|
||||
--tutu-shadow-e24: 0px 24px 48px 0px var(--tutu-color-shadow-l2);
|
||||
|
||||
|
||||
/* curves are also hard-coded in theme.ts */
|
||||
--tutu-anim-curve-std: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--tutu-anim-curve-deceleration: cubic-bezier(0, 0, 0.2, 1);
|
||||
--tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
@ -153,8 +136,6 @@
|
|||
--tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std);
|
||||
|
||||
--tutu-zidx-nav: 1100;
|
||||
|
||||
accent-color: var(--tutu-color-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { Theme, createTheme } from "@suid/material/styles";
|
||||
import { deepPurple, amber, red } from "@suid/material/colors";
|
||||
import { Accessor } from "solid-js";
|
||||
|
||||
/**
|
||||
* The MUI theme.
|
||||
*/
|
||||
export function createRootTheme(): Accessor<Theme> {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: deepPurple[500],
|
||||
},
|
||||
error: {
|
||||
main: red[900],
|
||||
},
|
||||
secondary: {
|
||||
main: amber.A200,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => 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)";
|
0
src/material/toolbar.module.css
Normal file
0
src/material/toolbar.module.css
Normal file
|
@ -1,6 +1,3 @@
|
|||
/* Don't import this file directly. This file is already included in material.css */
|
||||
|
||||
|
||||
.display4 {
|
||||
font-size: 7rem;
|
||||
font-weight: 300;
|
||||
|
@ -32,11 +29,12 @@
|
|||
font-size: var(--subheading-size);
|
||||
}
|
||||
|
||||
.body1, .body2 {
|
||||
.body1 {
|
||||
font-size: var(--body-size);
|
||||
}
|
||||
|
||||
.body2 {
|
||||
composes: body1;
|
||||
font-weight: var(--body2-weight);
|
||||
}
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
import { splitProps, type Ref, ComponentProps, ValidComponent } from "solid-js";
|
||||
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import typography from "./typography.module.css";
|
||||
import { mergeClass } from "../utils";
|
||||
|
||||
export type TypographyProps<E extends ValidComponent> = {
|
||||
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
|
||||
|
||||
type PropsOf<E extends AnyElement> =
|
||||
E extends ParentComponent<infer Props>
|
||||
? Props
|
||||
: E extends keyof JSX.IntrinsicElements
|
||||
? JSX.IntrinsicElements[E]
|
||||
: JSX.HTMLAttributes<HTMLElement>;
|
||||
|
||||
export type TypographyProps<E extends AnyElement> = {
|
||||
ref?: Ref<E>;
|
||||
component?: E;
|
||||
class?: string;
|
||||
} & ComponentProps<E>;
|
||||
} & PropsOf<E>;
|
||||
|
||||
type TypographyKind =
|
||||
| "display4"
|
||||
|
@ -20,7 +31,7 @@ type TypographyKind =
|
|||
| "caption"
|
||||
| "buttonText";
|
||||
|
||||
export function Typography<T extends ValidComponent>(
|
||||
export function Typography<T extends AnyElement>(
|
||||
props: { typography: TypographyKind } & TypographyProps<T>,
|
||||
) {
|
||||
const [managed, passthough] = splitProps(props, [
|
||||
|
@ -29,46 +40,48 @@ export function Typography<T extends ValidComponent>(
|
|||
"class",
|
||||
"typography",
|
||||
]);
|
||||
const classes = () =>
|
||||
mergeClass(managed.class, typography[managed.typography]);
|
||||
return (
|
||||
<Dynamic
|
||||
ref={managed.ref}
|
||||
component={managed.component ?? "span"}
|
||||
class={`${managed.class || ""} ${managed.typography}`}
|
||||
class={classes()}
|
||||
{...passthough}
|
||||
></Dynamic>
|
||||
);
|
||||
}
|
||||
|
||||
export function Display4<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Display4<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"display4"} {...props}></Typography>;
|
||||
}
|
||||
export function Display3<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Display3<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"display3"} {...props}></Typography>;
|
||||
}
|
||||
export function Display2<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Display2<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"display2"} {...props}></Typography>;
|
||||
}
|
||||
export function Display1<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Display1<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"display1"} {...props}></Typography>;
|
||||
}
|
||||
export function Headline<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Headline<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"headline"} {...props}></Typography>;
|
||||
}
|
||||
export function Title<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Title<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"title"} {...props}></Typography>;
|
||||
}
|
||||
export function Subheading<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Subheading<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"subheading"} {...props}></Typography>;
|
||||
}
|
||||
export function Body1<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Body1<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"body1"} {...props}></Typography>;
|
||||
}
|
||||
export function Body2<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Body2<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"body2"} {...props}></Typography>;
|
||||
}
|
||||
export function Caption<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function Caption<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"caption"} {...props}></Typography>;
|
||||
}
|
||||
export function ButtonText<E extends ValidComponent>(props: TypographyProps<E>) {
|
||||
export function ButtonText<E extends AnyElement>(props: TypographyProps<E>) {
|
||||
return <Typography typography={"buttonText"} {...props}></Typography>;
|
||||
}
|
||||
|
|
12
src/overrides.d.ts
vendored
12
src/overrides.d.ts
vendored
|
@ -3,18 +3,6 @@
|
|||
interface ImportMetaEnv {
|
||||
readonly BUILT_AT: string;
|
||||
readonly PACKAGE_VERSION: string;
|
||||
/**
|
||||
* The code reversion. It's recommended to be the git commit sha.
|
||||
*/
|
||||
readonly VITE_CODE_VERSION?: string;
|
||||
/**
|
||||
* Attach the overlay (in the dev mode) if it's `"true"`.
|
||||
*/
|
||||
readonly VITE_DEVTOOLS_OVERLAY?: string;
|
||||
/**
|
||||
* Always use compatible version of Masonry.
|
||||
*/
|
||||
readonly VITE_PLATFORM_MASONRY_ALWAYS_COMPAT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { splitProps, type JSX } from "solid-js";
|
||||
import { useNavigator } from "./StackedRouter";
|
||||
import { useResolvedPath } from "@solidjs/router";
|
||||
|
||||
function handleClick(
|
||||
push: (name: string, state: unknown) => void,
|
||||
event: MouseEvent & { currentTarget: HTMLAnchorElement },
|
||||
) {
|
||||
const target = event.currentTarget;
|
||||
event.preventDefault();
|
||||
push(target.href, { state: target.getAttribute("state") || undefined });
|
||||
}
|
||||
|
||||
const A = (oprops: Omit<JSX.HTMLElementTags["a"], "onClick" | "onclick">) => {
|
||||
const [props, rest] = splitProps(oprops, ["href"]);
|
||||
const resolvedPath = useResolvedPath(() => props.href || "#");
|
||||
const { push } = useNavigator();
|
||||
return <a onClick={[handleClick, push]} href={resolvedPath()} {...rest}></a>;
|
||||
};
|
||||
|
||||
export default A;
|
|
@ -1,24 +0,0 @@
|
|||
import type { IconButtonProps } from "@suid/material/IconButton";
|
||||
import IconButton from "@suid/material/IconButton";
|
||||
import { Show, type Component } from "solid-js";
|
||||
import { useCurrentFrame, useNavigator } from "./StackedRouter";
|
||||
import { ArrowBack, Close } from "@suid/icons-material";
|
||||
|
||||
export type BackButtonProps = Omit<IconButtonProps, "onClick" | "children">;
|
||||
|
||||
const BackButton: Component<BackButtonProps> = (props) => {
|
||||
const currentFrame = useCurrentFrame();
|
||||
const { pop } = useNavigator();
|
||||
|
||||
const hasPrevSubPage = () => currentFrame().index > 1;
|
||||
|
||||
return (
|
||||
<IconButton onClick={[pop, 1]} {...props}>
|
||||
<Show when={hasPrevSubPage()} fallback={<Close />}>
|
||||
<ArrowBack />
|
||||
</Show>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
|
@ -1,22 +0,0 @@
|
|||
import { children, createRenderEffect, onCleanup, type JSX } from "solid-js";
|
||||
|
||||
/**
|
||||
* Document title.
|
||||
*
|
||||
* The `children` must be plain text.
|
||||
*/
|
||||
export default function (props: { children?: JSX.Element }) {
|
||||
let otitle: string | undefined;
|
||||
|
||||
createRenderEffect(() => (otitle = document.title));
|
||||
|
||||
const title = children(() => props.children);
|
||||
|
||||
createRenderEffect(
|
||||
() => (document.title = (title.toArray() as string[]).join("")),
|
||||
);
|
||||
|
||||
onCleanup(() => (document.title = otitle!));
|
||||
|
||||
return <></>;
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
.CompatMasonry>* {
|
||||
margin-bottom: var(--Masonry-row-gap);
|
||||
}
|
||||
|
||||
@supports (grid-template-rows: masonry) {
|
||||
.NativeMasonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(33%, min-content));
|
||||
grid-template-rows: masonry;
|
||||
}
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
import {
|
||||
type Component,
|
||||
type JSX,
|
||||
splitProps,
|
||||
type Ref,
|
||||
createRenderEffect,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { Dynamic, type DynamicProps } from "solid-js/web";
|
||||
import MasonryLayout from "masonry-layout";
|
||||
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||
import "./Masonry.css";
|
||||
|
||||
type MasonryContainer =
|
||||
| Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||
| Component<{
|
||||
ref?: Ref<Element>;
|
||||
class?: string;
|
||||
}>;
|
||||
|
||||
type ElementOf<T extends MasonryContainer> =
|
||||
T extends Exclude<keyof JSX.IntrinsicElements, keyof JSX.SVGElementTags>
|
||||
? JSX.IntrinsicElements[T] extends { ref?: Ref<infer E> }
|
||||
? E
|
||||
: never
|
||||
: T extends Component<{ ref?: Ref<infer E> }>
|
||||
? E
|
||||
: never;
|
||||
|
||||
function forwardRef<T>(value: T, ref?: Ref<T>) {
|
||||
if (!ref) return;
|
||||
(ref as (value: T) => void)(value);
|
||||
}
|
||||
|
||||
function createCompatMasonry(
|
||||
element: Element,
|
||||
options: () => MasonryLayout.Options,
|
||||
) {
|
||||
const layout = new MasonryLayout(element, {
|
||||
initLayout: false,
|
||||
});
|
||||
|
||||
onCleanup(() => layout.destroy?.());
|
||||
|
||||
const size = createElementSize(element);
|
||||
|
||||
let layoutNotRequested = true;
|
||||
|
||||
const reflow = () => {
|
||||
layout.layout?.();
|
||||
layoutNotRequested = true;
|
||||
};
|
||||
|
||||
createRenderEffect(() => {
|
||||
const opts = options();
|
||||
layout.option?.(opts);
|
||||
});
|
||||
|
||||
const treeMutObx = new MutationObserver(() => {
|
||||
layout.reloadItems?.();
|
||||
if (layoutNotRequested) {
|
||||
layoutNotRequested = false;
|
||||
requestAnimationFrame(reflow);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => treeMutObx.disconnect());
|
||||
|
||||
createRenderEffect(() => {
|
||||
treeMutObx.observe(element, { childList: true });
|
||||
});
|
||||
|
||||
createRenderEffect(() => {
|
||||
const width = size.width; // only tracking width
|
||||
if (layoutNotRequested) {
|
||||
layoutNotRequested = false;
|
||||
requestAnimationFrame(reflow);
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
const onHotReloadUpdate = () => {
|
||||
if (layoutNotRequested) {
|
||||
layoutNotRequested = false;
|
||||
requestAnimationFrame(reflow);
|
||||
}
|
||||
};
|
||||
import.meta.hot.on("vite:afterUpdate", onHotReloadUpdate);
|
||||
import.meta.hot.off("vite:afterUpdate", onHotReloadUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
const supportsCSSMasonryLayout = /* @__PURE__ */ CSS.supports(
|
||||
"grid-template-rows",
|
||||
"masonry",
|
||||
);
|
||||
|
||||
console.debug("supports css masonry layout", supportsCSSMasonryLayout);
|
||||
|
||||
const useNativeImpl = import.meta.env.VITE_PLATFORM_MASONRY_ALWAYS_COMPAT
|
||||
? false
|
||||
: supportsCSSMasonryLayout;
|
||||
|
||||
if (import.meta.env.VITE_PLATFORM_MASONRY_ALWAYS_COMPAT) {
|
||||
console.warn(
|
||||
"Masonry is in compat mode because VITE_PLATFORM_MASONRY_ALWAYS_COMPAT is enabled",
|
||||
);
|
||||
}
|
||||
|
||||
function MasonryCompat<T extends MasonryContainer>(
|
||||
oprops: DynamicProps<T> & { class?: string },
|
||||
) {
|
||||
const [props, rest] = splitProps(oprops, ["ref", "class"]);
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
ref={(element: ElementOf<T>) => {
|
||||
forwardRef(element, props.ref as Ref<typeof element> | undefined);
|
||||
|
||||
const [columnGap, setColumnGap] = createSignal<number>();
|
||||
|
||||
createCompatMasonry(element, () => {
|
||||
return {
|
||||
gutter: columnGap(),
|
||||
};
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
|
||||
const rowGap = computedStyle.rowGap;
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.setProperty("--Masonry-row-gap", rowGap);
|
||||
}
|
||||
|
||||
const colGap = computedStyle.columnGap;
|
||||
if (colGap) {
|
||||
setColumnGap(Number(colGap.slice(0, colGap.length - 2)));
|
||||
}
|
||||
});
|
||||
}}
|
||||
class={`Masonry CompatMasonry ${props.class || ""}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MasonryNative<T extends MasonryContainer>(
|
||||
oprops: DynamicProps<T> & { class?: string },
|
||||
) {
|
||||
const [props, rest] = splitProps(oprops, ["class"]);
|
||||
return (
|
||||
<Dynamic class={`Masonry NativeMasonry ${props.class || ""}`} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Masonry Layout Container.
|
||||
*
|
||||
* **Native if possible** This component uses css masonry layout
|
||||
* and fallback to masonry-layout if not supported. The children
|
||||
* must have specified width and height.
|
||||
*
|
||||
* Testing native behaviour:
|
||||
* - Firefox: in `about:config`, search for `layout.css.grid-template-masonry-value.enabled`
|
||||
*
|
||||
* Class `NativeMasonry` will be added to the element if it's under the
|
||||
* css masonry layout, otherwise it's `CompatMasonry`. `Masonry` is always
|
||||
* added.
|
||||
*
|
||||
* **Children Changes** As the children changed, reflow will be triggered,
|
||||
* and there is might be a blink (or transition) for user. If it's not your
|
||||
* intention, don't remove/add the direct children. Instead wraps them under
|
||||
* containers and set the width and height on the container.
|
||||
*
|
||||
* **CSS compatibility** This component compatible to "gap" "row-gap"
|
||||
* "column-gap" property. But they are read only once after the element mounted.
|
||||
*/
|
||||
export default useNativeImpl ? MasonryNative : MasonryCompat;
|
|
@ -1,5 +0,0 @@
|
|||
.SizedTextarea {
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
import { splitProps, type Component, type JSX } from "solid-js";
|
||||
import "./SizedTextarea.css";
|
||||
|
||||
function isBoundEventHandler<T, E extends Event>(
|
||||
handler: JSX.EventHandlerUnion<T, E>,
|
||||
): handler is JSX.BoundEventHandler<T, E> {
|
||||
return Array.isArray(handler);
|
||||
}
|
||||
|
||||
function callEventHandlerUnion<T extends EventTarget, E extends Event>(
|
||||
handler: JSX.EventHandlerUnion<T, E>,
|
||||
event: E & { currentTarget: T; target: Element },
|
||||
) {
|
||||
if (isBoundEventHandler(handler)) {
|
||||
const fn = handler[0],
|
||||
value = handler[1];
|
||||
fn(value, event);
|
||||
} else {
|
||||
(handler as (e: typeof event) => void).bind(event.target)(event);
|
||||
}
|
||||
}
|
||||
|
||||
function onTextareaRefreshHeight<
|
||||
E extends Event & {
|
||||
currentTarget: HTMLTextAreaElement;
|
||||
target: HTMLTextAreaElement;
|
||||
},
|
||||
>(
|
||||
ocallback: JSX.EventHandlerUnion<HTMLTextAreaElement, E> | undefined,
|
||||
event: E,
|
||||
) {
|
||||
const element = event.currentTarget;
|
||||
element.style.removeProperty("height");
|
||||
element.style.height = `${element.scrollHeight + 2}px`;
|
||||
|
||||
if (ocallback) {
|
||||
callEventHandlerUnion(ocallback, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The <textarea /> automatically vertically sized as the content.
|
||||
*
|
||||
* Note: listens the "focus" and "input" event using `addEventListener()`
|
||||
* may not work - use the event listening syntax on the component instead.
|
||||
* If you find it work, tell Rubicon to remove this note.
|
||||
*/
|
||||
const SizedTextarea: Component<JSX.HTMLElementTags["textarea"]> = (oprops) => {
|
||||
const [props, rest] = splitProps(oprops, ["onInput", "onFocus", "class"]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
onInput={(event) =>
|
||||
onTextareaRefreshHeight<typeof event>(props.onInput, event)
|
||||
}
|
||||
onFocus={[onTextareaRefreshHeight, props.onFocus]}
|
||||
class={`SizedTextarea ${props.class || ""}`}
|
||||
{...rest}
|
||||
></textarea>
|
||||
);
|
||||
};
|
||||
|
||||
export default SizedTextarea;
|
|
@ -1,69 +0,0 @@
|
|||
.StackedPage {
|
||||
contain: strict;
|
||||
container: StackedPage / inline-size;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
dialog.StackedPage {
|
||||
border: none;
|
||||
position: fixed;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
width: 560px;
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
/*
|
||||
* WebKit does not see contain-instric-size as the real element size.
|
||||
* If the container does not have height, the child element using 100%
|
||||
* height (usually Scafflod in our case) was have 0px computed height.
|
||||
*
|
||||
* This behaviour is different from Firefox. So we need to actually
|
||||
* define the box height here. (Rubicon)
|
||||
*/
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: none;
|
||||
display: none;
|
||||
|
||||
contain: strict;
|
||||
contain-intrinsic-size: 560px 100vh;
|
||||
contain-intrinsic-size: 560px 100dvh;
|
||||
content-visibility: auto;
|
||||
|
||||
background: var(--tutu-color-surface);
|
||||
box-shadow: var(--tutu-shadow-e16);
|
||||
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
contain-intrinsic-size: 100vw 100vh;
|
||||
contain-intrinsic-size: 100dvw 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
&[open] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.animating {
|
||||
overflow: hidden;
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,724 +0,0 @@
|
|||
import {
|
||||
type RouterProps,
|
||||
type StaticRouterProps,
|
||||
createRouter,
|
||||
} from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
Index,
|
||||
onMount,
|
||||
Show,
|
||||
untrack,
|
||||
useContext,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
useTransition,
|
||||
getOwner,
|
||||
} from "solid-js";
|
||||
import { createStore, unwrap } from "solid-js/store";
|
||||
import "./StackedRouter.css";
|
||||
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
|
||||
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "~material/theme";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { isPointNotInRect } from "./dom";
|
||||
|
||||
let uniqueCounter = 0;
|
||||
|
||||
function createUniqueId() {
|
||||
return `sr-${uniqueCounter++}`;
|
||||
}
|
||||
|
||||
export type StackedRouterProps = Omit<RouterProps, "url">;
|
||||
|
||||
export type StackFrame = {
|
||||
path: string;
|
||||
rootId: string;
|
||||
state: unknown;
|
||||
|
||||
animateOpen?: (element: HTMLElement) => Animation;
|
||||
animateClose?: (element: HTMLElement) => Animation;
|
||||
};
|
||||
|
||||
export type NewFrameOptions<T> = (T extends undefined
|
||||
? {
|
||||
state?: T;
|
||||
}
|
||||
: { state: T }) & {
|
||||
/**
|
||||
* The new frame should replace the current frame or all the stack.
|
||||
*/
|
||||
replace?: boolean | "all";
|
||||
/**
|
||||
* The animatedOpen phase of the life cycle.
|
||||
*
|
||||
* You can use this hook to animate the opening
|
||||
* of the frame. In this phase, the frame content is created
|
||||
* and is mounted to the document.
|
||||
*
|
||||
* You must return an {@link Animation}. This function must be
|
||||
* without side effects. This phase is ended after the {@link Animation}
|
||||
* finished.
|
||||
*/
|
||||
animateOpen?: StackFrame["animateOpen"];
|
||||
/**
|
||||
* The animatedClose phase of the life cycle.
|
||||
*
|
||||
* You can use this hook to animate the closing of the frame.
|
||||
* In this phase, the frame content is still mounted in the
|
||||
* document and will be unmounted after this phase.
|
||||
*
|
||||
* You must return an {@link Animation}. This function must be
|
||||
* without side effects. This phase is ended after the
|
||||
* {@link Animation} finished.
|
||||
*/
|
||||
animateClose?: StackFrame["animateClose"];
|
||||
};
|
||||
|
||||
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
|
||||
| undefined
|
||||
| any
|
||||
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Promise<Readonly<StackFrame>>
|
||||
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Promise<Readonly<StackFrame>>;
|
||||
|
||||
export type Navigator<PushGuide = Record<string, any>> = {
|
||||
frames: readonly StackFrame[];
|
||||
push: FramePusher<PushGuide>;
|
||||
pop: (depth?: number) => void;
|
||||
};
|
||||
|
||||
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
||||
|
||||
/**
|
||||
* Get the possible navigator of the {@link StackedRouter}.
|
||||
*
|
||||
* @see useNavigator for the navigator usage.
|
||||
*/
|
||||
export function useMaybeNavigator() {
|
||||
return useContext(NavigatorContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the navigator of the {@link StackedRouter}.
|
||||
*
|
||||
* This function returns a {@link Navigator} without available
|
||||
* push guide. Push guide is a record type contains available
|
||||
* path and its state. If you need push guide, you may want to
|
||||
* define your own function (like `useAppNavigator`) and cast the
|
||||
* navigator to the type you need.
|
||||
*
|
||||
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
||||
*/
|
||||
export function useNavigator() {
|
||||
const navigator = useMaybeNavigator();
|
||||
|
||||
if (!navigator) {
|
||||
throw new TypeError("not in available scope of StackedRouter");
|
||||
}
|
||||
|
||||
return navigator;
|
||||
}
|
||||
|
||||
export type CurrentFrame = {
|
||||
index: number;
|
||||
frame: Readonly<StackFrame>;
|
||||
};
|
||||
|
||||
const CurrentFrameContext =
|
||||
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
||||
|
||||
/**
|
||||
* Return the current, if possible.
|
||||
*
|
||||
* @see {@link useCurrentFrame} asserts the frame exists
|
||||
*/
|
||||
export function useMaybeCurrentFrame() {
|
||||
return useContext(CurrentFrameContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current frame, assert the frame exists.
|
||||
*
|
||||
* @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}.
|
||||
*/
|
||||
export function useCurrentFrame() {
|
||||
const frame = useMaybeCurrentFrame();
|
||||
|
||||
if (!frame) {
|
||||
throw new TypeError("not in available scope of StackedRouter");
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an accessor of is current frame is suspended.
|
||||
*
|
||||
* A suspended frame is the one not on the top. "Suspended"
|
||||
* is the description of a certain situtation, not in the life cycle
|
||||
* of a frame.
|
||||
*
|
||||
* If this is not called under a {@link StackedRouter}, it always
|
||||
* returns `false`.
|
||||
*/
|
||||
export function useIsFrameSuspended() {
|
||||
const { frames } = useMaybeNavigator() || {};
|
||||
|
||||
if (typeof frames === "undefined") {
|
||||
return () => false;
|
||||
}
|
||||
|
||||
const thisFrame = useCurrentFrame();
|
||||
|
||||
return () => {
|
||||
const idx = thisFrame().index;
|
||||
return frames.length - 1 > idx;
|
||||
};
|
||||
}
|
||||
|
||||
function onDialogClick(
|
||||
onClose: () => void,
|
||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||
) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (isPointNotInRect(rect, event.clientX, event.clientY)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function animateClose(element: HTMLElement) {
|
||||
if (window.innerWidth <= 560) {
|
||||
return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION });
|
||||
} else {
|
||||
return element.animate(
|
||||
{
|
||||
opacity: [0.5, 0],
|
||||
},
|
||||
{ easing: ANIM_CURVE_STD, duration: 220 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function animateOpen(element: HTMLElement) {
|
||||
if (window.innerWidth <= 560) {
|
||||
return animateSlideInFromRight(element, {
|
||||
easing: ANIM_CURVE_DECELERATION,
|
||||
});
|
||||
} else {
|
||||
return element.animate(
|
||||
{
|
||||
opacity: [0.5, 1],
|
||||
},
|
||||
{ easing: ANIM_CURVE_STD, duration: 220 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function serializableStack(stack: readonly StackFrame[]) {
|
||||
const frames = unwrap(stack);
|
||||
return frames.map((fr) => {
|
||||
return fr.animateClose || fr.animateOpen
|
||||
? {
|
||||
path: fr.path,
|
||||
rootId: fr.rootId,
|
||||
state: fr.state,
|
||||
}
|
||||
: fr;
|
||||
});
|
||||
}
|
||||
|
||||
function isNotInIOSSwipeToBackArea(x: number) {
|
||||
return (
|
||||
(x > 22 && x < window.innerWidth - 22) ||
|
||||
(x < -22 && x > window.innerWidth + 22)
|
||||
);
|
||||
}
|
||||
|
||||
function onEntryTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [fig0] = event.touches;
|
||||
|
||||
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function contains the state for swipe to back.
|
||||
*
|
||||
* @returns the props for dialogs to feature swipe to back.
|
||||
*/
|
||||
function createManagedSwipeToBack(
|
||||
stack: readonly Readonly<StackFrame>[],
|
||||
onlyPopFrame: (depth: number) => void,
|
||||
) {
|
||||
let reenterableAnimation: Animation | undefined;
|
||||
let origWidth = 0,
|
||||
origFigX = 0,
|
||||
origFigY = 0;
|
||||
|
||||
const resetAnimation = () => {
|
||||
reenterableAnimation = undefined;
|
||||
};
|
||||
|
||||
const onDialogTouchStart = (
|
||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
|
||||
const [fig0] = event.touches;
|
||||
const { width } = event.currentTarget.getBoundingClientRect();
|
||||
origWidth = width;
|
||||
origFigX = fig0.clientX;
|
||||
origFigY = fig0.clientY;
|
||||
|
||||
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
||||
return;
|
||||
}
|
||||
// Prevent the default swipe to back/forward on iOS
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
let animationProgressUpdateReleased = true;
|
||||
let nextAnimationProgress = 0;
|
||||
|
||||
const updateAnimationProgress = () => {
|
||||
try {
|
||||
if (!reenterableAnimation) return;
|
||||
const { activeDuration, delay } =
|
||||
reenterableAnimation.effect!.getComputedTiming();
|
||||
|
||||
const totalTime = (delay || 0) + Number(activeDuration);
|
||||
reenterableAnimation.currentTime = totalTime * nextAnimationProgress;
|
||||
} finally {
|
||||
animationProgressUpdateReleased = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchMove = (
|
||||
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
||||
) => {
|
||||
if (event.touches.length !== 1) {
|
||||
if (reenterableAnimation) {
|
||||
reenterableAnimation.reverse();
|
||||
reenterableAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
const [fig0] = event.touches;
|
||||
|
||||
const ofsX = fig0.clientX - origFigX;
|
||||
|
||||
if (!reenterableAnimation) {
|
||||
if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) {
|
||||
return;
|
||||
}
|
||||
const lastFr = stack[stack.length - 1];
|
||||
const createAnimation = lastFr.animateClose ?? animateClose;
|
||||
reenterableAnimation = createAnimation(event.currentTarget);
|
||||
reenterableAnimation.pause();
|
||||
reenterableAnimation.addEventListener("finish", resetAnimation);
|
||||
reenterableAnimation.addEventListener("cancel", resetAnimation);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio;
|
||||
|
||||
if (animationProgressUpdateReleased) {
|
||||
animationProgressUpdateReleased = false;
|
||||
|
||||
requestAnimationFrame(updateAnimationProgress);
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchEnd = (event: TouchEvent) => {
|
||||
if (!reenterableAnimation) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { activeDuration, delay } =
|
||||
reenterableAnimation.effect!.getComputedTiming();
|
||||
const totalTime = (delay || 0) + Number(activeDuration);
|
||||
|
||||
if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) {
|
||||
reenterableAnimation.addEventListener("finish", () => {
|
||||
onlyPopFrame(1);
|
||||
});
|
||||
reenterableAnimation.play();
|
||||
} else {
|
||||
reenterableAnimation.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogTouchCancel = (event: TouchEvent) => {
|
||||
if (!reenterableAnimation) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
reenterableAnimation.cancel();
|
||||
};
|
||||
|
||||
return {
|
||||
"on:touchstart": onDialogTouchStart,
|
||||
"on:touchmove": onDialogTouchMove,
|
||||
"on:touchend": onDialogTouchEnd,
|
||||
"on:touchcancel": onDialogTouchCancel,
|
||||
};
|
||||
}
|
||||
|
||||
function animateUntil(
|
||||
stepfn: (onCreated: (animation: Animation) => void) => void,
|
||||
) {
|
||||
const execStep = () => {
|
||||
requestAnimationFrame(() => {
|
||||
stepfn((step) => {
|
||||
step.addEventListener("finish", () => {
|
||||
execStep();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
execStep();
|
||||
}
|
||||
|
||||
function noOp() {}
|
||||
|
||||
function StaticRouter(props: StaticRouterProps) {
|
||||
const url = () => props.url || "";
|
||||
|
||||
// TODO: support onBeforeLeave, see
|
||||
// https://github.com/solidjs/solid-router/blob/main/src/routers/Router.ts
|
||||
|
||||
return createRouter({
|
||||
get: url,
|
||||
set: noOp,
|
||||
init(notify) {
|
||||
createRenderEffect(() => notify(url()));
|
||||
return noOp;
|
||||
},
|
||||
})(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache key of saved stack for hot reload.
|
||||
*
|
||||
* We could not use symbols because every time the hot reload the `Symbol()`
|
||||
* call creates a new symbol.
|
||||
*/
|
||||
const $StackedRouterSavedStack = "$StackedRouterSavedStack";
|
||||
|
||||
/**
|
||||
* The router that stacks the pages.
|
||||
*
|
||||
* **Routes** The router accepts the {@link RouterProps} excluding the "url" field.
|
||||
* You can seamlessly use the `<Route />` from `@solidjs/router`.
|
||||
*
|
||||
* Be advised that this component is not a drop-in replacement of that router.
|
||||
* These primitives from `@solidjs/router` won't work correctly:
|
||||
*
|
||||
* - `<A />` component - use ~platform/A instead
|
||||
* - `useLocation()` - see {@link useCurrentFrame}
|
||||
* - `useNavigate()` - see {@link useNavigator}
|
||||
*
|
||||
* The other primitives may work, as long as they don't rely on the global location.
|
||||
* This component uses `@solidjs/router` {@link StaticRouter} to route.
|
||||
*
|
||||
* **Injecting Safe Area Insets** The router calculate correct
|
||||
* `--safe-area-inset-left` and `--safe-area-inset-right` from the window
|
||||
* width and `--safe-area-inset-*` from the :root element. That means
|
||||
* the injected insets do not reflects the overrides that are not on the :root.
|
||||
*
|
||||
* The recalculation is only performed when the window size changed.
|
||||
*
|
||||
* **Navigation Animation** The router provides default animation for
|
||||
* navigation.
|
||||
*
|
||||
* If the default animation does not met your requirement,
|
||||
* this component is also intergated with Web Animation API.
|
||||
* You can provide {@link NewFrameOptions.animateOpen} and
|
||||
* {@link NewFrameOptions.animateClose} to define custom animation.
|
||||
*
|
||||
* **Swipe to back** For the subpages (the pages stacked on the entry),
|
||||
* swipe to back gesture is provided for user experience.
|
||||
*
|
||||
* Navigation animations (even the custom ones) will be played during
|
||||
* swipe to back, please keep in mind when designing animations.
|
||||
*
|
||||
* The iOS default gesture is blocked on all pages.
|
||||
*/
|
||||
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
if (import.meta.hot) {
|
||||
const saveStack = () => {
|
||||
import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack);
|
||||
console.debug("stack saved");
|
||||
};
|
||||
|
||||
import.meta.hot.on("vite:beforeUpdate", saveStack);
|
||||
onCleanup(() => import.meta.hot!.off("vite:beforeUpdate", saveStack));
|
||||
|
||||
const loadStack = () => {
|
||||
const savedStack = import.meta.hot!.data[$StackedRouterSavedStack];
|
||||
if (savedStack) {
|
||||
mutStack(savedStack);
|
||||
console.debug("stack loaded");
|
||||
}
|
||||
delete import.meta.hot!.data[$StackedRouterSavedStack];
|
||||
};
|
||||
|
||||
createRenderEffect(() => {
|
||||
loadStack();
|
||||
});
|
||||
}
|
||||
|
||||
const pushFrame = async (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
||||
await untrack(async () => {
|
||||
const frame = {
|
||||
path,
|
||||
state: opts?.state,
|
||||
rootId: createUniqueId(),
|
||||
animateOpen: opts?.animateOpen,
|
||||
animateClose: opts?.animateClose,
|
||||
};
|
||||
|
||||
const replace = opts?.replace;
|
||||
const length = stack.length;
|
||||
await startTransition(() => {
|
||||
if (replace === "all" || length === 0) {
|
||||
mutStack([frame]);
|
||||
} else if (replace) {
|
||||
const idx = length - 1;
|
||||
mutStack(idx, frame);
|
||||
} else {
|
||||
mutStack(length, frame);
|
||||
}
|
||||
});
|
||||
|
||||
const savedStack = serializableStack(stack);
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(savedStack, "", path);
|
||||
} else {
|
||||
window.history.pushState(savedStack, "", path);
|
||||
}
|
||||
|
||||
return frame;
|
||||
});
|
||||
|
||||
const onlyPopFrameOnStack = (depth: number) => {
|
||||
mutStack((o) => o.toSpliced(o.length - depth, depth));
|
||||
};
|
||||
|
||||
const onlyPopFrame = (depth: number) => {
|
||||
onlyPopFrameOnStack(depth);
|
||||
window.history.go(-depth);
|
||||
};
|
||||
|
||||
const animatePopOneFrame = (onCreated: (animation: Animation) => void) => {
|
||||
const lastFrame = stack[stack.length - 1];
|
||||
const element = document.getElementById(
|
||||
lastFrame.rootId,
|
||||
)! as HTMLDialogElement;
|
||||
const createAnimation = lastFrame.animateClose ?? animateClose;
|
||||
element.classList.add("animating");
|
||||
|
||||
const onNavAnimEnd = () => {
|
||||
element.classList.remove("animating");
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const animation = createAnimation(element);
|
||||
animation.addEventListener("finish", onNavAnimEnd);
|
||||
animation.addEventListener("cancel", onNavAnimEnd);
|
||||
onCreated(animation);
|
||||
});
|
||||
};
|
||||
|
||||
const popFrame = (depth: number = 1) =>
|
||||
untrack(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
if (depth < 0) {
|
||||
console.warn("the depth to pop should not < 0, now is", depth);
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 1) {
|
||||
let count = depth;
|
||||
animateUntil((created) => {
|
||||
if (count > 0) {
|
||||
animatePopOneFrame((a) => {
|
||||
a.addEventListener("finish", () => onlyPopFrame(1));
|
||||
created(a);
|
||||
});
|
||||
}
|
||||
count--;
|
||||
});
|
||||
} else {
|
||||
onlyPopFrame(1);
|
||||
}
|
||||
});
|
||||
|
||||
createRenderEffect(() =>
|
||||
untrack(() => {
|
||||
if (stack.length === 0) {
|
||||
const parts = [window.location.pathname] as string[];
|
||||
if (window.location.search) {
|
||||
parts.push(window.location.search);
|
||||
}
|
||||
pushFrame(parts.join(""), {
|
||||
replace: "all",
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
createRenderEffect(() => {
|
||||
makeEventListener(window, "popstate", (event) => {
|
||||
if (!event.state) return;
|
||||
|
||||
// TODO: verify the stack in state and handling forwards
|
||||
|
||||
if (stack.length === 0) {
|
||||
mutStack(event.state || []);
|
||||
} else if (stack.length > event.state.length) {
|
||||
let count = stack.length - event.state.length;
|
||||
animateUntil((created) => {
|
||||
if (count > 0) {
|
||||
animatePopOneFrame((a) => {
|
||||
a.addEventListener("finish", () => {
|
||||
onlyPopFrameOnStack(1);
|
||||
created(a);
|
||||
});
|
||||
});
|
||||
}
|
||||
count--;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const onBeforeDialogMount = (element: HTMLDialogElement) => {
|
||||
onMount(() => {
|
||||
const lastFr = untrack(() => stack[stack.length - 1]);
|
||||
const createAnimation = lastFr.animateOpen ?? animateOpen;
|
||||
requestAnimationFrame(() => {
|
||||
element.showModal();
|
||||
element.classList.add("animating");
|
||||
const animation = createAnimation(element);
|
||||
animation.addEventListener("finish", () =>
|
||||
element.classList.remove("animating"),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const subInsets = createMemo(() => {
|
||||
const SUBPAGE_MAX_WIDTH = 560;
|
||||
const { width } = windowSize;
|
||||
if (width <= SUBPAGE_MAX_WIDTH) {
|
||||
// page width = 100vw, use the inset directly
|
||||
return {};
|
||||
}
|
||||
const computedStyle = window.getComputedStyle(
|
||||
document.querySelector(":root")!,
|
||||
);
|
||||
const oinsetLeft = computedStyle
|
||||
.getPropertyValue("--safe-area-inset-left")
|
||||
.split("px", 1)[0];
|
||||
const oinsetRight = computedStyle
|
||||
.getPropertyValue("--safe-area-inset-right")
|
||||
.split("px", 1)[0];
|
||||
const left = Number(oinsetLeft),
|
||||
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
|
||||
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
|
||||
if (width >= totalWidth) {
|
||||
return {
|
||||
"--safe-area-inset-left": "0px",
|
||||
"--safe-area-inset-right": "0px",
|
||||
};
|
||||
}
|
||||
const ofs = (totalWidth - width) / 2;
|
||||
return {
|
||||
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
|
||||
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
||||
|
||||
return (
|
||||
<NavigatorContext.Provider
|
||||
value={{
|
||||
push: pushFrame,
|
||||
pop: popFrame,
|
||||
frames: stack,
|
||||
}}
|
||||
>
|
||||
<Index each={stack}>
|
||||
{(frame, index) => {
|
||||
const currentFrame = () => {
|
||||
return {
|
||||
index,
|
||||
get frame() {
|
||||
return frame();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<CurrentFrameContext.Provider value={currentFrame}>
|
||||
<Show
|
||||
when={index !== 0}
|
||||
fallback={
|
||||
<div
|
||||
class="StackedPage"
|
||||
id={frame().rootId}
|
||||
role="presentation"
|
||||
on:touchstart={onEntryTouchStart}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<dialog
|
||||
ref={onBeforeDialogMount}
|
||||
class="StackedPage"
|
||||
onCancel={[popFrame, 1]}
|
||||
onClick={[onDialogClick, popFrame]}
|
||||
{...swipeToBackProps}
|
||||
id={frame().rootId}
|
||||
style={subInsets()}
|
||||
>
|
||||
<StaticRouter url={frame().path} {...oprops} />
|
||||
</dialog>
|
||||
</Show>
|
||||
</CurrentFrameContext.Provider>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</NavigatorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StackedRouter;
|
|
@ -1,244 +1,51 @@
|
|||
import {
|
||||
createContext,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
untrack,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type Signal,
|
||||
} from "solid-js";
|
||||
|
||||
export function animateRollOutFromTop(
|
||||
root: HTMLElement,
|
||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
||||
) {
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
export type HeroSource = {
|
||||
[key: string | symbol | number]: HTMLElement | undefined;
|
||||
};
|
||||
|
||||
const { height } = root.getBoundingClientRect();
|
||||
const HeroSourceContext = createContext<Signal<HeroSource>>(
|
||||
/* __@PURE__ */ undefined,
|
||||
);
|
||||
|
||||
const opts = Object.assign(
|
||||
{
|
||||
duration: Math.floor((height / 1600) * 1000),
|
||||
},
|
||||
options,
|
||||
);
|
||||
export const HeroSourceProvider = HeroSourceContext.Provider;
|
||||
|
||||
const animation = root.animate(
|
||||
{
|
||||
height: ["0px", `${height}px`],
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
const restore = () => (root.style.overflow = overflow);
|
||||
|
||||
animation.addEventListener("finish", restore);
|
||||
animation.addEventListener("cancel", restore);
|
||||
|
||||
return animation;
|
||||
function useHeroSource() {
|
||||
return useContext(HeroSourceContext);
|
||||
}
|
||||
|
||||
export function animateRollInFromBottom(
|
||||
root: HTMLElement,
|
||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
||||
) {
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
/**
|
||||
* Use hero value for the {@link key}.
|
||||
*/
|
||||
export function useHeroSignal(
|
||||
key: string | symbol | number,
|
||||
): Signal<HTMLElement | undefined> {
|
||||
const source = useHeroSource();
|
||||
if (source) {
|
||||
const [get, set] = createSignal<HTMLElement>();
|
||||
|
||||
const { height } = root.getBoundingClientRect();
|
||||
createRenderEffect(() => {
|
||||
const value = source[0]();
|
||||
if (value[key]) {
|
||||
set(value[key]);
|
||||
source[1]((x) => {
|
||||
const cpy = Object.assign({}, x);
|
||||
delete cpy[key];
|
||||
return cpy;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const opts = Object.assign(
|
||||
{
|
||||
duration: Math.floor((height / 1600) * 1000),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const animation = root.animate(
|
||||
{
|
||||
height: [`${height}px`, "0px"],
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
const restore = () => (root.style.overflow = overflow);
|
||||
|
||||
animation.addEventListener("finish", restore);
|
||||
animation.addEventListener("cancel", restore);
|
||||
|
||||
return animation;
|
||||
}
|
||||
|
||||
export function animateGrowFromTopRight(
|
||||
root: HTMLElement,
|
||||
options?: KeyframeAnimationOptions,
|
||||
) {
|
||||
const transformOrigin = root.style.transformOrigin;
|
||||
root.style.transformOrigin = "top right";
|
||||
|
||||
const { width, height } = root.getBoundingClientRect();
|
||||
|
||||
const speed = transitionSpeedForEnter(window.innerHeight);
|
||||
|
||||
const durationX = Math.floor(height / speed);
|
||||
const durationY = Math.floor(width / speed);
|
||||
|
||||
// finds the offset for the center frame,
|
||||
// it will stops at the (minDuration / maxDuration)%
|
||||
const minDuration = Math.min(durationX, durationY);
|
||||
const maxDuration = Math.max(durationX, durationY);
|
||||
|
||||
const centerOffset = minDuration / maxDuration;
|
||||
|
||||
const keyframes = [
|
||||
{ transform: "scaleX(0.5)", opacity: 0, height: "0px", offset: 0 },
|
||||
{
|
||||
transform: `scaleX(${minDuration === durationX ? "1" : centerOffset / 2 + 0.5})`,
|
||||
height: `${(minDuration === durationY ? 1 : centerOffset) * height}px`,
|
||||
offset: centerOffset,
|
||||
},
|
||||
{ transform: "scaleX(1)", height: `${height}px`, opacity: 1, offset: 1 },
|
||||
];
|
||||
|
||||
const animation = root.animate(keyframes, {
|
||||
...options,
|
||||
duration: maxDuration,
|
||||
});
|
||||
|
||||
const restore = () => {
|
||||
root.style.transformOrigin = transformOrigin;
|
||||
};
|
||||
|
||||
animation.addEventListener("cancel", restore);
|
||||
animation.addEventListener("finish", restore);
|
||||
|
||||
return animation;
|
||||
}
|
||||
|
||||
export function animateShrinkToTopRight(
|
||||
root: HTMLElement,
|
||||
options?: KeyframeAnimationOptions,
|
||||
) {
|
||||
const overflow = root.style.overflow;
|
||||
root.style.overflow = "hidden";
|
||||
const transformOrigin = root.style.transformOrigin;
|
||||
root.style.transformOrigin = "top right";
|
||||
|
||||
const { width, height } = root.getBoundingClientRect();
|
||||
|
||||
const speed = transitionSpeedForLeave(window.innerWidth);
|
||||
|
||||
const duration = Math.floor(Math.max(width / speed, height / speed));
|
||||
|
||||
const animation = root.animate(
|
||||
{
|
||||
transform: ["scale(1)", "scale(0.5)"],
|
||||
opacity: [1, 0],
|
||||
},
|
||||
{ ...options, duration },
|
||||
);
|
||||
|
||||
const restore = () => {
|
||||
root.style.overflow = overflow;
|
||||
root.style.transformOrigin = transformOrigin;
|
||||
};
|
||||
|
||||
animation.addEventListener("cancel", restore);
|
||||
animation.addEventListener("finish", restore);
|
||||
|
||||
return animation;
|
||||
}
|
||||
|
||||
// Contribution to the animation speed:
|
||||
// - the screen size: mobiles should have longer transition,
|
||||
// the transition time should be longer as the travelling distance longer,
|
||||
// but it's not linear. The larger screen should have higher velocity,
|
||||
// to avoid the transition is too long.
|
||||
// As the screen larger, on desktops, the transition should be simpler and
|
||||
// signficantly faster.
|
||||
// On much smaller screens, like wearables, the transition should be shorter
|
||||
// than on mobiles.
|
||||
// - Animation complexity: On mobile:
|
||||
// - large, complex, full-screen transitions may have longer durations, over 375ms
|
||||
// - entering screen over 225ms
|
||||
// - leaving screen over 195ms
|
||||
|
||||
function transitionSpeedForEnter(innerWidth: number) {
|
||||
if (innerWidth < 300) {
|
||||
return 2.4;
|
||||
} else if (innerWidth < 560) {
|
||||
return 1.6;
|
||||
} else if (innerWidth < 1200) {
|
||||
return 2.4;
|
||||
return [get, set];
|
||||
} else {
|
||||
return 2.55;
|
||||
return [() => undefined, () => undefined];
|
||||
}
|
||||
}
|
||||
|
||||
function transitionSpeedForLeave(innerWidth: number) {
|
||||
if (innerWidth < 300) {
|
||||
return 2.8;
|
||||
} else if (innerWidth < 560) {
|
||||
return 1.96;
|
||||
} else if (innerWidth < 1200) {
|
||||
return 2.8;
|
||||
} else {
|
||||
return 2.55;
|
||||
}
|
||||
}
|
||||
|
||||
export function animateSlideInFromRight(
|
||||
root: HTMLElement,
|
||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
||||
) {
|
||||
const { left } = root.getBoundingClientRect();
|
||||
const { innerWidth } = window;
|
||||
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const distance = Math.abs(left - innerWidth);
|
||||
const duration = Math.floor(distance / transitionSpeedForEnter(innerWidth));
|
||||
|
||||
const opts = Object.assign({ duration }, options);
|
||||
|
||||
const animation = root.animate(
|
||||
{
|
||||
left: [`${innerWidth}px`, `${left}px`],
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
const restore = () => {
|
||||
document.body.style.overflow = oldOverflow;
|
||||
};
|
||||
|
||||
animation.addEventListener("cancel", restore);
|
||||
animation.addEventListener("finish", restore);
|
||||
|
||||
return animation;
|
||||
}
|
||||
|
||||
export function animateSlideOutToRight(
|
||||
root: HTMLElement,
|
||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
||||
) {
|
||||
const { left } = root.getBoundingClientRect();
|
||||
const { innerWidth } = window;
|
||||
|
||||
const oldOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const distance = Math.abs(left - innerWidth);
|
||||
const duration = Math.floor(distance / transitionSpeedForLeave(innerWidth));
|
||||
|
||||
const opts = Object.assign({ duration }, options);
|
||||
|
||||
const animation = root.animate(
|
||||
{
|
||||
left: [`${left}px`, `${innerWidth}px`],
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
const restore = () => {
|
||||
document.body.style.overflow = oldOverflow;
|
||||
};
|
||||
|
||||
animation.addEventListener("cancel", restore);
|
||||
animation.addEventListener("finish", restore);
|
||||
|
||||
return animation;
|
||||
}
|
||||
|
|
|
@ -1,164 +0,0 @@
|
|||
/*
|
||||
Blurhash toolkit.
|
||||
|
||||
base83 decoder/encoder is copied from
|
||||
https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts,
|
||||
which is MIT Licensed: https://github.com/woltapp/blurhash?tab=MIT-1-ov-file#readme
|
||||
*/
|
||||
const digitCharacters = [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"*",
|
||||
"+",
|
||||
",",
|
||||
"-",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
"=",
|
||||
"?",
|
||||
"@",
|
||||
"[",
|
||||
"]",
|
||||
"^",
|
||||
"_",
|
||||
"{",
|
||||
"|",
|
||||
"}",
|
||||
"~",
|
||||
];
|
||||
|
||||
function decode83(str: string) {
|
||||
let value = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str[i];
|
||||
const digit = digitCharacters.indexOf(c);
|
||||
value = value * 83 + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function encode83(n: number, length: number): string {
|
||||
var result = "";
|
||||
for (let i = 1; i <= length; i++) {
|
||||
let digit = (Math.floor(n) / Math.pow(83, length - i)) % 83;
|
||||
result += digitCharacters[Math.floor(digit)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* toColorHex() is modified from
|
||||
https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/,
|
||||
licensed BSD-3. */
|
||||
|
||||
// Pre-Init
|
||||
const LUT_HEX_4b = [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
];
|
||||
const LUT_HEX_8b = new Array(0x100);
|
||||
for (let n = 0; n < 0x100; n++) {
|
||||
LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xf]}${LUT_HEX_4b[n & 0xf]}`;
|
||||
}
|
||||
// End Pre-Init
|
||||
function toColorHex(buffer: Uint8ClampedArray): `#${string}` {
|
||||
let out = "#";
|
||||
for (let idx = 0, edx = buffer.length; idx < edx; idx++) {
|
||||
out += LUT_HEX_8b[buffer[idx]];
|
||||
}
|
||||
return out as `#${string}`;
|
||||
}
|
||||
|
||||
export function averageColor(blurhash: string) {
|
||||
const v = decode83(blurhash.substring(2, 6)); // 24-bit RGB
|
||||
|
||||
return [v >> 16, (v >> 8) & 255, v & 255] as const;
|
||||
}
|
||||
|
||||
export function averageColorHex(blurhash: string) : `#${string}` {
|
||||
const [r, g, b] = averageColor(blurhash);
|
||||
|
||||
const buf = new Uint8ClampedArray(3);
|
||||
buf[0] = r;
|
||||
buf[1] = g;
|
||||
buf[2] = b;
|
||||
|
||||
return toColorHex(buf);
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
import { addMinutes, formatRFC7231 } from "date-fns";
|
||||
import {
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
untrack,
|
||||
} from "solid-js";
|
||||
|
||||
export function createCacheBucket(name: string) {
|
||||
let bucket: Cache | undefined;
|
||||
|
||||
return async () => {
|
||||
if (bucket) {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
bucket = await self.caches.open(name);
|
||||
|
||||
return bucket;
|
||||
};
|
||||
}
|
||||
|
||||
export type FetchRequest = {
|
||||
url: string;
|
||||
headers?: HeadersInit | Headers;
|
||||
};
|
||||
|
||||
async function searchCache(request: Request) {
|
||||
return await self.caches.match(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link fetch} helper with additional caching support.
|
||||
*/
|
||||
export class CachedFetch<
|
||||
Transformer extends (response: Response) => any,
|
||||
Keyer extends (...args: any[]) => FetchRequest,
|
||||
> {
|
||||
private cacheBucket: () => Promise<Cache>;
|
||||
keyFor: Keyer;
|
||||
private transform: Transformer;
|
||||
|
||||
constructor(
|
||||
cacheBucket: () => Promise<Cache>,
|
||||
keyFor: Keyer,
|
||||
tranformer: Transformer,
|
||||
) {
|
||||
this.cacheBucket = cacheBucket;
|
||||
this.keyFor = keyFor;
|
||||
this.transform = tranformer;
|
||||
}
|
||||
|
||||
private async validateCache(request: Request) {
|
||||
const buk = await this.cacheBucket();
|
||||
const response = await fetch(request);
|
||||
buk.put(request, response.clone());
|
||||
return response;
|
||||
}
|
||||
|
||||
private request(...args: Parameters<Keyer>) {
|
||||
const { url, ...init } = this.keyFor(...args);
|
||||
const request = new Request(url, init);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race between the cache and the network result,
|
||||
* use the fastest result.
|
||||
*
|
||||
* The cache will be revalidated.
|
||||
*/
|
||||
async fastest(
|
||||
...args: Parameters<Keyer>
|
||||
): Promise<Awaited<ReturnType<Transformer>>> {
|
||||
const request = this.request(...args);
|
||||
const validating = this.validateCache(request);
|
||||
|
||||
const searching = searchCache(request);
|
||||
|
||||
const earlyResult = await Promise.race([validating, searching]);
|
||||
|
||||
if (earlyResult) {
|
||||
return await this.transform(earlyResult);
|
||||
}
|
||||
|
||||
return await this.transform(await validating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return the result.
|
||||
*/
|
||||
async validate(
|
||||
...args: Parameters<Keyer>
|
||||
): Promise<Awaited<ReturnType<Transformer>>> {
|
||||
return await this.transform(
|
||||
await this.validateCache(this.request(...args)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a response as the cache.
|
||||
* Recommend to set `Expires` or `Cache-Control` to limit its live time.
|
||||
*/
|
||||
async set(key: Parameters<Keyer>, response: Response) {
|
||||
const buk = await this.cacheBucket();
|
||||
await buk.put(this.request(...key), response);
|
||||
}
|
||||
|
||||
/** Set a json object as the cache.
|
||||
* Only available for 5 minutes.
|
||||
*/
|
||||
async setJson(key: Parameters<Keyer>, object: unknown) {
|
||||
const response = new Response(JSON.stringify(object), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Expires: formatRFC7231(addMinutes(new Date(), 5)),
|
||||
"X-Cache-Src": "set",
|
||||
},
|
||||
});
|
||||
|
||||
await this.set(key, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a resource, using the cache at first, and revalidate
|
||||
* later.
|
||||
*/
|
||||
cachedAndRevalidate(args: () => Parameters<Keyer>) {
|
||||
const res = createResource(args, (p) => this.validate(...p));
|
||||
|
||||
const checkCacheIfStillLoading = async () => {
|
||||
const saved = await searchCache(this.request(...args()));
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
const transformed = await this.transform(saved);
|
||||
if (res[0].loading) {
|
||||
res[1].mutate(transformed);
|
||||
}
|
||||
};
|
||||
|
||||
createRenderEffect(() => void untrack(() => checkCacheIfStillLoading()));
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
export function isPointNotInRect(rect: DOMRect, ptX: number, ptY: number) {
|
||||
return (
|
||||
ptY < rect.top || ptY > rect.bottom || ptX < rect.left || ptX > rect.right
|
||||
);
|
||||
}
|
|
@ -1,37 +1,12 @@
|
|||
import { createContext, useContext, type Accessor } from "solid-js";
|
||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||
|
||||
export function isiOS() {
|
||||
return (
|
||||
[
|
||||
"iPad Simulator",
|
||||
"iPhone Simulator",
|
||||
"iPod Simulator",
|
||||
"iPad",
|
||||
"iPhone",
|
||||
"iPod",
|
||||
].includes(navigator.platform) ||
|
||||
// iPad on iOS 13 detection
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
);
|
||||
}
|
||||
|
||||
export type ServiceWorkerService = {
|
||||
needRefresh: Accessor<boolean>;
|
||||
offlineReady: Accessor<boolean>;
|
||||
serviceWorker: Accessor<ServiceWorker | undefined>;
|
||||
};
|
||||
|
||||
const ServiceWorkerContext = /* @__PURE__ */ createContext<
|
||||
ServiceWorkerService
|
||||
>(({
|
||||
needRefresh: () => false,
|
||||
offlineReady: () => false,
|
||||
serviceWorker: () => undefined
|
||||
}));
|
||||
|
||||
export const ServiceWorkerProvider = ServiceWorkerContext.Provider;
|
||||
|
||||
export function useServiceWorker(): ServiceWorkerService {
|
||||
return useContext(ServiceWorkerContext);
|
||||
}
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'iPod'
|
||||
].includes(navigator.platform)
|
||||
// iPad on iOS 13 detection
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
}
|
|
@ -1,21 +1,24 @@
|
|||
import {
|
||||
catchError,
|
||||
ParentComponent,
|
||||
createContext,
|
||||
createMemo,
|
||||
createResource,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { match } from "@formatjs/intl-localematcher";
|
||||
import { Accessor } from "solid-js";
|
||||
import { Accessor, createEffect, createSignal } from "solid-js";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { enGB } from "date-fns/locale/en-GB";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import type { Locale } from "date-fns";
|
||||
import {
|
||||
resolveTemplate,
|
||||
translator,
|
||||
type Template,
|
||||
} from "@solid-primitives/i18n";
|
||||
import { resolveTemplate, translator, type Template } from "@solid-primitives/i18n";
|
||||
|
||||
async function synchronised(
|
||||
name: string,
|
||||
callback: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await navigator.locks.request(name, callback);
|
||||
}
|
||||
|
||||
export const SUPPORTED_LANGS = ["en", "zh-Hans"] as const;
|
||||
|
||||
|
@ -25,61 +28,49 @@ const DEFAULT_LANG = "en";
|
|||
|
||||
/**
|
||||
* Decide the using language for the user.
|
||||
*
|
||||
* **Performance**: This function is costy, make sure you cache the result.
|
||||
* In the app, you should use {@link useAppLocale} instead.
|
||||
*
|
||||
* @returns the selected language tag
|
||||
*/
|
||||
export function autoMatchLangTag() {
|
||||
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the using region for the user.
|
||||
*
|
||||
* **Performance**: This function is costy, make sure you cache the result.
|
||||
* In the app, you should use {@link useAppLocale} instead.
|
||||
*/
|
||||
export function autoMatchRegion() {
|
||||
const specifiers = navigator.languages.map((x) => x.split("-"));
|
||||
const DateFnLocaleCx = /* __@PURE__ */createContext<Accessor<Locale>>(() => enGB);
|
||||
|
||||
for (const s of specifiers) {
|
||||
if (s.length === 1) {
|
||||
const lang = s[0];
|
||||
for (const available of SUPPORTED_REGIONS) {
|
||||
if (available.toLowerCase().startsWith(lang.toLowerCase())) {
|
||||
return available;
|
||||
}
|
||||
const cachedDateFnLocale: Record<string, Locale> = {
|
||||
enGB,
|
||||
};
|
||||
|
||||
export function autoMatchRegion() {
|
||||
const regions = navigator.languages
|
||||
.map((x) => {
|
||||
const parts = x.split("_");
|
||||
if (parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
} else if (s.length === 2) {
|
||||
const [lang, region] = s;
|
||||
for (const available of SUPPORTED_REGIONS) {
|
||||
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
|
||||
return available;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((x): x is string => !!x);
|
||||
for (const r of regions) {
|
||||
for (const available of SUPPORTED_REGIONS) {
|
||||
if (available.toLowerCase().endsWith(r.toLowerCase())) {
|
||||
return available;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "en_GB";
|
||||
}
|
||||
|
||||
export function createCurrentRegion() {
|
||||
export function useRegion() {
|
||||
const appSettings = useStore($settings);
|
||||
|
||||
return createMemo(
|
||||
() => {
|
||||
const settings = appSettings();
|
||||
if (typeof settings.region !== "undefined") {
|
||||
return settings.region;
|
||||
} else {
|
||||
return autoMatchRegion();
|
||||
}
|
||||
},
|
||||
"en_GB",
|
||||
{ name: "region" },
|
||||
);
|
||||
return createMemo(() => {
|
||||
const settings = appSettings();
|
||||
if (typeof settings.region !== "undefined") {
|
||||
return settings.region;
|
||||
} else {
|
||||
return autoMatchRegion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function importDateFnLocale(tag: string): Promise<Locale> {
|
||||
|
@ -87,7 +78,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
|||
case "en_us":
|
||||
return (await import("date-fns/locale/en-US")).enUS;
|
||||
case "en_gb":
|
||||
return enGB;
|
||||
return (await import("date-fns/locale/en-GB")).enGB;
|
||||
case "zh_cn":
|
||||
return (await import("date-fns/locale/zh-CN")).zhCN;
|
||||
default:
|
||||
|
@ -95,6 +86,51 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides runtime values and fetch dependencies for date-fns locale
|
||||
*/
|
||||
export const DateFnScope: ParentComponent = (props) => {
|
||||
const [dateFnLocale, setDateFnLocale] = createSignal(enGB);
|
||||
const region = useRegion();
|
||||
|
||||
createEffect(() => {
|
||||
const dateFnLocaleName = region();
|
||||
|
||||
if (cachedDateFnLocale[dateFnLocaleName]) {
|
||||
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
|
||||
} else {
|
||||
synchronised("i18n-wrapper-load-date-fns-locale", async () => {
|
||||
if (cachedDateFnLocale[dateFnLocaleName]) {
|
||||
setDateFnLocale(cachedDateFnLocale[dateFnLocaleName]);
|
||||
return;
|
||||
}
|
||||
const target = `date-fns/locale/${dateFnLocaleName}`;
|
||||
try {
|
||||
const mod = await importDateFnLocale(dateFnLocaleName);
|
||||
cachedDateFnLocale[dateFnLocaleName] = mod;
|
||||
setDateFnLocale(mod);
|
||||
} catch (reason) {
|
||||
console.error(
|
||||
{
|
||||
act: "load-date-fns-locale",
|
||||
stat: "failed",
|
||||
reason,
|
||||
target,
|
||||
},
|
||||
"failed to load date-fns locale",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DateFnLocaleCx.Provider value={dateFnLocale}>
|
||||
{props.children}
|
||||
</DateFnLocaleCx.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the {@link Locale} object for date-fns.
|
||||
*
|
||||
|
@ -103,51 +139,28 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
|||
* @returns Accessor for Locale
|
||||
*/
|
||||
export function useDateFnLocale(): Accessor<Locale> {
|
||||
const { dateFn } = useAppLocale();
|
||||
return dateFn;
|
||||
const cx = useContext(DateFnLocaleCx);
|
||||
return cx;
|
||||
}
|
||||
|
||||
export function createCurrentLanguage() {
|
||||
export function useLanguage() {
|
||||
const settings = useStore($settings);
|
||||
return createMemo(() => settings().language || autoMatchLangTag());
|
||||
return () => settings().language || autoMatchLangTag();
|
||||
}
|
||||
|
||||
type ImportFn<T> = (name: string) => Promise<{ default: T }>;
|
||||
type ImportFn<T> = (name: string) => Promise<{default: T}>
|
||||
|
||||
type ImportedModule<F> = F extends ImportFn<infer T> ? T : never;
|
||||
type ImportedModule<F> = F extends ImportFn<infer T> ? T: never
|
||||
|
||||
type MergedImportedModule<T> = T extends []
|
||||
? {}
|
||||
: T extends [infer I]
|
||||
? ImportedModule<I>
|
||||
: T extends [infer I, ...infer J]
|
||||
? ImportedModule<I> & MergedImportedModule<J>
|
||||
: never;
|
||||
type MergedImportedModule<T> =
|
||||
T extends [] ? {} :
|
||||
T extends [infer I] ? ImportedModule<I> :
|
||||
T extends [infer I, ...infer J] ? ImportedModule<I> & MergedImportedModule<J> : never
|
||||
|
||||
/**
|
||||
* Create a resource that combines all I18N strings into one object.
|
||||
*
|
||||
* The result is combined in the order of the argument functions.
|
||||
* The formers will be overrided by the latter.
|
||||
*
|
||||
* @param importFns a series of functions imports the string modules
|
||||
* based on the specified language code.
|
||||
*
|
||||
* **Context**: This function must be used under {@link AppLocaleProvider}.
|
||||
*
|
||||
* @example ````ts
|
||||
* const [strings] = createStringResource(
|
||||
* async (code) => await import(`./i18n/${code}.json`), // Vite can handle the bundling
|
||||
* async () => import("./i18n/generic.json"), // You can also ignore the code.
|
||||
* );
|
||||
* ````
|
||||
*
|
||||
* @see {@link createTranslator} if you need a Translator from "@solid-primitives/i18n"
|
||||
*/
|
||||
export function createStringResource<
|
||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||
>(...importFns: T) {
|
||||
const { language } = useAppLocale();
|
||||
const language = useLanguage();
|
||||
const cache: Record<string, MergedImportedModule<T>> = {};
|
||||
|
||||
return createResource(
|
||||
|
@ -157,11 +170,9 @@ export function createStringResource<
|
|||
return cache[nlang];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
importFns.map((x) => x(nlang).then((v) => v.default)),
|
||||
);
|
||||
const results = await Promise.all(importFns.map(x => x(nlang).then(v => v.default)))
|
||||
|
||||
const merged: MergedImportedModule<T> = Object.assign({}, ...results);
|
||||
const merged: MergedImportedModule<T> = Object.assign({}, ...results)
|
||||
|
||||
cache[nlang] = merged;
|
||||
|
||||
|
@ -170,57 +181,8 @@ export function createStringResource<
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Translator from "@solid-primitives/i18n" based on
|
||||
* the {@link createStringResource}.
|
||||
*
|
||||
* @param importFns same to {@link createStringResource}
|
||||
*
|
||||
* @returns the first element is the translator, the second is the result from
|
||||
* {@link createStringResource}.
|
||||
*
|
||||
* @see {@link translator} for the translator usage
|
||||
* @see {@link createStringResource} for the raw strings
|
||||
*/
|
||||
export function createTranslator<
|
||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||
>(...importFns: T) {
|
||||
const res = createStringResource(...importFns);
|
||||
export function createTranslator<T extends ImportFn<Record<string, string | Template<any> | undefined>>[],>(...importFns: T) {
|
||||
const res = createStringResource(...importFns)
|
||||
|
||||
return [translator(res[0], resolveTemplate), res] as const;
|
||||
}
|
||||
|
||||
export type AppLocale = {
|
||||
dateFn: () => Locale;
|
||||
language: () => string;
|
||||
region: () => string;
|
||||
};
|
||||
|
||||
const AppLocaleContext = /* @__PURE__ */ createContext<AppLocale>();
|
||||
|
||||
export const AppLocaleProvider = AppLocaleContext.Provider;
|
||||
|
||||
export function useAppLocale() {
|
||||
const l = useContext(AppLocaleContext);
|
||||
if (!l) {
|
||||
throw new TypeError("app locale not found");
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
export function createDateFnLocaleResource(region: () => string) {
|
||||
const [localeUncaught] = createResource(
|
||||
region,
|
||||
async (region) => {
|
||||
return await importDateFnLocale(region);
|
||||
},
|
||||
{ initialValue: enGB },
|
||||
);
|
||||
|
||||
return createMemo(
|
||||
() =>
|
||||
catchError(localeUncaught, (reason) => {
|
||||
console.error("fetch date-fns locale", reason);
|
||||
}) ?? enGB,
|
||||
);
|
||||
return [translator(res[0], resolveTemplate), res] as const
|
||||
}
|
||||
|
|
|
@ -1,27 +1,24 @@
|
|||
//! This module has side effect.
|
||||
//! It recommended to include the module by <script> tag.
|
||||
|
||||
if (typeof Promise.withResolvers === "undefined") {
|
||||
// Chrome/Edge 119, Firefox 121, Safari/iOS 17.4
|
||||
|
||||
// Promise.withResolvers is generic and works with subclasses - the typescript built-in decl
|
||||
// could not handle the subclassing case.
|
||||
(Promise.prototype as any).withResolvers = function <T>(
|
||||
this: AnyPromiseConstructor<T>,
|
||||
) {
|
||||
let resolve!: PromiseWithResolvers<T>["resolve"],
|
||||
reject!: PromiseWithResolvers<T>["reject"];
|
||||
// These variables are expected to be set after `new this()`
|
||||
|
||||
const promise = new this((resolve0, reject0) => {
|
||||
resolve = resolve0;
|
||||
reject = reject0;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
};
|
||||
if (typeof document.body.animate === "undefined") {
|
||||
// @ts-ignore: this file is polyfill, no exposed decls
|
||||
import("web-animations-js").then(() => {
|
||||
// all target platforms supported, prepared to remove
|
||||
console.warn("web animation polyfill is included");
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window.crypto.randomUUID === "undefined") {
|
||||
// Chrome/Edge 92+
|
||||
// https://stackoverflow.com/a/2117523/2800218
|
||||
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||
window.crypto.randomUUID =
|
||||
function randomUUID(): `${string}-${string}-${string}-${string}-${string}` {
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
|
||||
(
|
||||
+c ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
|
||||
).toString(16),
|
||||
) as `${string}-${string}-${string}-${string}-${string}`;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import {
|
|||
onCleanup,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import BottomSheet from "~material/BottomSheet";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import BottomSheet from "../material/BottomSheet";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
|
@ -14,9 +14,9 @@ import {
|
|||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
|
||||
import { Title } from "~material/typography";
|
||||
import { Title } from "../material/typography";
|
||||
import { render } from "solid-js/web";
|
||||
import { createRootTheme } from "~material/theme";
|
||||
import { useRootTheme } from "../material/mui";
|
||||
|
||||
const ShareBottomSheet: Component<{
|
||||
data?: ShareData;
|
||||
|
@ -78,7 +78,7 @@ export async function share(data?: ShareData): Promise<void> {
|
|||
|
||||
const dispose = render(() => {
|
||||
const [open, setOpen] = createSignal(true);
|
||||
const theme = createRootTheme();
|
||||
const theme = useRootTheme();
|
||||
onCleanup(() => {
|
||||
element.remove();
|
||||
resolve();
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { usePageVisibility } from "@solid-primitives/page-visibility";
|
||||
import {
|
||||
Accessor,
|
||||
createContext,
|
||||
|
@ -16,30 +15,19 @@ export const TimeSourceProvider = TimeSourceContext.Provider;
|
|||
export function createTimeSource() {
|
||||
let id: ReturnType<typeof setTimeout> | undefined;
|
||||
const [get, set] = createSignal(new Date());
|
||||
const visible = usePageVisibility();
|
||||
|
||||
const cancelTimer = () => {
|
||||
createRenderEffect(() =>
|
||||
untrack(() => {
|
||||
id = setTimeout(() => {
|
||||
set(new Date());
|
||||
}, 30 * 1000);
|
||||
}),
|
||||
);
|
||||
|
||||
onCleanup(() => {
|
||||
if (typeof id !== "undefined") {
|
||||
clearInterval(id);
|
||||
}
|
||||
id = undefined;
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
cancelTimer();
|
||||
set(new Date());
|
||||
id = setTimeout(() => {
|
||||
set(new Date());
|
||||
}, 30 * 1000); // refresh rate: 30s
|
||||
};
|
||||
|
||||
createRenderEffect(() => {
|
||||
onCleanup(cancelTimer);
|
||||
if (visible()) {
|
||||
resetTimer();
|
||||
} else {
|
||||
console.debug("createTimeSource: page is invisible, cancel the timer")
|
||||
}
|
||||
});
|
||||
|
||||
return get;
|
||||
|
|
|
@ -1,166 +0,0 @@
|
|||
.Profile {
|
||||
overflow: hidden auto;
|
||||
|
||||
.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;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
margin-top: calc(-1 * var(--scaffold-topbar-height));
|
||||
|
||||
>img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
& a {
|
||||
color: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
word-break: break-all;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.username {
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.acct-mark {
|
||||
font-size: 1.2em;
|
||||
vertical-align: sub;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
& * {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (container-type: inline-size) {
|
||||
@container StackedPage (inline-size >=960px) {
|
||||
.Profile {
|
||||
display: grid;
|
||||
grid-template-columns: auto 560px;
|
||||
grid-template-rows: min-content 1fr;
|
||||
height: 100cqh;
|
||||
|
||||
>.topbar {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1 /2;
|
||||
|
||||
.MuiToolbar-root {
|
||||
padding-right: calc(560px + 24px);
|
||||
}
|
||||
}
|
||||
|
||||
>.details {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
>.intro {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
>.recent-toots {
|
||||
overflow-y: auto;
|
||||
margin-top: calc(-1 * var(--scaffold-topbar-height));
|
||||
z-index: calc(var(--tutu-zidx-nav, 1) + 1);
|
||||
|
||||
>.toot-list-toolbar {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Profile__page-title {
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -1,577 +0,0 @@
|
|||
import {
|
||||
catchError,
|
||||
createResource,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
For,
|
||||
Switch,
|
||||
Match,
|
||||
onCleanup,
|
||||
Show,
|
||||
type Component,
|
||||
createMemo,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemAvatar,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
Close,
|
||||
Edit,
|
||||
ExpandMore,
|
||||
Group,
|
||||
Lock,
|
||||
MoreVert,
|
||||
OpenInBrowser,
|
||||
PersonOff,
|
||||
PlaylistAdd,
|
||||
Send,
|
||||
Share,
|
||||
SmartToySharp,
|
||||
Subject,
|
||||
Verified,
|
||||
} from "@suid/icons-material";
|
||||
import { Body2, Title } from "~material/typography";
|
||||
import { 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 {
|
||||
createTimeline,
|
||||
createTimelineSnapshot,
|
||||
emptyTimeline,
|
||||
} from "../masto/timelines";
|
||||
import TootList from "../timelines/TootList";
|
||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
||||
import TootFilterButton from "./TootFilterButton";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { share } from "~platform/share";
|
||||
import "./Profile.css";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import {
|
||||
createSingluarItemSelection,
|
||||
default as ItemSelectionProvider,
|
||||
} from "../timelines/toots/ItemSelectionProvider";
|
||||
import AppTopBar from "~material/AppTopBar";
|
||||
import type { Account } from "../accounts/stores";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
|
||||
const Profile: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
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 [, selectionState] = createSingluarItemSelection();
|
||||
|
||||
const menuButId = createUniqueId();
|
||||
const recentTootListId = createUniqueId();
|
||||
const optMenuId = createUniqueId();
|
||||
|
||||
const [menuOpen, setMenuOpen] = createSignal(false);
|
||||
|
||||
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
|
||||
|
||||
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 [profileUncaught] = createResource(
|
||||
() => [session().client, params.id] as const,
|
||||
async ([client, id]) => {
|
||||
if (id.startsWith("@")) {
|
||||
return await client.v1.accounts.lookup({ acct: id.slice(1) });
|
||||
}
|
||||
return await client.v1.accounts.$select(id).fetch();
|
||||
},
|
||||
);
|
||||
|
||||
const profile = () => {
|
||||
try {
|
||||
return profileUncaught();
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const profileAcctId = () => {
|
||||
if (params.id.startsWith("@")) {
|
||||
// Webfinger
|
||||
return profile()?.id;
|
||||
} else {
|
||||
return params.id;
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentSessionProfile = () => {
|
||||
return (session().account as Account).inf?.url === profile()?.url;
|
||||
};
|
||||
|
||||
const [recentTootFilter, setRecentTootFilter] = createSignal({
|
||||
pinned: true,
|
||||
boost: false,
|
||||
reply: 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 }] =
|
||||
createTimeline(recentTimeline, () => {
|
||||
const { boost, reply } = recentTootFilter();
|
||||
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
|
||||
});
|
||||
|
||||
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
|
||||
recentTimeline,
|
||||
() => {
|
||||
return { limit: 20, pinned: true };
|
||||
},
|
||||
);
|
||||
|
||||
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
|
||||
() => [session(), profileAcctId()] as const,
|
||||
async ([sess, id]) => {
|
||||
if (!sess.account || !id) return; // No account, no relation
|
||||
const relations = await session().client.v1.accounts.relationships.fetch({
|
||||
id: [id],
|
||||
});
|
||||
return relations.length > 0 ? relations[0] : undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const relationship = () =>
|
||||
catchError(relationshipUncaught, (reason) => {
|
||||
console.error(reason);
|
||||
});
|
||||
|
||||
const bannerImg = () => profile()?.header;
|
||||
const avatarImg = () => profile()?.avatar;
|
||||
const displayName = () =>
|
||||
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
|
||||
const fullUsername = () => (profile()?.acct ? `@${profile()!.acct!}` : ""); // TODO: full user name
|
||||
const description = () => profile()?.note;
|
||||
|
||||
const isTootListLoading = () =>
|
||||
recentTootChunk.loading ||
|
||||
(recentTootFilter().pinned && pinnedTootChunk.loading);
|
||||
|
||||
const sessionDisplayName = createMemo(() =>
|
||||
resolveCustomEmoji(
|
||||
(session().account as Account).inf?.displayName || "",
|
||||
(session().account as Account).inf?.emojis ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
const toggleSubscribeHome = async (event: Event) => {
|
||||
const client = session().client;
|
||||
const acctId = profileAcctId();
|
||||
if (!session().account || !acctId) return;
|
||||
const isSubscribed = relationship()?.following ?? false;
|
||||
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
|
||||
subscribeMenuState.onClose(event);
|
||||
|
||||
if (isSubscribed) {
|
||||
const nrel = await client.v1.accounts.$select(acctId).unfollow();
|
||||
mutateRelationship(nrel);
|
||||
} else {
|
||||
const nrel = await client.v1.accounts.$select(acctId).follow();
|
||||
mutateRelationship(nrel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>{profile()?.displayName ?? "Someone"}</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar
|
||||
role="navigation"
|
||||
position="static"
|
||||
color={scrolledPastBanner() ? "primary" : "transparent"}
|
||||
elevation={scrolledPastBanner() ? undefined : 0}
|
||||
style={{
|
||||
color: scrolledPastBanner()
|
||||
? undefined
|
||||
: bannerSampledColors()?.text,
|
||||
}}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Title
|
||||
class="Profile__page-title"
|
||||
style={{
|
||||
visibility: scrolledPastBanner() ? undefined : "hidden",
|
||||
}}
|
||||
innerHTML={displayName()}
|
||||
></Title>
|
||||
|
||||
<IconButton
|
||||
id={menuButId}
|
||||
aria-controls={optMenuId}
|
||||
color="inherit"
|
||||
onClick={[setMenuOpen, true]}
|
||||
aria-label="Open Options for the Profile"
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</AppTopBar>
|
||||
}
|
||||
class="Profile"
|
||||
>
|
||||
<div class="details" role="presentation">
|
||||
<Menu
|
||||
id={optMenuId}
|
||||
open={menuOpen()}
|
||||
onClose={[setMenuOpen, false]}
|
||||
anchor={() =>
|
||||
document.getElementById(menuButId)!.getBoundingClientRect()
|
||||
}
|
||||
aria-label="Options for the Profile"
|
||||
>
|
||||
<Show when={session().account}>
|
||||
<MenuItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={(session().account as Account).inf?.avatar} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText secondary={"Default account"}>
|
||||
<span innerHTML={sessionDisplayName()}></span>
|
||||
</ListItemText>
|
||||
{/* <ArrowRight /> // for future */}
|
||||
</MenuItem>
|
||||
</Show>
|
||||
<Show when={session().account && profile()}>
|
||||
<Show
|
||||
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>
|
||||
<ListItemIcon>
|
||||
<Edit />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Edit...</ListItemText>
|
||||
</MenuItem>
|
||||
</Show>
|
||||
<Divider />
|
||||
</Show>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Group />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Followers</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<span aria-label="The number of the account follower">
|
||||
{profile()?.followersCount ?? ""}
|
||||
</span>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Subject />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Following</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<span aria-label="The number the account following">
|
||||
{profile()?.followingCount ?? ""}
|
||||
</span>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<PersonOff />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Blocklist</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<Send />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Mention in...</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
component={"a"}
|
||||
href={profile()?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<OpenInBrowser />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Open in browser...</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => share({ url: profile()?.url })}>
|
||||
<ListItemIcon>
|
||||
<Share />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Share...</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<div
|
||||
style={{
|
||||
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
|
||||
}}
|
||||
class="banner"
|
||||
role="presentation"
|
||||
>
|
||||
<img
|
||||
ref={(e) => obx.observe(e)}
|
||||
src={bannerImg()}
|
||||
crossOrigin="anonymous"
|
||||
alt={`Banner image for ${profile()?.displayName || "the user"}`}
|
||||
onLoad={(event) => {
|
||||
const ins = new FastAverageColor();
|
||||
const colors = ins.getColor(event.currentTarget);
|
||||
setBannerSampledColors({
|
||||
average: colors.hex,
|
||||
text: colors.isDark ? "white" : "black",
|
||||
});
|
||||
ins.destroy();
|
||||
}}
|
||||
></img>
|
||||
</div>
|
||||
|
||||
<Menu {...subscribeMenuState}>
|
||||
<MenuItem
|
||||
onClick={toggleSubscribeHome}
|
||||
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
src={(session().account as Account).inf?.avatar}
|
||||
></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
secondary={
|
||||
relationship()?.following
|
||||
? undefined
|
||||
: profile()?.locked
|
||||
? "A request will be sent"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span innerHTML={sessionDisplayName()}></span>
|
||||
<span>'s Home</span>
|
||||
</ListItemText>
|
||||
|
||||
<Checkbox checked={relationship()?.following ?? false} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<div
|
||||
class="intro"
|
||||
style={{
|
||||
"background-color": bannerSampledColors()?.average,
|
||||
color: bannerSampledColors()?.text,
|
||||
}}
|
||||
>
|
||||
<section class="acct-grp">
|
||||
<Avatar
|
||||
src={avatarImg()}
|
||||
alt={`${profile()?.displayName || "the user"}'s avatar`}
|
||||
sx={{
|
||||
marginTop: "calc(-16px - 72px / 2)",
|
||||
width: "72px",
|
||||
height: "72px",
|
||||
}}
|
||||
></Avatar>
|
||||
<div class="name-grp">
|
||||
<div class="display-name">
|
||||
<Show when={profile()?.bot}>
|
||||
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
||||
</Show>
|
||||
<Show when={profile()?.locked}>
|
||||
<Lock class="acct-mark" aria-label="Locked" />
|
||||
</Show>
|
||||
<Body2
|
||||
component="span"
|
||||
innerHTML={displayName()}
|
||||
aria-label="Display name"
|
||||
></Body2>
|
||||
</div>
|
||||
<span aria-label="Complete username" class="username">
|
||||
{fullUsername()}
|
||||
</span>
|
||||
</div>
|
||||
<div role="presentation">
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
!session().account ||
|
||||
profileUncaught.loading ||
|
||||
profileUncaught.error
|
||||
}
|
||||
>
|
||||
{<></>}
|
||||
</Match>
|
||||
<Match when={isCurrentSessionProfile()}>
|
||||
<IconButton color="inherit">
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
openSubscribeMenu(
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{relationship()?.following ? "Subscribed" : "Subscribe"}
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="description"
|
||||
aria-label={`${profile()?.displayName || "the user"}'s description`}
|
||||
innerHTML={description() || ""}
|
||||
></section>
|
||||
|
||||
<table
|
||||
class="acct-fields"
|
||||
aria-label={`${profile()?.displayName || "the user"}'s 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 innerHTML={item.value}></td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-toots" role="presentation">
|
||||
<div class="toot-list-toolbar">
|
||||
<TootFilterButton
|
||||
options={{
|
||||
pinned: "Pinneds",
|
||||
boost: "Boosts",
|
||||
reply: "Replies",
|
||||
original: "Originals",
|
||||
}}
|
||||
applied={recentTootFilter()}
|
||||
onApply={setRecentTootFilter}
|
||||
disabledKeys={["original"]}
|
||||
></TootFilterButton>
|
||||
</div>
|
||||
|
||||
<ItemSelectionProvider value={selectionState}>
|
||||
<TimeSourceProvider value={time}>
|
||||
<Show
|
||||
when={recentTootFilter().pinned && pinnedToots.list.length > 0}
|
||||
>
|
||||
<TootList
|
||||
threads={pinnedToots.list}
|
||||
onUnknownThread={pinnedToots.getPath}
|
||||
onChangeToot={pinnedToots.set}
|
||||
/>
|
||||
<Divider />
|
||||
</Show>
|
||||
<TootList
|
||||
id={recentTootListId}
|
||||
threads={recentToots.list}
|
||||
onUnknownThread={recentToots.getPath}
|
||||
onChangeToot={recentToots.set}
|
||||
/>
|
||||
</TimeSourceProvider>
|
||||
</ItemSelectionProvider>
|
||||
|
||||
<Show when={!recentTootChunk()?.done}>
|
||||
<div
|
||||
style={{
|
||||
"text-align": "center",
|
||||
"padding-bottom": "var(--safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Load More"
|
||||
aria-controls={recentTootListId}
|
||||
size="large"
|
||||
color="primary"
|
||||
onClick={[refetchRecentToots, "prev"]}
|
||||
disabled={isTootListLoading()}
|
||||
>
|
||||
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
|
||||
<CircularProgress sx={{ width: "24px", height: "24px" }} />
|
||||
</Show>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Scaffold>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
|
@ -1,107 +0,0 @@
|
|||
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],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
let anchor: { left: number; top: number; right: number };
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
anchor = {
|
||||
left: event.clientX,
|
||||
right: event.clientX,
|
||||
top: event.clientY,
|
||||
};
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="large" onClick={onClick} id={buttonId}>
|
||||
{appliedKeys().length === optionKeys().length ? (
|
||||
<FilterListOff />
|
||||
) : (
|
||||
<FilterList />
|
||||
)}
|
||||
|
||||
<span style={{ "margin-left": "0.5em" }}>{text()}</span>
|
||||
</Button>
|
||||
<Menu open={open()} onClose={[setOpen, false]} anchor={() => anchor}>
|
||||
<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;
|
|
@ -1,41 +0,0 @@
|
|||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc";
|
||||
|
||||
function isServiceWorker(
|
||||
self: WorkerGlobalScope,
|
||||
// @ts-ignore: workaround for workbox logger.d.ts decl
|
||||
): self is ServiceWorkerGlobalScope {
|
||||
return (
|
||||
(self as unknown as Record<string, unknown>)["serviceWorker"] instanceof
|
||||
ServiceWorker
|
||||
);
|
||||
}
|
||||
|
||||
if (!isServiceWorker(self)) {
|
||||
throw new TypeError("This entry point must be run in a service worker");
|
||||
}
|
||||
|
||||
cleanupOutdatedCaches();
|
||||
precacheAndRoute(self.__WB_MANIFEST, {
|
||||
cleanURLs: false,
|
||||
});
|
||||
|
||||
// auto update
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
export const Service = {
|
||||
ping() {},
|
||||
};
|
||||
|
||||
self.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
const payload = event.data;
|
||||
if (typeof payload !== "object") return;
|
||||
if (isJSONRPCCall(payload as Record<string, unknown>)) {
|
||||
dispatchCall(
|
||||
Service,
|
||||
event as MessageEvent<Call<unknown>> & { source: MessageEventSource },
|
||||
);
|
||||
}
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
export type Service = {
|
||||
ping(): void
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["WebWorker", "ESNext"],
|
||||
},
|
||||
"extends": ["../../tsconfig.super.json"],
|
||||
"include": ["./**/*.ts"],
|
||||
}
|
|
@ -1,223 +0,0 @@
|
|||
export type JSONRPC = {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
export type Call<T = undefined> = JSONRPC & {
|
||||
method: string;
|
||||
params: T;
|
||||
};
|
||||
|
||||
export type RemoteError<E = undefined> = { data: E } & (
|
||||
| {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
code: -32700;
|
||||
message: "Parse Error";
|
||||
}
|
||||
| {
|
||||
code: -32600;
|
||||
message: "Invalid Request";
|
||||
}
|
||||
| {
|
||||
code: -32601;
|
||||
message: "Method not found";
|
||||
}
|
||||
| {
|
||||
code: -32602;
|
||||
message: "Invalid params";
|
||||
}
|
||||
| {
|
||||
code: -32603;
|
||||
message: "Internal error";
|
||||
}
|
||||
);
|
||||
|
||||
export type Result<T, E> = JSONRPC & { id: string | number } & (
|
||||
| {
|
||||
result: T;
|
||||
error: undefined;
|
||||
}
|
||||
| { error: RemoteError<E>; result: undefined }
|
||||
);
|
||||
|
||||
export function isJSONRPCResult(
|
||||
object: Record<string, unknown>,
|
||||
): object is Result<unknown, unknown> {
|
||||
return !!(object["jsonrpc"] === "2.0" && object["id"] && !object["method"]);
|
||||
}
|
||||
|
||||
export function isJSONRPCCall(
|
||||
object: Record<string, unknown>,
|
||||
): object is Call<unknown> {
|
||||
return object["jsonrpc"] === "2.0" && !!object["method"];
|
||||
}
|
||||
|
||||
export class ResultDispatcher {
|
||||
private map: Map<
|
||||
number | string,
|
||||
((value: Result<unknown, unknown>) => void) | true // `true` = a call is generated, but the promise not created
|
||||
>;
|
||||
private nextId: number = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
private rollId() {
|
||||
let id = 0;
|
||||
while (this.map.get((id = this.nextId++))) {
|
||||
if (this.nextId >= Number.MAX_SAFE_INTEGER) {
|
||||
this.nextId = Number.MIN_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createCall<T>(
|
||||
method: string,
|
||||
params: T,
|
||||
): [Promise<Call<T>>, Promise<Result<unknown, unknown>>] {
|
||||
const id = this.rollId();
|
||||
const p = new Promise<Result<unknown, unknown>>((resolve) =>
|
||||
this.map.set(id, resolve),
|
||||
);
|
||||
this.map.set(id, true);
|
||||
const call: Call<T> = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return [
|
||||
new Promise((resolve) => {
|
||||
const waitUntilTheIdSet = () => {
|
||||
// We must do this check to make sure the id is set before the call made.
|
||||
// or the dispatching may lost the callback
|
||||
if (this.map.get(id)) {
|
||||
resolve(call);
|
||||
} else {
|
||||
setTimeout(waitUntilTheIdSet, 0);
|
||||
}
|
||||
};
|
||||
|
||||
waitUntilTheIdSet();
|
||||
}),
|
||||
p,
|
||||
];
|
||||
}
|
||||
|
||||
dispatch(id: string | number, message: Result<unknown, unknown>) {
|
||||
{
|
||||
const callback = this.map.get(id);
|
||||
if (!callback) return;
|
||||
// Now the callback is not undefined
|
||||
|
||||
// Fast path
|
||||
if (typeof callback !== "boolean") {
|
||||
callback(message);
|
||||
this.map.delete(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>();
|
||||
|
||||
let retried = 0;
|
||||
|
||||
const checkAndDispatch = () => {
|
||||
const callback = this.map.get(id);
|
||||
if (typeof callback !== "boolean") {
|
||||
callback!(message); // the nullability is already checked before
|
||||
this.map.delete(id);
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
setTimeout(checkAndDispatch, 0);
|
||||
if (++retried > 3) {
|
||||
console.warn(
|
||||
`retried ${retried} time(s) but the callback is still disappeared, id is "${id}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// start the loop
|
||||
checkAndDispatch();
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
createTypedCall<
|
||||
S extends AnyService,
|
||||
E = unknown,
|
||||
K extends keyof S = keyof S,
|
||||
P extends Parameters<S[K]> = Parameters<S[K]>,
|
||||
R extends ReturnType<S[K]> = ReturnType<S[K]>,
|
||||
>(method: K, ...params: P): [Promise<Call<P>>, Promise<Result<R, E>>] {
|
||||
return this.createCall(method as string, params) as [
|
||||
Promise<Call<P>>,
|
||||
Promise<Result<R, E>>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
type AnyService = Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
export async function dispatchCall<S extends Partial<AnyService>>(
|
||||
service: S,
|
||||
event: MessageEvent<Call<unknown>> & { source: MessageEventSource },
|
||||
) {
|
||||
try {
|
||||
const fn = service[event.data.method];
|
||||
if (!fn) {
|
||||
console.warn("requested unknown method", event.data.method, event.data);
|
||||
if (event.data.id)
|
||||
return event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: -30601,
|
||||
message: "Method not found",
|
||||
},
|
||||
} as Result<void, void>);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn(...(event.data.params as unknown[]));
|
||||
|
||||
if (!event.data.id) return;
|
||||
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
result: result,
|
||||
} as Result<unknown, void>);
|
||||
} catch (reason) {
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: 0,
|
||||
message: String(reason),
|
||||
data: reason,
|
||||
},
|
||||
} as Result<unknown, unknown>);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (event.data.id)
|
||||
event.source.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: event.data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "Internal error",
|
||||
data: reason,
|
||||
},
|
||||
} as Result<void, unknown>);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -19,19 +19,17 @@ import {
|
|||
autoMatchLangTag,
|
||||
createTranslator,
|
||||
SUPPORTED_LANGS,
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import AppTopBar from "~material/AppTopBar";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
|
||||
const ChooseLang: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
const navigate = useNavigate()
|
||||
const [t] = createTranslator(
|
||||
() => import("./i18n/generic.json"),
|
||||
() => import("./i18n/lang-names.json"),
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Record<string, string | undefined> & {
|
||||
|
@ -39,9 +37,9 @@ const ChooseLang: Component = () => {
|
|||
};
|
||||
}>,
|
||||
);
|
||||
const settings = useStore($settings);
|
||||
const settings = useStore($settings)
|
||||
|
||||
const code = () => settings().language;
|
||||
const code = () => settings().language
|
||||
|
||||
const unsupportedLangCodes = createMemo(() => {
|
||||
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
|
||||
|
@ -50,74 +48,73 @@ const ChooseLang: Component = () => {
|
|||
const matchedLangCode = createMemo(() => autoMatchLangTag());
|
||||
|
||||
const onCodeChange = (code?: string) => {
|
||||
$settings.setKey("language", code);
|
||||
};
|
||||
$settings.setKey("language", code)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>{t("Choose Language")}</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppBar position="static">
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("Choose Language")}</Title>
|
||||
</AppTopBar>
|
||||
}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "var(--safe-area-inset-bottom, 0)",
|
||||
}}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "var(--safe-area-inset-bottom, 0)",
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onCodeChange(code() ? undefined : matchedLangCode());
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onCodeChange(code() ? undefined : matchedLangCode());
|
||||
}}
|
||||
>
|
||||
<ListItemText>
|
||||
{t("lang.auto", {
|
||||
detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(),
|
||||
})}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={typeof code() === "undefined"} />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
|
||||
<For each={SUPPORTED_LANGS}>
|
||||
{(c) => (
|
||||
<ListItemButton
|
||||
disabled={typeof code() === "undefined"}
|
||||
onClick={[onCodeChange, c]}
|
||||
>
|
||||
<ListItemText>{t(`lang.${c}`)}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Radio
|
||||
checked={
|
||||
code() === c ||
|
||||
(code() === undefined && matchedLangCode() == c)
|
||||
}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
|
||||
<List subheader={<ListSubheader>{t("Unsupported")}</ListSubheader>}>
|
||||
<For each={unsupportedLangCodes()}>
|
||||
{(code) => (
|
||||
<ListItem>
|
||||
<ListItemText>{iso639_1.getNativeName(code)}</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
<ListItemText>
|
||||
{t("lang.auto", {
|
||||
detected: t(`lang.${matchedLangCode()}`) ?? matchedLangCode(),
|
||||
})}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={typeof code() === "undefined"} />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
|
||||
<For each={SUPPORTED_LANGS}>
|
||||
{(c) => (
|
||||
<ListItemButton
|
||||
disabled={typeof code() === "undefined"}
|
||||
onClick={[onCodeChange, c]}
|
||||
>
|
||||
<ListItemText>{t(`lang.${c}`)}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Radio
|
||||
checked={code() === c || (code() === undefined && matchedLangCode() == c)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</Scaffold>
|
||||
</>
|
||||
|
||||
<List subheader={<ListSubheader>{t("Unsupported")}</ListSubheader>}>
|
||||
<For each={unsupportedLangCodes()}>
|
||||
{(code) => (
|
||||
<ListItem>
|
||||
<ListItemText>{iso639_1.getNativeName(code)}</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</List>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import type { Component } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Divider,
|
||||
|
@ -12,17 +12,15 @@ import {
|
|||
Switch,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { Title } from "~material/typography";
|
||||
import { Title } from "../material/typography";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { ArrowBack } from "@suid/icons-material";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { createTranslator } from "../platform/i18n";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import AppTopBar from "~material/AppTopBar";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
|
||||
const Motions: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const [t] = createTranslator(
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
|
@ -31,58 +29,60 @@ const Motions: Component = () => {
|
|||
);
|
||||
const settings = useStore($settings);
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>{t("motions")}</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppBar position="static">
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("motions")}</Title>
|
||||
</AppTopBar>
|
||||
}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "calc(var(--safe-area-inset-bottom, 0px) + 16px)",
|
||||
}}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "calc(var(--safe-area-inset-bottom, 0px) + 16px)",
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
<ul style={{ "padding-left": 0 }}>
|
||||
<ListSubheader>{t("motions.gifs")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
$settings.setKey("autoPlayGIFs", !settings().autoPlayGIFs)
|
||||
}
|
||||
>
|
||||
<ListItemText>{t("motions.gifs.autoplay")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={settings().autoPlayGIFs}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<ul style={{ "padding-left": 0 }}>
|
||||
<ListSubheader>{t("motions.vids")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
$settings.setKey("autoPlayVideos", !settings().autoPlayVideos)
|
||||
}
|
||||
>
|
||||
<ListItemText>{t("motions.vids.autoplay")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={settings().autoPlayVideos}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
</li>
|
||||
</List>
|
||||
</Scaffold>
|
||||
</>
|
||||
<li>
|
||||
<ul style={{ "padding-left": 0 }}>
|
||||
<ListSubheader>{t("motions.gifs")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
$settings.setKey("autoPlayGIFs", !settings().autoPlayGIFs)
|
||||
}
|
||||
>
|
||||
<ListItemText>{t("motions.gifs.autoplay")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={settings().autoPlayGIFs}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<ul style={{ "padding-left": 0 }}>
|
||||
<ListSubheader>{t("motions.vids")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
$settings.setKey("autoPlayVideos", !settings().autoPlayVideos)
|
||||
}
|
||||
>
|
||||
<ListItemText>{t("motions.vids.autoplay")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={settings().autoPlayVideos}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
</li>
|
||||
</List>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -17,19 +17,17 @@ import {
|
|||
autoMatchRegion,
|
||||
createTranslator,
|
||||
SUPPORTED_REGIONS,
|
||||
} from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
} from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
import type { Template } from "@solid-primitives/i18n";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { $settings } from "./stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { useNavigator } from "~platform/StackedRouter";
|
||||
import AppTopBar from "~material/AppTopBar";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
|
||||
const ChooseRegion: Component = () => {
|
||||
const { pop } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const [t] = createTranslator(
|
||||
() => import("./i18n/generic.json"),
|
||||
() => import("./i18n/lang-names.json"),
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Record<string, string | undefined> & {
|
||||
|
@ -49,62 +47,64 @@ const ChooseRegion: Component = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>{t("Choose Region")}</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppBar position="static">
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Title>{t("Choose Region")}</Title>
|
||||
</AppTopBar>
|
||||
}
|
||||
<Title>{t("Choose Language")}</Title>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "var(--safe-area-inset-bottom, 0)",
|
||||
}}
|
||||
>
|
||||
<List
|
||||
sx={{
|
||||
paddingBottom: "var(--safe-area-inset-bottom, 0)",
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onCodeChange(region() ? undefined : matchedRegionCode());
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
onCodeChange(region() ? undefined : matchedRegionCode());
|
||||
}}
|
||||
>
|
||||
<ListItemText>
|
||||
{t("region.auto", {
|
||||
detected:
|
||||
t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(),
|
||||
})}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={typeof region() === "undefined"} />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<ListItemText>
|
||||
{t("region.auto", {
|
||||
detected:
|
||||
t(`region.${matchedRegionCode()}`) ?? matchedRegionCode(),
|
||||
})}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={typeof region() === "undefined"} />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
|
||||
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
|
||||
<For each={SUPPORTED_REGIONS}>
|
||||
{(code) => (
|
||||
<ListItemButton
|
||||
disabled={typeof region() === "undefined"}
|
||||
onClick={[onCodeChange, code]}
|
||||
>
|
||||
<ListItemText>{t(`region.${code}`)}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Radio
|
||||
checked={
|
||||
region() === code ||
|
||||
(region() === undefined && matchedRegionCode() == code)
|
||||
}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
<List subheader={<ListSubheader>{t("Supported")}</ListSubheader>}>
|
||||
<For each={SUPPORTED_REGIONS}>
|
||||
{(code) => (
|
||||
<ListItemButton
|
||||
disabled={typeof region() === "undefined"}
|
||||
onClick={[onCodeChange, code]}
|
||||
>
|
||||
<ListItemText>{t(`region.${code}`)}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Radio
|
||||
checked={
|
||||
region() === code ||
|
||||
(region() === undefined && matchedRegionCode() == code)
|
||||
}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</Scaffold>
|
||||
</>
|
||||
</List>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import { For, Show, type Component } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold.js";
|
||||
import {
|
||||
children,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold.js";
|
||||
import {
|
||||
AppBar,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
|
@ -10,181 +17,62 @@ import {
|
|||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
NativeSelect,
|
||||
Switch,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import {
|
||||
Animation as AnimationIcon,
|
||||
Close as CloseIcon,
|
||||
DeleteForever,
|
||||
Logout,
|
||||
Public as PublicIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Translate as TranslateIcon,
|
||||
} from "@suid/icons-material";
|
||||
import A from "~platform/A.js";
|
||||
import { Title } from "~material/typography.js";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { Title } from "../material/typography.jsx";
|
||||
import { css } from "solid-styled";
|
||||
import { useSignedInProfiles } from "../masto/acct.js";
|
||||
import { signOut, type Account } from "../accounts/stores.js";
|
||||
import { format } from "date-fns";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "./stores.js";
|
||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||
import {
|
||||
autoMatchLangTag,
|
||||
autoMatchRegion,
|
||||
createTranslator,
|
||||
useDateFnLocale,
|
||||
} from "~platform/i18n.jsx";
|
||||
} from "../platform/i18n.jsx";
|
||||
import { type Template } from "@solid-primitives/i18n";
|
||||
import { useServiceWorker } from "~platform/host.js";
|
||||
import { makeAcctText, useSessions } from "../masto/clients.js";
|
||||
import { useNavigator } from "~platform/StackedRouter.jsx";
|
||||
import AppTopBar from "~material/AppTopBar.jsx";
|
||||
import MastodonLogo from "./MastodonLogo.jsx";
|
||||
import DocumentTitle from "~platform/DocumentTitle.jsx";
|
||||
|
||||
type Inset = {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
|
||||
type SafeAreaInsets = {
|
||||
landscape: Inset;
|
||||
protrait: Inset;
|
||||
};
|
||||
|
||||
const safeAreaInsets: Record<string, SafeAreaInsets> = {
|
||||
iphone15: {
|
||||
protrait: {
|
||||
top: 59,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 59,
|
||||
right: 59,
|
||||
},
|
||||
},
|
||||
iphone12: {
|
||||
protrait: {
|
||||
top: 47,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 47,
|
||||
right: 47,
|
||||
},
|
||||
},
|
||||
iphone13mini: {
|
||||
protrait: {
|
||||
top: 50,
|
||||
bottom: 34,
|
||||
},
|
||||
landscape: {
|
||||
bottom: 21,
|
||||
left: 50,
|
||||
right: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let screenOrientationCallback: (() => void) | undefined;
|
||||
|
||||
function removeSafeAreaEmulation(root: HTMLElement) {
|
||||
for (const name of ["top", "right", "bottom", "left"]) {
|
||||
root.style.removeProperty(`--safe-area-inset-${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applySafeAreaEmulation(root: HTMLElement, insets: Inset) {
|
||||
removeSafeAreaEmulation(root);
|
||||
for (const key of Object.keys(insets) as (keyof Inset)[]) {
|
||||
const value = insets[key];
|
||||
if (!value || value === 0) continue;
|
||||
root.style.setProperty(`--safe-area-inset-${key}`, `${value}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function setupSafeAreaEmulation(name: string) {
|
||||
const insets = safeAreaInsets[name];
|
||||
const root = document.querySelector(":root")! as HTMLElement;
|
||||
|
||||
if (screenOrientationCallback) {
|
||||
window.screen.orientation.removeEventListener(
|
||||
"change",
|
||||
screenOrientationCallback,
|
||||
);
|
||||
screenOrientationCallback = undefined;
|
||||
}
|
||||
|
||||
removeSafeAreaEmulation(root);
|
||||
|
||||
if (insets) {
|
||||
screenOrientationCallback = () => {
|
||||
console.debug(
|
||||
`safe area emulation target: ${window.screen.orientation.type}`,
|
||||
);
|
||||
if (window.screen.orientation.type === "portrait-primary") {
|
||||
console.debug("safe area emulation: protrait");
|
||||
applySafeAreaEmulation(root, insets.protrait);
|
||||
} else if (window.screen.orientation.type === "landscape-primary") {
|
||||
console.debug("safe area emulation: landscape");
|
||||
applySafeAreaEmulation(root, insets.landscape);
|
||||
}
|
||||
};
|
||||
window.screen.orientation.addEventListener(
|
||||
"change",
|
||||
screenOrientationCallback,
|
||||
);
|
||||
screenOrientationCallback();
|
||||
}
|
||||
}
|
||||
|
||||
const $$SAFE_AREA_EMU = "$$SAFE_AREA_EMU";
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("vite:beforeUpdate", () => {
|
||||
import.meta.hot!.data[$$SAFE_AREA_EMU] = screenOrientationCallback;
|
||||
});
|
||||
|
||||
import.meta.hot.on("vite:afterUpdate", () => {
|
||||
screenOrientationCallback = import.meta.hot?.data?.[$$SAFE_AREA_EMU];
|
||||
if (screenOrientationCallback) {
|
||||
setTimeout(screenOrientationCallback, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
import BottomSheet from "../material/BottomSheet.jsx";
|
||||
|
||||
type Strings = {
|
||||
["lang.auto"]: Template<{ detected: string }>;
|
||||
} & Record<string, string | undefined>;
|
||||
|
||||
const Settings: Component = () => {
|
||||
const Settings: ParentComponent = (props) => {
|
||||
const [t] = createTranslator(
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
default: Strings;
|
||||
}>,
|
||||
() => import(`./i18n/generic.json`),
|
||||
() => import(`./i18n/lang-names.json`),
|
||||
);
|
||||
const { pop, push } = useNavigator();
|
||||
const navigate = useNavigate();
|
||||
const settings$ = useStore($settings);
|
||||
const { needRefresh } = useServiceWorker();
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
} = useRegisterSW();
|
||||
const dateFnLocale = useDateFnLocale();
|
||||
|
||||
const profiles = useSessions();
|
||||
const [profiles] = useSignedInProfiles();
|
||||
|
||||
const doSignOut = (acct: Account) => {
|
||||
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
|
||||
|
||||
if (profiles().length == 0) {
|
||||
push("/accounts/sign-in", { replace: "all" });
|
||||
}
|
||||
};
|
||||
|
||||
const subpage = children(() => props.children);
|
||||
|
||||
css`
|
||||
ul {
|
||||
padding: 0;
|
||||
|
@ -192,234 +80,165 @@ const Settings: Component = () => {
|
|||
|
||||
.setting-list {
|
||||
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
|
||||
overflow: hidden auto;
|
||||
height: calc(100vh - var(--scaffold-topbar-height, 0));
|
||||
height: calc(100dvh - var(--scaffold-topbar-height, 0));
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>{t("Settings")}</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar>
|
||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppBar position="static">
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Title>{t("Settings")}</Title>
|
||||
</AppTopBar>
|
||||
}
|
||||
>
|
||||
<List class="setting-list" use:solid-styled>
|
||||
<li>
|
||||
<ul>
|
||||
<ListSubheader>{t("Accounts")}</ListSubheader>
|
||||
<ListItemButton disabled>
|
||||
<ListItemText>{t("All Notifications")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch value={false} disabled />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton disabled>
|
||||
<ListItemText>{t("Sign in...")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
<For each={profiles()}>
|
||||
{({ account: acct }) => (
|
||||
<ul data-site={acct.site} data-username={acct.inf?.username}>
|
||||
<ListSubheader>{`@${acct.inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
|
||||
<ListItemButton disabled>
|
||||
<ListItemText>{t("Notifications")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch value={false} disabled />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton onClick={[doSignOut, acct]}>
|
||||
<ListItemIcon>
|
||||
<Logout />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("Sign out")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
)}
|
||||
</For>
|
||||
</li>
|
||||
<li>
|
||||
<ListSubheader>{t("timelines")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
"prefetchTootsDisabled",
|
||||
!settings$().prefetchTootsDisabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemText secondary={t("Prefetch Toots.2nd")}>
|
||||
{t("Prefetch Toots")}
|
||||
</ListItemText>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
>
|
||||
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
|
||||
{subpage()}
|
||||
</BottomSheet>
|
||||
|
||||
<List class="setting-list" use:solid-styled>
|
||||
<li>
|
||||
<ul>
|
||||
<ListSubheader>{t("Accounts")}</ListSubheader>
|
||||
<ListItemButton disabled>
|
||||
<ListItemText>{t("All Notifications")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={!settings$().prefetchTootsDisabled} />
|
||||
<Switch value={false} disabled />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton component={A} href="./motions">
|
||||
<ListItemIcon>
|
||||
<AnimationIcon></AnimationIcon>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("motions")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</li>
|
||||
<li>
|
||||
<ListSubheader>{t("storage")}</ListSubheader>
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<DeleteForever />
|
||||
</ListItemIcon>
|
||||
<ListItemText secondary={t("storage.cache.UA-managed")}>
|
||||
{t("storage.cache.clear")}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
</li>
|
||||
<li>
|
||||
<ListSubheader>{t("This Application")}</ListSubheader>
|
||||
<ListItemButton component={A} href="./language">
|
||||
<ListItemIcon>
|
||||
<TranslateIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
secondary={
|
||||
settings$().language === undefined
|
||||
? t("lang.auto", {
|
||||
detected:
|
||||
t("lang." + autoMatchLangTag()) ?? autoMatchLangTag(),
|
||||
})
|
||||
: t("lang." + settings$().language)
|
||||
}
|
||||
>
|
||||
{t("Language")}
|
||||
</ListItemText>
|
||||
<ListItemText>{t("Sign in...")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton component={A} href="./region">
|
||||
<ListItemIcon>
|
||||
<PublicIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
secondary={
|
||||
settings$().region === undefined
|
||||
? t("region.auto", {
|
||||
detected:
|
||||
t("region." + autoMatchRegion()) ?? autoMatchRegion(),
|
||||
})
|
||||
: t("region." + settings$().region)
|
||||
}
|
||||
>
|
||||
{t("Region")}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
component={A}
|
||||
aria-label={t("mastodonlink.open")}
|
||||
href={`/${encodeURIComponent(profiles().length > 0 ? makeAcctText(profiles()[0]) : "@")}/profile/@tutu@indieweb.social`}
|
||||
>
|
||||
<MastodonLogo />
|
||||
</IconButton>
|
||||
</ul>
|
||||
<For each={profiles()}>
|
||||
{({ account: acct, inf }) => (
|
||||
<ul data-site={acct.site} data-username={inf?.username}>
|
||||
<ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
|
||||
<ListItemButton disabled>
|
||||
<ListItemText>{t("Notifications")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch value={false} disabled />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton onClick={[doSignOut, acct]}>
|
||||
<ListItemIcon>
|
||||
<Logout />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("Sign out")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</ul>
|
||||
)}
|
||||
</For>
|
||||
</li>
|
||||
<li>
|
||||
<ListSubheader>{t("timelines")}</ListSubheader>
|
||||
<ListItemButton
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
"prefetchTootsDisabled",
|
||||
!settings$().prefetchTootsDisabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemText secondary={t("Prefetch Toots.2nd")}>
|
||||
{t("Prefetch Toots")}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={!settings$().prefetchTootsDisabled} />
|
||||
</ListItemSecondaryAction>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton component={A} href="./motions">
|
||||
<ListItemIcon>
|
||||
<AnimationIcon></AnimationIcon>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("motions")}</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
</li>
|
||||
<li>
|
||||
<ListSubheader>{t("This Application")}</ListSubheader>
|
||||
<ListItemButton component={A} href="./language">
|
||||
<ListItemIcon>
|
||||
<TranslateIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
secondary={
|
||||
settings$().language === undefined
|
||||
? t("lang.auto", {
|
||||
detected:
|
||||
t("lang." + autoMatchLangTag()) ?? autoMatchLangTag(),
|
||||
})
|
||||
: t("lang." + settings$().language)
|
||||
}
|
||||
>
|
||||
<ListItemText secondary={t("About Tutu.2nd")}>
|
||||
{t("About Tutu")}
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
secondary={t("version", {
|
||||
packageVersion: import.meta.env.PACKAGE_VERSION,
|
||||
builtAt: format(
|
||||
import.meta.env.BUILT_AT,
|
||||
t("datefmt") || "yyyy/MM/dd",
|
||||
{ locale: dateFnLocale() },
|
||||
),
|
||||
buildMode: import.meta.env.MODE,
|
||||
})}
|
||||
>
|
||||
{needRefresh() ? t("updates.ready") : t("updates.no")}
|
||||
</ListItemText>
|
||||
<Show when={needRefresh()}>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
aria-label="Restart Now"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</Show>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
{import.meta.env.VITE_CODE_VERSION ? (
|
||||
<>
|
||||
<ListItem>
|
||||
<ListItemText secondary={import.meta.env.VITE_CODE_VERSION}>
|
||||
{t("version.code")}
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</li>
|
||||
{import.meta.env.DEV ? (
|
||||
<li>
|
||||
<ListSubheader>Developer Tools</ListSubheader>
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
window.screen?.orientation ? (
|
||||
<NativeSelect
|
||||
sx={{ maxWidth: "40vw" }}
|
||||
onChange={(event) => {
|
||||
const k = event.currentTarget.value;
|
||||
setupSafeAreaEmulation(k);
|
||||
}}
|
||||
>
|
||||
<option>Don't change</option>
|
||||
<option value={"ua"}>User agent</option>
|
||||
<option value={"iphone15"}>
|
||||
iPhone 15 and Plus, Pro, Pro Max
|
||||
</option>
|
||||
<option value={"iphone12"}>iPhone 12, 13 and 14</option>
|
||||
<option value={"iphone13mini"}>iPhone 13 mini</option>
|
||||
</NativeSelect>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
secondary={
|
||||
window.screen?.orientation
|
||||
? undefined
|
||||
: "Unsupported on This Platform"
|
||||
}
|
||||
{t("Language")}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItemButton component={A} href="./region">
|
||||
<ListItemIcon>
|
||||
<PublicIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
secondary={
|
||||
settings$().region === undefined
|
||||
? t("region.auto", {
|
||||
detected:
|
||||
t("region." + autoMatchRegion()) ?? autoMatchRegion(),
|
||||
})
|
||||
: t("region." + settings$().region)
|
||||
}
|
||||
>
|
||||
{t("Region")}
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText secondary={t("About Tutu.2nd")}>
|
||||
{t("About Tutu")}
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
secondary={t("version", {
|
||||
packageVersion: import.meta.env.PACKAGE_VERSION,
|
||||
builtAt: format(
|
||||
import.meta.env.BUILT_AT,
|
||||
t("datefmt") || "yyyy/MM/dd",
|
||||
{ locale: dateFnLocale() },
|
||||
),
|
||||
buildMode: import.meta.env.MODE,
|
||||
})}
|
||||
>
|
||||
{needRefresh() ? t("updates.ready") : t("updates.no")}
|
||||
</ListItemText>
|
||||
<Show when={needRefresh()}>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
aria-label="Restart Now"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Safe Area Insets
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</List>
|
||||
</Scaffold>
|
||||
</>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</Show>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</li>
|
||||
</List>
|
||||
</Scaffold>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
"updates.ready": "An update is ready, restart the Tutu to apply",
|
||||
"updates.no": "No updates",
|
||||
"version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})",
|
||||
"version.code": "Code Version",
|
||||
"Language": "Language",
|
||||
"Region": "Region",
|
||||
"lang.auto": "(Auto) {{detected}}",
|
||||
|
@ -33,11 +32,5 @@
|
|||
"motions.gifs": "GIFs",
|
||||
"motions.gifs.autoplay": "Auto-play GIFs",
|
||||
"motions.vids": "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"
|
||||
"motions.vids.autoplay": "Auto-play Videos"
|
||||
}
|
|
@ -12,7 +12,6 @@
|
|||
"updates.ready": "更新已准备好,下次开启会启动新版本",
|
||||
"updates.no": "已是最新版本",
|
||||
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
|
||||
"version.code": "代码版本",
|
||||
"Language": "语言",
|
||||
"Region": "区域",
|
||||
"lang.auto": "(自动){{detected}}",
|
||||
|
@ -33,11 +32,5 @@
|
|||
"motions.gifs": "动图",
|
||||
"motions.gifs.autoplay": "自动播放动图",
|
||||
"motions.vids": "视频",
|
||||
"motions.vids.autoplay": "自动播放视频",
|
||||
|
||||
"storage": "存储空间",
|
||||
"storage.cache.clear": "清除缓存",
|
||||
"storage.cache.UA-managed": "缓存由你的浏览器管理。",
|
||||
|
||||
"mastodonlink.open": "打开图图的Mastodon账户"
|
||||
"motions.vids.autoplay": "自动播放视频"
|
||||
}
|
|
@ -1,5 +1,10 @@
|
|||
import { For, onMount, type Component, type JSX } from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import {
|
||||
For,
|
||||
onMount,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
IconButton,
|
||||
|
@ -12,9 +17,8 @@ import {
|
|||
} from "@suid/material";
|
||||
import { Close as CloseIcon } from "@suid/icons-material";
|
||||
import iso639_1 from "iso-639-1";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { Title } from "~material/typography";
|
||||
import "./TootLangPicker.css";
|
||||
import { createTranslator } from "../platform/i18n";
|
||||
import { Title } from "../material/typography";
|
||||
|
||||
type ChooseTootLangProps = {
|
||||
code: string;
|
||||
|
@ -23,7 +27,7 @@ type ChooseTootLangProps = {
|
|||
};
|
||||
|
||||
const ChooseTootLang: Component<ChooseTootLangProps> = (props) => {
|
||||
let listRef!: HTMLUListElement;
|
||||
let listRef: HTMLUListElement;
|
||||
const [t] = createTranslator(
|
||||
(code) =>
|
||||
import(`./i18n/${code}.json`) as Promise<{
|
||||
|
@ -54,7 +58,6 @@ const ChooseTootLang: Component<ChooseTootLangProps> = (props) => {
|
|||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
class="TootLangPicker"
|
||||
>
|
||||
<List
|
||||
ref={listRef!}
|
57
src/timelines/CompactToot.tsx
Normal file
57
src/timelines/CompactToot.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import type { mastodon } from "masto";
|
||||
import { Show, type Component } from "solid-js";
|
||||
import tootStyle from "./toot.module.css";
|
||||
import { formatRelative } from "date-fns";
|
||||
import Img from "../material/Img";
|
||||
import { Body2 } from "../material/typography";
|
||||
import { appliedCustomEmoji } from "../masto/toot";
|
||||
import { TootPreviewCard } from "./RegularToot";
|
||||
|
||||
type CompactTootProps = {
|
||||
status: mastodon.v1.Status;
|
||||
now: Date;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const CompactToot: Component<CompactTootProps> = (props) => {
|
||||
const toot = () => props.status;
|
||||
return (
|
||||
<section
|
||||
class={[tootStyle.toot, tootStyle.compact, props.class || ""].join(" ")}
|
||||
lang={toot().language || undefined}
|
||||
>
|
||||
<Img
|
||||
src={toot().account.avatar}
|
||||
class={[tootStyle.tootAvatar].join(" ")}
|
||||
/>
|
||||
<div class={[tootStyle.compactAuthorGroup].join(" ")}>
|
||||
<Body2
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
appliedCustomEmoji(
|
||||
e,
|
||||
toot().account.displayName,
|
||||
toot().account.emojis,
|
||||
);
|
||||
}}
|
||||
></Body2>
|
||||
<span class={tootStyle.compactAuthorUsername}>
|
||||
@{toot().account.username}@{new URL(toot().account.url).hostname}
|
||||
</span>
|
||||
<time datetime={toot().createdAt}>
|
||||
{formatRelative(props.now, toot().createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<div
|
||||
ref={(e: { innerHTML: string }) => {
|
||||
appliedCustomEmoji(e, toot().content, toot().emojis);
|
||||
}}
|
||||
class={[tootStyle.compactTootContent].join(" ")}
|
||||
></div>
|
||||
<Show when={toot().card}>
|
||||
<TootPreviewCard src={toot().card!} alwaysCompact />
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactToot;
|
|
@ -1,71 +1,76 @@
|
|||
import {
|
||||
createSignal,
|
||||
Show,
|
||||
onMount,
|
||||
type ParentComponent,
|
||||
createEffect,
|
||||
useTransition,
|
||||
children,
|
||||
Suspense,
|
||||
} from "solid-js";
|
||||
import Scaffold from "~material/Scaffold";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { type mastodon } from "masto";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Switch,
|
||||
Toolbar,
|
||||
} from "@suid/material";
|
||||
import { css } from "solid-styled";
|
||||
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
|
||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
||||
import ProfileMenuButton from "./ProfileMenuButton";
|
||||
import Tabs from "~material/Tabs";
|
||||
import Tab from "~material/Tab";
|
||||
import Tabs from "../material/Tabs";
|
||||
import Tab from "../material/Tab";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import BottomSheet, {
|
||||
HERO as BOTTOM_SHEET_HERO,
|
||||
} from "../material/BottomSheet";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useSignedInProfiles } from "../masto/acct";
|
||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
||||
import TrendTimelinePanel from "./TrendTimelinePanel";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import { useSessions } from "../masto/clients";
|
||||
import {
|
||||
createSingluarItemSelection,
|
||||
default as ItemSelectionProvider,
|
||||
} from "./toots/ItemSelectionProvider";
|
||||
import AppTopBar from "~material/AppTopBar";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import DocumentTitle from "~platform/DocumentTitle";
|
||||
|
||||
type StringRes = Record<
|
||||
"tabs.home" | "tabs.trending" | "tabs.public" | "set.prefetch-toots",
|
||||
string
|
||||
>;
|
||||
|
||||
const Home: ParentComponent = (props) => {
|
||||
let panelList: HTMLDivElement;
|
||||
const [t, [stringRes]] = createTranslator(
|
||||
(code) => import(`./i18n/${code}.json`) as Promise<{ default: StringRes }>,
|
||||
);
|
||||
useDocumentTitle("Timelines");
|
||||
const now = createTimeSource();
|
||||
const [, selectionState] = createSingluarItemSelection(
|
||||
undefined as string | undefined,
|
||||
);
|
||||
|
||||
const settings$ = useStore($settings);
|
||||
|
||||
const profiles = useSessions();
|
||||
const [profiles] = useSignedInProfiles();
|
||||
const profile = () => {
|
||||
const all = profiles();
|
||||
if (all.length > 0) {
|
||||
return all[0].inf;
|
||||
}
|
||||
};
|
||||
const client = () => {
|
||||
const all = profiles();
|
||||
return all?.[0]?.client;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
|
||||
const [panelOffset, setPanelOffset] = createSignal(0);
|
||||
const prefetching = () => !settings$().prefetchTootsDisabled;
|
||||
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
|
||||
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
|
||||
number,
|
||||
number,
|
||||
]);
|
||||
|
||||
const child = children(() => props.children);
|
||||
|
||||
let scrollEventLockReleased = true;
|
||||
|
||||
const recalculateTabIndicator = () => {
|
||||
scrollEventLockReleased = false;
|
||||
try {
|
||||
if (!panelList!) return;
|
||||
const { x: panelX, width: panelWidth } =
|
||||
panelList.getBoundingClientRect();
|
||||
let minIdx = +Infinity,
|
||||
|
@ -97,23 +102,18 @@ const Home: ParentComponent = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const requestRecalculateTabIndicator = () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
};
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
createEffect((last) => {
|
||||
const { width } = windowSize;
|
||||
if (last !== width) {
|
||||
requestRecalculateTabIndicator();
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
requestRecalculateTabIndicator();
|
||||
onMount(() => {
|
||||
makeEventListener(panelList, "scroll", () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
});
|
||||
makeEventListener(window, "resize", () => {
|
||||
if (scrollEventLockReleased) {
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(recalculateTabIndicator);
|
||||
});
|
||||
|
||||
const isTabFocus = (idx: number) => {
|
||||
|
@ -123,7 +123,7 @@ const Home: ParentComponent = (props) => {
|
|||
};
|
||||
|
||||
const onTabClick = (idx: number) => {
|
||||
const items = panelList!.querySelectorAll(".tab-panel");
|
||||
const items = panelList.querySelectorAll(".tab-panel");
|
||||
if (items.length > idx) {
|
||||
items.item(idx).scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
|
@ -135,6 +135,31 @@ const Home: ParentComponent = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const openFullScreenToot = (
|
||||
toot: mastodon.v1.Status,
|
||||
srcElement?: HTMLElement,
|
||||
reply?: boolean,
|
||||
) => {
|
||||
const p = profiles()[0];
|
||||
const inf = p.account.inf ?? profile();
|
||||
if (!inf) {
|
||||
console.warn("no account info?");
|
||||
return;
|
||||
}
|
||||
setHeroSrc((x) =>
|
||||
Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
|
||||
);
|
||||
const acct = `${inf.username}@${p.account.site}`;
|
||||
setTootBottomSheetCache(acct, toot);
|
||||
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
|
||||
state: reply
|
||||
? {
|
||||
tootReply: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
css`
|
||||
.tab-panel {
|
||||
overflow: visible auto;
|
||||
|
@ -143,9 +168,6 @@ const Home: ParentComponent = (props) => {
|
|||
padding: 0 16px;
|
||||
scroll-snap-align: center;
|
||||
overscroll-behavior-block: none;
|
||||
contain: strict;
|
||||
contain-intrinsic-size: auto 560px auto 100vh;
|
||||
contain-intrinsic-size: auto 560px auto 100dvh;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 0;
|
||||
|
@ -156,7 +178,7 @@ const Home: ParentComponent = (props) => {
|
|||
display: grid;
|
||||
grid-auto-columns: 560px;
|
||||
grid-auto-flow: column;
|
||||
overflow: auto hidden;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-snap-stop: always;
|
||||
height: calc(100vh - var(--scaffold-topbar-height, 0px));
|
||||
|
@ -164,10 +186,6 @@ const Home: ParentComponent = (props) => {
|
|||
padding-left: var(--safe-area-inset-left, 0);
|
||||
padding-right: var(--safe-area-inset-right, 0);
|
||||
|
||||
& > * {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-auto-columns: 100%;
|
||||
}
|
||||
|
@ -176,75 +194,87 @@ const Home: ParentComponent = (props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle>Timelines</DocumentTitle>
|
||||
<Scaffold
|
||||
topbar={
|
||||
<AppTopBar>
|
||||
<Tabs>
|
||||
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
|
||||
{t("tabs.home")}
|
||||
</Tab>
|
||||
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
|
||||
{t("tabs.trending")}
|
||||
</Tab>
|
||||
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
|
||||
{t("tabs.public")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<ProfileMenuButton profile={profiles()[0]}>
|
||||
<MenuItem
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
"prefetchTootsDisabled",
|
||||
!$settings.get().prefetchTootsDisabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemText>{t("set.prefetch-toots")}</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={prefetching()}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
</ProfileMenuButton>
|
||||
</AppTopBar>
|
||||
<AppBar position="static">
|
||||
<Toolbar
|
||||
variant="dense"
|
||||
class="responsive"
|
||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
|
||||
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
|
||||
Home
|
||||
</Tab>
|
||||
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
|
||||
Trending
|
||||
</Tab>
|
||||
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
|
||||
Public
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<ProfileMenuButton profile={profile()}>
|
||||
<MenuItem
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
"prefetchTootsDisabled",
|
||||
!$settings.get().prefetchTootsDisabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListItemText>Prefetch Toots</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch checked={prefetching()}></Switch>
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
</ProfileMenuButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
}
|
||||
>
|
||||
<ItemSelectionProvider value={selectionState}>
|
||||
<TimeSourceProvider value={now}>
|
||||
<Show when={!!client()}>
|
||||
<div
|
||||
class="panel-list"
|
||||
ref={panelList!}
|
||||
onScroll={requestRecalculateTabIndicator}
|
||||
>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="home"
|
||||
prefetch={prefetching()}
|
||||
/>
|
||||
</div>
|
||||
<TimeSourceProvider value={now}>
|
||||
<Show when={!!client()}>
|
||||
<div class="panel-list" ref={panelList!}>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="home"
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TrendTimelinePanel client={client()} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="public"
|
||||
prefetch={prefetching()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</Show>
|
||||
</TimeSourceProvider>
|
||||
</ItemSelectionProvider>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TrendTimelinePanel
|
||||
client={client()}
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="public"
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</Show>
|
||||
</TimeSourceProvider>
|
||||
<Suspense>
|
||||
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
|
||||
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
|
||||
{child()}
|
||||
</BottomSheet>
|
||||
</HeroSourceProvider>
|
||||
</Suspense>
|
||||
</Scaffold>
|
||||
</>
|
||||
);
|
||||
|
|
149
src/timelines/MediaAttachmentGrid.tsx
Normal file
149
src/timelines/MediaAttachmentGrid.tsx
Normal file
|
@ -0,0 +1,149 @@
|
|||
import type { mastodon } from "masto";
|
||||
import {
|
||||
type Component,
|
||||
For,
|
||||
createEffect,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { css } from "solid-styled";
|
||||
import tootStyle from "./toot.module.css";
|
||||
import MediaViewer from "./MediaViewer";
|
||||
import { render } from "solid-js/web";
|
||||
import { useWindowSize } from "@solid-primitives/resize-observer";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { $settings } from "../settings/stores";
|
||||
|
||||
const MediaAttachmentGrid: Component<{
|
||||
attachments: mastodon.v1.MediaAttachment[];
|
||||
}> = (props) => {
|
||||
let rootRef: HTMLElement;
|
||||
const [viewerIndex, setViewerIndex] = createSignal<number>();
|
||||
const viewerOpened = () => typeof viewerIndex() !== "undefined";
|
||||
const windowSize = useWindowSize();
|
||||
const vh35 = () => Math.floor(windowSize.height * 0.35);
|
||||
const settings = useStore($settings);
|
||||
|
||||
createRenderEffect((lastDispose?: () => void) => {
|
||||
lastDispose?.();
|
||||
const vidx = viewerIndex();
|
||||
if (typeof vidx === "undefined") return;
|
||||
const container = document.createElement("div");
|
||||
container.setAttribute("role", "presentation");
|
||||
document.body.appendChild(container);
|
||||
return render(() => {
|
||||
onCleanup(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
return (
|
||||
<MediaViewer
|
||||
show={viewerOpened()}
|
||||
index={viewerIndex() || 0}
|
||||
onIndexUpdated={setViewerIndex}
|
||||
media={props.attachments}
|
||||
onClose={() => setViewerIndex()}
|
||||
/>
|
||||
);
|
||||
}, container);
|
||||
});
|
||||
|
||||
const openViewerFor = (index: number) => {
|
||||
setViewerIndex(index);
|
||||
};
|
||||
|
||||
const columnCount = () => {
|
||||
if (props.attachments.length === 1) {
|
||||
return 1;
|
||||
} else if (props.attachments.length % 2 === 0) {
|
||||
return 2;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
css`
|
||||
.attachments {
|
||||
column-count: ${columnCount().toString()};
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<section
|
||||
ref={rootRef!}
|
||||
class={[tootStyle.tootAttachmentGrp, "attachments"].join(" ")}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<For each={props.attachments}>
|
||||
{(item, index) => {
|
||||
const [loaded, setLoaded] = createSignal(false);
|
||||
const width = item.meta?.small?.width;
|
||||
const height = item.meta?.small?.height;
|
||||
const aspectRatio = item.meta?.small?.aspect;
|
||||
const maxHeight = vh35();
|
||||
const realHeight = height && height > maxHeight ? maxHeight : height;
|
||||
const realWidth =
|
||||
width && height && height > maxHeight
|
||||
? maxHeight * (aspectRatio ?? 1)
|
||||
: width;
|
||||
const style = () =>
|
||||
loaded()
|
||||
? undefined
|
||||
: {
|
||||
width: realWidth ? `${realWidth}px` : undefined,
|
||||
height: realHeight ? `${realHeight}px` : undefined,
|
||||
};
|
||||
switch (item.type) {
|
||||
case "image":
|
||||
return (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
style={style()}
|
||||
alt={item.description || undefined}
|
||||
onClick={[openViewerFor, index()]}
|
||||
onLoad={[setLoaded, true]}
|
||||
loading="lazy"
|
||||
></img>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<video
|
||||
src={item.url || undefined}
|
||||
style={style()}
|
||||
onLoadedMetadata={[setLoaded, true]}
|
||||
autoplay={settings().autoPlayVideos}
|
||||
playsinline={settings().autoPlayVideos ? true : undefined}
|
||||
controls
|
||||
poster={item.previewUrl}
|
||||
/>
|
||||
);
|
||||
case "gifv":
|
||||
return (
|
||||
<video
|
||||
src={item.url || undefined}
|
||||
style={style()}
|
||||
onLoadedMetadata={[setLoaded, true]}
|
||||
autoplay={settings().autoPlayGIFs}
|
||||
controls
|
||||
playsinline /* or safari on iOS will play in full-screen */
|
||||
loop
|
||||
poster={item.previewUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
case "unknown":
|
||||
return <div></div>;
|
||||
}
|
||||
}}
|
||||
</For>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaAttachmentGrid;
|
|
@ -38,7 +38,7 @@ function clamp(input: number, min: number, max: number) {
|
|||
}
|
||||
|
||||
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
|
||||
let rootRef!: HTMLDialogElement;
|
||||
let rootRef: HTMLDialogElement;
|
||||
|
||||
type State = {
|
||||
ref?: HTMLElement;
|
||||
|
|
|
@ -5,121 +5,122 @@ import {
|
|||
ListItemAvatar,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@suid/material";
|
||||
import { Show, createUniqueId, type ParentComponent } from "solid-js";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Show,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
type ParentComponent,
|
||||
} from "solid-js";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Bookmark as BookmarkIcon,
|
||||
Star as LikeIcon,
|
||||
FeaturedPlayList as ListIcon,
|
||||
} from "@suid/icons-material";
|
||||
import A from "~platform/A";
|
||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
||||
import { createTranslator } from "~platform/i18n";
|
||||
|
||||
type StringRes = Record<
|
||||
"nav.bookmarks" | "nav.likes" | "nav.lists" | "nav.settings",
|
||||
string
|
||||
>;
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
const ProfileMenuButton: ParentComponent<{
|
||||
profile?: {
|
||||
account: {
|
||||
site: string;
|
||||
inf?: {
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
profile?: { displayName: string; avatar: string; username: string };
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
}> = (props) => {
|
||||
const menuId = createUniqueId();
|
||||
const buttonId = createUniqueId();
|
||||
const [t] = createTranslator(
|
||||
async (code) =>
|
||||
(await import(`./i18n/${code}.json`)) as { default: StringRes },
|
||||
);
|
||||
|
||||
const [open, state] = createManagedMenuState();
|
||||
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
|
||||
const open = () => !!anchor();
|
||||
|
||||
const onClick = (event: { currentTarget: HTMLElement }) => {
|
||||
open(event.currentTarget.getBoundingClientRect());
|
||||
const onClick = (
|
||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||
) => {
|
||||
setAnchor(event.currentTarget);
|
||||
props.onClick?.();
|
||||
};
|
||||
|
||||
const inf = () => props.profile?.account.inf;
|
||||
const onClose = () => {
|
||||
props.onClick?.();
|
||||
setAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
aria-haspopup="true"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
id={buttonId}
|
||||
onClick={onClick}
|
||||
aria-controls={state.open ? menuId : undefined}
|
||||
aria-expanded={state.open ? "true" : "false"}
|
||||
>
|
||||
<Avatar
|
||||
alt={`${inf()?.displayName}'s avatar`}
|
||||
src={inf()?.avatar}
|
||||
></Avatar>
|
||||
</ButtonBase>
|
||||
<Menu
|
||||
id={menuId}
|
||||
MenuListProps={{
|
||||
"aria-labelledby": menuId,
|
||||
style: {
|
||||
"min-width": "220px",
|
||||
},
|
||||
}}
|
||||
{...state}
|
||||
>
|
||||
<MenuItem
|
||||
component={A}
|
||||
href={`/${encodeURIComponent(`${inf()?.username}@${props.profile?.account.site}`)}/profile/${inf()?.id}`}
|
||||
disabled={!props.profile}
|
||||
<ButtonBase
|
||||
aria-haspopup="true"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
id={buttonId}
|
||||
onClick={onClick}
|
||||
aria-controls={open() ? menuId : undefined}
|
||||
aria-expanded={open() ? "true" : undefined}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={inf()?.avatar}></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={inf()?.displayName}
|
||||
secondary={`@${inf()?.username}`}
|
||||
></ListItemText>
|
||||
</MenuItem>
|
||||
<Avatar
|
||||
alt={`${props.profile?.displayName}'s avatar`}
|
||||
src={props.profile?.avatar}
|
||||
></Avatar>
|
||||
</ButtonBase>
|
||||
<Menu
|
||||
id={menuId}
|
||||
anchorEl={anchor()}
|
||||
open={open()}
|
||||
onClose={onClose}
|
||||
MenuListProps={{
|
||||
"aria-labelledby": buttonId,
|
||||
sx: {
|
||||
minWidth: "220px",
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={props.profile?.avatar}></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={props.profile?.displayName}
|
||||
secondary={`@${props.profile?.username}`}
|
||||
></ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<BookmarkIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("nav.bookmarks")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<LikeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("nav.likes")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("nav.lists")}</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Show when={props.children}>
|
||||
{props.children}
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<BookmarkIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Bookmarks</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<LikeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Likes</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Lists</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
</Show>
|
||||
<MenuItem component={A} href="/settings">
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("nav.settings")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Show when={props.children}>
|
||||
{props.children}
|
||||
<Divider />
|
||||
</Show>
|
||||
<MenuItem component={A} href="/settings" onClick={onClose}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Settings</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,23 +1,31 @@
|
|||
import {
|
||||
createEffect,
|
||||
createRenderEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
Show,
|
||||
untrack,
|
||||
type Component,
|
||||
type Signal,
|
||||
} from "solid-js";
|
||||
import { css } from "solid-styled";
|
||||
import { Refresh as RefreshIcon } from "@suid/icons-material";
|
||||
import { CircularProgress } from "@suid/material";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
||||
import { useIsFrameSuspended } from "~platform/StackedRouter";
|
||||
import {
|
||||
createEventListener,
|
||||
makeEventListener,
|
||||
} from "@solid-primitives/event-listener";
|
||||
import {
|
||||
createViewportObserver,
|
||||
createVisibilityObserver,
|
||||
} from "@solid-primitives/intersection-observer";
|
||||
|
||||
const PullDownToRefresh: Component<{
|
||||
loading?: boolean;
|
||||
linkedElement?: HTMLElement;
|
||||
onRefresh?: () => void;
|
||||
}> = (props) => {
|
||||
let rootElement!: HTMLDivElement;
|
||||
let rootElement: HTMLDivElement;
|
||||
const [pullDown, setPullDown] = createSignal(0);
|
||||
|
||||
const stopPos = () => 160;
|
||||
|
@ -34,7 +42,6 @@ const PullDownToRefresh: Component<{
|
|||
});
|
||||
|
||||
const rootVisible = obvx(() => rootElement);
|
||||
const isFrameSuspended = useIsFrameSuspended()
|
||||
|
||||
createEffect(() => {
|
||||
if (!rootVisible()) setPullDown(0);
|
||||
|
@ -107,16 +114,14 @@ const PullDownToRefresh: Component<{
|
|||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
createEffect((cleanup?: () => void) => {
|
||||
if (!rootVisible()) {
|
||||
return;
|
||||
}
|
||||
if (isFrameSuspended()) {
|
||||
return;
|
||||
}
|
||||
cleanup?.();
|
||||
const element = props.linkedElement;
|
||||
if (!element) return;
|
||||
makeEventListener(element, "wheel", handleLinkedWheel);
|
||||
return makeEventListener(element, "wheel", handleLinkedWheel);
|
||||
});
|
||||
|
||||
let lastTouchId: number | undefined = undefined;
|
||||
|
@ -160,17 +165,16 @@ const PullDownToRefresh: Component<{
|
|||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
createEffect((cleanup?: () => void) => {
|
||||
if (!rootVisible()) {
|
||||
return;
|
||||
}
|
||||
if (isFrameSuspended()) {
|
||||
return;
|
||||
}
|
||||
cleanup?.();
|
||||
const element = props.linkedElement;
|
||||
if (!element) return;
|
||||
makeEventListener(element, "touchmove", handleTouch);
|
||||
makeEventListener(element, "touchend", handleTouchEnd);
|
||||
const cleanup0 = makeEventListener(element, "touchmove", handleTouch);
|
||||
const cleanup1 = makeEventListener(element, "touchend", handleTouchEnd);
|
||||
return () => (cleanup0(), cleanup1());
|
||||
});
|
||||
|
||||
css`
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
.RegularToot {
|
||||
--card-pad: 16px;
|
||||
--card-gut: 16px;
|
||||
--toot-avatar-size: 40px;
|
||||
margin-block: 0;
|
||||
position: relative;
|
||||
contain: content;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
transition:
|
||||
height 60ms var(--tutu-anim-curve-sharp),
|
||||
var(--tutu-transition-shadow);
|
||||
border-radius: 0;
|
||||
|
||||
time {
|
||||
color: var(--tutu-color-secondary-text-on-surface);
|
||||
}
|
||||
|
||||
>.retoot-grp {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
|
||||
> :first-child {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
& .custom-emoji {
|
||||
height: 1em;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
z-index: calc(var(--tutu-zidx-nav) - 2);
|
||||
box-shadow: var(--tutu-shadow-e9);
|
||||
}
|
||||
|
||||
&.thread-top,
|
||||
&.thread-mid,
|
||||
&.thread-btm {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 36px;
|
||||
background-color: var(--tutu-color-secondary);
|
||||
width: 2px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.thread-mid {
|
||||
&::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.thread-top {
|
||||
&::before {
|
||||
top: 16px;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.thread-btm {
|
||||
&::before {
|
||||
top: 0;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue