Rewrite timelines #25
99 changed files with 2018 additions and 5736 deletions
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_PLATFROM_MASONRY_ALWAYS_COMPAT=
|
|
|
@ -31,11 +31,11 @@ jobs:
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Build Dist (Staging)
|
- 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'
|
if: env.GITHUB_REF_NAME == 'master'
|
||||||
|
|
||||||
- name: Build Dist
|
- name: Build Dist
|
||||||
run: VITE_CODE_VERSION=$GITHUB_SHA bun dist
|
run: bun dist
|
||||||
if: env.GITHUB_REF_NAME != 'master'
|
if: env.GITHUB_REF_NAME != 'master'
|
||||||
|
|
||||||
- name: Depoly to Preview
|
- name: Depoly to Preview
|
||||||
|
|
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -1 +0,0 @@
|
||||||
*.lockb binary diff=lockb
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,3 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
dev-dist/
|
dev-dist/
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
10
README.md
10
README.md
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet.
|
Tutu is a comfortable experience for tooting. Designed to work on any device - desktop, phone and tablet.
|
||||||
|
|
||||||
[Launch Tutu](https://tutu.lightstands.xyz)
|
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
The code is built against those targets and Tutu must run on those platforms:
|
The code is built against those targets and Tutu must run on those platforms:
|
||||||
|
@ -14,12 +12,6 @@ The code is built against those targets and Tutu must run on those platforms:
|
||||||
|
|
||||||
Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement.
|
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 "Next" Branch
|
|
||||||
|
|
||||||
The "next" branch of the app is built on every commit pushed into "master". You can tatse latest change but risks your data.
|
|
||||||
|
|
||||||
[Launch Tutu (Next)](https://master.tututheapp.pages.dev)
|
|
||||||
|
|
||||||
## Build & Depoly
|
## Build & Depoly
|
||||||
|
|
||||||
Tutu uses [bun](https://bun.sh) as the package manager. Run
|
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.
|
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).
|
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
|
|
||||||
```
|
|
|
@ -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.
|
- 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.
|
- 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 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.
|
The `WritableAtom<unknwon>.set` might do an equals check. You must set a different object to ensure the atom sending a notify.
|
||||||
|
@ -37,50 +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.
|
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.
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
# Optimizing Tutu
|
|
||||||
|
|
||||||
Topic Index:
|
|
||||||
|
|
||||||
- Time to first byte
|
|
||||||
- Time to first draw: [Load size](#load-size)
|
|
||||||
- CLS
|
|
||||||
- Framerate: [Algorithm](#algorithm)
|
|
||||||
|
|
||||||
## 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.
|
|
|
@ -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;
|
|
46
package.json
46
package.json
|
@ -1,65 +1,55 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package",
|
"$schema": "https://json.schemastore.org/package",
|
||||||
"name": "tutu",
|
"name": "tutu",
|
||||||
"version": "1.1.0",
|
"version": "1.0.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"dist": "vite build",
|
"dist": "vite build"
|
||||||
"count-source-lines": "exec scripts/src-lc.sh"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Rubicon",
|
"author": "Rubicon",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@solid-devtools/overlay": "^0.30.1",
|
"@suid/vite-plugin": "^0.3.0",
|
||||||
"@suid/vite-plugin": "^0.3.1",
|
"@types/hammerjs": "^2.0.45",
|
||||||
"@types/hammerjs": "^2.0.46",
|
"postcss": "^8.4.45",
|
||||||
"@types/masonry-layout": "^4.2.8",
|
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.5",
|
||||||
"vite-plugin-package-version": "^1.1.0",
|
"vite-plugin-package-version": "^1.1.0",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
"vite-plugin-solid": "^2.10.2",
|
"vite-plugin-solid": "^2.10.2",
|
||||||
"vite-plugin-solid-styled": "^0.11.1",
|
"vite-plugin-solid-styled": "^0.11.1",
|
||||||
"workbox-build": "^7.3.0",
|
"wrangler": "^3.78.2"
|
||||||
"wrangler": "^3.86.1"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-localematcher": "^0.5.7",
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
"@nanostores/persistent": "^0.10.2",
|
"@nanostores/persistent": "^0.10.2",
|
||||||
"@nanostores/solid": "^0.5.0",
|
"@nanostores/solid": "^0.4.2",
|
||||||
"@solid-primitives/event-listener": "^2.3.3",
|
"@solid-primitives/event-listener": "^2.3.3",
|
||||||
"@solid-primitives/i18n": "^2.1.1",
|
"@solid-primitives/i18n": "^2.1.1",
|
||||||
"@solid-primitives/intersection-observer": "^2.1.6",
|
"@solid-primitives/intersection-observer": "^2.1.6",
|
||||||
"@solid-primitives/map": "^0.4.13",
|
"@solid-primitives/map": "^0.4.13",
|
||||||
"@solid-primitives/page-visibility": "^2.0.17",
|
|
||||||
"@solid-primitives/resize-observer": "^2.0.26",
|
"@solid-primitives/resize-observer": "^2.0.26",
|
||||||
"@solidjs/router": "^0.15.1",
|
"@solidjs/router": "^0.14.5",
|
||||||
"@suid/icons-material": "^0.8.1",
|
"@suid/icons-material": "^0.8.0",
|
||||||
"@suid/material": "^0.18.0",
|
"@suid/material": "^0.17.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"colorjs.io": "^0.5.2",
|
"colorjs.io": "^0.5.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^3.6.0",
|
||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"iso-639-1": "^3.1.3",
|
"iso-639-1": "^3.1.3",
|
||||||
"masonry-layout": "^4.2.2",
|
"masto": "^6.8.0",
|
||||||
"masto": "^6.10.1",
|
|
||||||
"nanostores": "^0.11.3",
|
"nanostores": "^0.11.3",
|
||||||
"normalize.css": "^8.0.1",
|
"solid-js": "^1.8.22",
|
||||||
"solid-devtools": "^0.30.1",
|
|
||||||
"solid-js": "^1.9.3",
|
|
||||||
"solid-styled": "^0.11.1",
|
"solid-styled": "^0.11.1",
|
||||||
"stacktrace-js": "^2.0.2",
|
"stacktrace-js": "^2.0.2",
|
||||||
"workbox-core": "^7.3.0",
|
"web-animations-js": "^2.3.2"
|
||||||
"workbox-precaching": "^7.3.0"
|
|
||||||
},
|
},
|
||||||
"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=-
|
|
31
src/App.css
31
src/App.css
|
@ -1,41 +1,12 @@
|
||||||
@import "normalize.css/normalize.css";
|
|
||||||
@import "./material/theme.css";
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--safe-area-inset-top: env(safe-area-inset-top);
|
--safe-area-inset-top: env(safe-area-inset-top);
|
||||||
--safe-area-inset-left: env(safe-area-inset-left);
|
--safe-area-inset-left: env(safe-area-inset-left);
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||||
--safe-area-inset-right: env(safe-area-inset-right);
|
--safe-area-inset-right: env(safe-area-inset-right);
|
||||||
background-color: var(--tutu-color-surface, transparent);
|
background-color: var(--tutu-color-surface, transparent);
|
||||||
}
|
overscroll-behavior-block: none;
|
||||||
|
|
||||||
/*
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-emoji {
|
.custom-emoji {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
89
src/App.tsx
89
src/App.tsx
|
@ -5,12 +5,11 @@ import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createSignal,
|
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
lazy,
|
lazy,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useRootTheme } from "./material/theme.js";
|
import { useRootTheme } from "./material/mui.js";
|
||||||
import {
|
import {
|
||||||
Provider as ClientProvider,
|
Provider as ClientProvider,
|
||||||
createMastoClientFor,
|
createMastoClientFor,
|
||||||
|
@ -18,16 +17,6 @@ import {
|
||||||
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
import { $accounts, updateAcctInf } from "./accounts/stores.js";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { DateFnScope, useLanguage } from "./platform/i18n.jsx";
|
import { DateFnScope, useLanguage } 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";
|
|
||||||
|
|
||||||
const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
|
const AccountSignIn = lazy(() => import("./accounts/SignIn.js"));
|
||||||
const AccountMastodonOAuth2Callback = lazy(
|
const AccountMastodonOAuth2Callback = lazy(
|
||||||
|
@ -38,21 +27,22 @@ const Settings = lazy(() => import("./settings/Settings.js"));
|
||||||
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
|
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
|
||||||
const MotionSettings = lazy(() => import("./settings/Motions.js"));
|
const MotionSettings = lazy(() => import("./settings/Motions.js"));
|
||||||
const LanguageSettings = lazy(() => import("./settings/Language.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 UnexpectedError = lazy(() => import("./UnexpectedError.js"));
|
||||||
const Profile = lazy(() => import("./profiles/Profile.js"));
|
|
||||||
|
|
||||||
const Routing: Component = () => {
|
const Routing: Component = () => {
|
||||||
return (
|
return (
|
||||||
<StackedRouter>
|
<Router>
|
||||||
<Route path="/" component={TimelineHome} />
|
<Route path="/" component={TimelineHome}>
|
||||||
<Route path="/settings/language" component={LanguageSettings} />
|
<Route path=""></Route>
|
||||||
<Route path="/settings/region" component={RegionSettings} />
|
<Route path="/settings" component={Settings}>
|
||||||
<Route path="/settings/motions" component={MotionSettings} />
|
<Route path=""></Route>
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/language" component={LanguageSettings}></Route>
|
||||||
<Route path="/:acct/toot/:id" component={TootBottomSheet} />
|
<Route path="/region" component={RegionSettings}></Route>
|
||||||
<Route path="/:acct/profile/:id" component={Profile} />
|
<Route path="/motions" component={MotionSettings}></Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
|
||||||
|
</Route>
|
||||||
<Route path={"/accounts"}>
|
<Route path={"/accounts"}>
|
||||||
<Route path={"/sign-in"} component={AccountSignIn} />
|
<Route path={"/sign-in"} component={AccountSignIn} />
|
||||||
<Route
|
<Route
|
||||||
|
@ -60,7 +50,7 @@ const Routing: Component = () => {
|
||||||
component={AccountMastodonOAuth2Callback}
|
component={AccountMastodonOAuth2Callback}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</StackedRouter>
|
</Router>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -68,45 +58,6 @@ const App: Component = () => {
|
||||||
const theme = useRootTheme();
|
const theme = useRootTheme();
|
||||||
const accts = useStore($accounts);
|
const accts = useStore($accounts);
|
||||||
const lang = useLanguage();
|
const lang = useLanguage();
|
||||||
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 clients = createMemo(() => {
|
const clients = createMemo(() => {
|
||||||
return accts().map((x) => ({
|
return accts().map((x) => ({
|
||||||
|
@ -149,18 +100,10 @@ const App: Component = () => {
|
||||||
return <UnexpectedError error={err} />;
|
return <UnexpectedError error={err} />;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme()}>
|
||||||
<DateFnScope>
|
<DateFnScope>
|
||||||
<ClientProvider value={clients}>
|
<ClientProvider value={clients}>
|
||||||
<ServiceWorkerProvider
|
<Routing />
|
||||||
value={{
|
|
||||||
needRefresh,
|
|
||||||
offlineReady,
|
|
||||||
serviceWorker,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Routing />
|
|
||||||
</ServiceWorkerProvider>
|
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
</DateFnScope>
|
</DateFnScope>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
@ -18,16 +18,7 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return `${err.name}: ${err.message}\n${strackMsg}`;
|
return `${err.name}: ${err.message}\n${strackMsg}`;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
return `<failed to build the stacktrace of "${err}"...>\n${reason}\n${JSON.stringify(
|
return `<failed to build the stacktrace of "${err}"...>\n${reason}`;
|
||||||
{
|
|
||||||
name: err.name,
|
|
||||||
stack: err.stack,
|
|
||||||
cause: err.cause,
|
|
||||||
message: err.message,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
2,
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,29 +33,6 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
||||||
calc(var(--safe-area-inset-bottom) + 20px)
|
calc(var(--safe-area-inset-bottom) + 20px)
|
||||||
calc(var(--safe-area-inset-left) + 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 (
|
return (
|
||||||
|
@ -72,25 +40,17 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
|
||||||
<h1>Oh, it is our fault.</h1>
|
<h1>Oh, it is our fault.</h1>
|
||||||
<p>There is an unexpected error in our app, and it's not your fault.</p>
|
<p>There is an unexpected error in our app, and it's not your fault.</p>
|
||||||
<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.
|
repeatly, please report to us.
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div>
|
||||||
<Button
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
onClick={() => window.location.replace("/")}
|
|
||||||
variant="contained"
|
|
||||||
>
|
|
||||||
Restart App
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
||||||
</summary>
|
</summary>
|
||||||
<pre>
|
<pre>{errorMsg()}</pre>
|
||||||
On: {window.location.href} <br />
|
|
||||||
{errorMsg()}
|
|
||||||
</pre>
|
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useSearchParams } from "@solidjs/router";
|
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
Show,
|
Show,
|
||||||
|
@ -9,12 +9,11 @@ import {
|
||||||
import { acceptAccountViaAuthCode } from "./stores";
|
import { acceptAccountViaAuthCode } from "./stores";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import cards from "~material/cards.module.css";
|
import cards from "../material/cards.module.css";
|
||||||
import { LinearProgress } from "@suid/material";
|
import { LinearProgress } from "@suid/material";
|
||||||
import Img from "~material/Img";
|
import Img from "../material/Img";
|
||||||
import { createRestAPIClient } from "masto";
|
import { createRestAPIClient } from "masto";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
|
|
||||||
type OAuth2CallbackParams = {
|
type OAuth2CallbackParams = {
|
||||||
code?: string;
|
code?: string;
|
||||||
|
@ -26,7 +25,7 @@ const MastodonOAuth2Callback: Component = () => {
|
||||||
const progressId = createUniqueId();
|
const progressId = createUniqueId();
|
||||||
const titleId = createUniqueId();
|
const titleId = createUniqueId();
|
||||||
const [params] = useSearchParams<OAuth2CallbackParams>();
|
const [params] = useSearchParams<OAuth2CallbackParams>();
|
||||||
const { push: navigate } = useNavigator();
|
const navigate = useNavigate();
|
||||||
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
|
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
|
||||||
const [siteImg, setSiteImg] = createSignal<{
|
const [siteImg, setSiteImg] = createSignal<{
|
||||||
src: string;
|
src: string;
|
||||||
|
|
|
@ -7,11 +7,11 @@ import {
|
||||||
createUniqueId,
|
createUniqueId,
|
||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import cards from "~material/cards.module.css";
|
import cards from "../material/cards.module.css";
|
||||||
import TextField from "~material/TextField.js";
|
import TextField from "../material/TextField.js";
|
||||||
import Button from "~material/Button.js";
|
import Button from "../material/Button.js";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { LinearProgress } from "@suid/material";
|
import { LinearProgress } from "@suid/material";
|
||||||
import { createRestAPIClient } from "masto";
|
import { createRestAPIClient } from "masto";
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import App from "./App.js";
|
import App from "./App.js";
|
||||||
import "solid-devtools";
|
import "./material/theme.css";
|
||||||
import { attachDevtoolsOverlay } from "@solid-devtools/overlay";
|
|
||||||
|
|
||||||
render(() => <App />, document.getElementById("root")!);
|
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,15 +1,14 @@
|
||||||
import {
|
import {
|
||||||
Accessor,
|
Accessor,
|
||||||
createContext,
|
createContext,
|
||||||
createMemo,
|
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createResource,
|
createResource,
|
||||||
|
Signal,
|
||||||
useContext,
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { Account } from "../accounts/stores";
|
import { Account } from "../accounts/stores";
|
||||||
import { createRestAPIClient, mastodon } from "masto";
|
import { createRestAPIClient, mastodon } from "masto";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation, useNavigate } from "@solidjs/router";
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
|
|
||||||
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
const restfulCache: Record<string, mastodon.rest.Client> = {};
|
||||||
|
|
||||||
|
@ -50,19 +49,18 @@ export type Session = {
|
||||||
client: mastodon.rest.Client;
|
client: mastodon.rest.Client;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Context =
|
const Context = /* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
|
||||||
/* @__PURE__ */ createContext<Accessor<readonly Readonly<Session>[]>>();
|
|
||||||
|
|
||||||
export const Provider = Context.Provider;
|
export const Provider = Context.Provider;
|
||||||
|
|
||||||
export function useSessions() {
|
export function useSessions() {
|
||||||
const sessions = useSessionsRaw();
|
const sessions = useSessionsRaw();
|
||||||
const {push} = useNavigator();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (sessions().length > 0) return;
|
if (sessions().length > 0) return;
|
||||||
push(
|
navigate(
|
||||||
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
"/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
|
@ -78,64 +76,3 @@ function useSessionsRaw() {
|
||||||
}
|
}
|
||||||
return store;
|
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 `undefined` 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: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeAcctText(session: Session) {
|
|
||||||
return `${session.account.inf?.username}@${session.account.site}`;
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,132 +7,86 @@ import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createResource,
|
createResource,
|
||||||
untrack,
|
untrack,
|
||||||
type Resource,
|
|
||||||
type ResourceFetcherInfo,
|
type ResourceFetcherInfo,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
|
||||||
type Timeline<T extends mastodon.DefaultPaginationParams> = {
|
type Timeline = {
|
||||||
list(params?: T): mastodon.Paginator<mastodon.v1.Status[], unknown>;
|
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 function createTimelineSnapshot(
|
||||||
|
timeline: Accessor<Timeline>,
|
||||||
export type ThreadNode = TreeNode<mastodon.v1.Status>;
|
limit: Accessor<number>,
|
||||||
|
) {
|
||||||
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> {
|
|
||||||
const [shot, { refetch }] = createResource(
|
const [shot, { refetch }] = createResource(
|
||||||
() => [timeline(), params()] as const,
|
() => [timeline(), limit()] as const,
|
||||||
async ([tl, limit]) => {
|
async ([tl, limit]) => {
|
||||||
const ls = await tl.list(limit).next();
|
const ls = await tl.list({ limit }).next();
|
||||||
return ls.value;
|
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 [
|
return [
|
||||||
controls,
|
snapshot,
|
||||||
shot,
|
shot,
|
||||||
{
|
{
|
||||||
refetch,
|
refetch,
|
||||||
|
mutate: setSnapshot,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TimelineFetchDirection = mastodon.Direction;
|
export type TimelineFetchDirection = mastodon.Direction;
|
||||||
|
|
||||||
export type TimelineChunk<T extends mastodon.DefaultPaginationParams> = {
|
export type TimelineChunk = {
|
||||||
tl: Timeline<T>;
|
tl: Timeline;
|
||||||
rebuilt: boolean;
|
rebuilt: boolean;
|
||||||
chunk: readonly mastodon.v1.Status[];
|
chunk: readonly mastodon.v1.Status[];
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
direction: TimelineFetchDirection;
|
direction: TimelineFetchDirection;
|
||||||
params: T;
|
limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TreeNode<T> = {
|
type TreeNode<T> = {
|
||||||
|
@ -154,20 +108,21 @@ function collectPath<T>(node: TreeNode<T>) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTimelineChunk<
|
function createTimelineChunk(
|
||||||
T extends Timeline<mastodon.DefaultPaginationParams>,
|
timeline: Accessor<Timeline>,
|
||||||
>(timeline: Accessor<T>, params: Accessor<TimelineParamsOf<T>>) {
|
limit: Accessor<number>,
|
||||||
|
) {
|
||||||
let vpMaxId: string | undefined, vpMinId: string | undefined;
|
let vpMaxId: string | undefined, vpMinId: string | undefined;
|
||||||
|
|
||||||
const fetchExtendingPage = async (
|
const fetchExtendingPage = async (
|
||||||
tl: T,
|
tl: Timeline,
|
||||||
direction: TimelineFetchDirection,
|
direction: TimelineFetchDirection,
|
||||||
params: TimelineParamsOf<T>,
|
limit: number,
|
||||||
) => {
|
) => {
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case "next": {
|
case "next": {
|
||||||
const page = await tl
|
const page = await tl
|
||||||
.list({ ...params, sinceId: vpMaxId })
|
.list({ limit, sinceId: vpMaxId })
|
||||||
.setDirection(direction)
|
.setDirection(direction)
|
||||||
.next();
|
.next();
|
||||||
if ((page.value?.length ?? 0) > 0) {
|
if ((page.value?.length ?? 0) > 0) {
|
||||||
|
@ -178,7 +133,7 @@ function createTimelineChunk<
|
||||||
|
|
||||||
case "prev": {
|
case "prev": {
|
||||||
const page = await tl
|
const page = await tl
|
||||||
.list({ ...params, maxId: vpMinId })
|
.list({ limit, maxId: vpMinId })
|
||||||
.setDirection(direction)
|
.setDirection(direction)
|
||||||
.next();
|
.next();
|
||||||
if ((page.value?.length ?? 0) > 0) {
|
if ((page.value?.length ?? 0) > 0) {
|
||||||
|
@ -190,11 +145,11 @@ function createTimelineChunk<
|
||||||
};
|
};
|
||||||
|
|
||||||
return createResource(
|
return createResource(
|
||||||
() => [timeline(), params()] as const,
|
() => [timeline(), limit()] as const,
|
||||||
async (
|
async (
|
||||||
[tl, params],
|
[tl, limit],
|
||||||
info: ResourceFetcherInfo<
|
info: ResourceFetcherInfo<
|
||||||
Readonly<TimelineChunk<TimelineParamsOf<T>>>,
|
Readonly<TimelineChunk>,
|
||||||
TimelineFetchDirection
|
TimelineFetchDirection
|
||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
|
@ -205,66 +160,27 @@ function createTimelineChunk<
|
||||||
vpMaxId = undefined;
|
vpMaxId = undefined;
|
||||||
vpMinId = undefined;
|
vpMinId = undefined;
|
||||||
}
|
}
|
||||||
const posts = await fetchExtendingPage(tl, direction, params);
|
const posts = await fetchExtendingPage(tl, direction, limit);
|
||||||
return {
|
return {
|
||||||
tl,
|
tl,
|
||||||
rebuilt: rebuildTimeline,
|
rebuilt: rebuildTimeline,
|
||||||
chunk: posts.value ?? [],
|
chunk: posts.value ?? [],
|
||||||
done: posts.done,
|
done: posts.done,
|
||||||
direction,
|
direction,
|
||||||
params,
|
limit,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TimelineControls = {
|
export function createTimeline(
|
||||||
/**
|
timeline: Accessor<Timeline>,
|
||||||
* The threads.
|
limit: Accessor<number>,
|
||||||
*
|
) {
|
||||||
* 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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
|
||||||
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
|
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
|
||||||
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
|
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
|
||||||
|
|
||||||
const [chunk, { refetch }] = createTimelineChunk(timeline, params);
|
const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const chk = catchError(chunk, (e) => console.error(e));
|
const chk = catchError(chunk, (e) => console.error(e));
|
||||||
|
@ -272,29 +188,24 @@ export function createTimeline<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chk.rebuilt) {
|
||||||
|
lookup.clear();
|
||||||
|
setThreads([]);
|
||||||
|
}
|
||||||
|
|
||||||
const existence = [] as boolean[];
|
const existence = [] as boolean[];
|
||||||
|
|
||||||
batch(() => {
|
for (const [idx, status] of chk.chunk.entries()) {
|
||||||
if (chk.rebuilt) {
|
existence[idx] = !!untrack(() => lookup.get(status.id));
|
||||||
lookup.clear();
|
lookup.set(status.id, {
|
||||||
setThreads([]);
|
value: status,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
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 status of chk.chunk) {
|
||||||
|
const node = untrack(() => lookup.get(status.id))!;
|
||||||
|
if (status.inReplyToId) {
|
||||||
|
const parent = lookup.get(status.inReplyToId);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const children = parent.children ?? [];
|
const children = parent.children ?? [];
|
||||||
if (!children.find((x) => x.value.id == status.id)) {
|
if (!children.find((x) => x.value.id == status.id)) {
|
||||||
|
@ -304,7 +215,7 @@ export function createTimeline<
|
||||||
node.parent = parent;
|
node.parent = parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const nThreadIds = chk.chunk
|
const nThreadIds = chk.chunk
|
||||||
.filter((x, i) => !existence[i])
|
.filter((x, i) => !existence[i])
|
||||||
|
@ -317,18 +228,29 @@ export function createTimeline<
|
||||||
setThreads((threads) => [...nThreadIds, ...threads]);
|
setThreads((threads) => [...nThreadIds, ...threads]);
|
||||||
}
|
}
|
||||||
|
|
||||||
untrack(() => {
|
setThreads((threads) =>
|
||||||
setThreads((threads) =>
|
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
|
||||||
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
list: threads,
|
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,
|
chunk,
|
||||||
{ refetch },
|
{ refetch },
|
||||||
|
|
|
@ -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;
|
border: none;
|
||||||
position: fixed;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
@ -11,20 +14,20 @@
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
height: 95%;
|
|
||||||
contain: strict;
|
|
||||||
contain-intrinsic-size: auto 560px auto 95vh;
|
|
||||||
|
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
background: transparent;
|
background-color: black;
|
||||||
|
opacity: 0.5;
|
||||||
transition: background-color 120ms var(--tutu-anim-curve-std);
|
transition: opacity 220ms var(--tutu-anim-curve-std);
|
||||||
transition-behavior: allow-discrete;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
box-shadow: var(--tutu-shadow-e16);
|
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) {
|
@media (max-width: 560px) {
|
||||||
& {
|
& {
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -33,38 +36,42 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
max-height: 100vh;
|
||||||
contain-intrinsic-size: auto 100vw 100vh;
|
max-height: 100dvh;
|
||||||
contain-intrinsic-size: auto 100dvw 100dvh;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.animated {
|
&.animated {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
|
transform: translateY(-50%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
will-change: width, height, top, left;
|
will-change: width, height, top, left;
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
& * {
|
& * {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
& {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bottom {
|
&.bottom {
|
||||||
top: unset;
|
top: unset;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: auto;
|
|
||||||
contain: content;
|
|
||||||
contain-intrinsic-size: unset;
|
|
||||||
|
|
||||||
&[open]::backdrop {
|
|
||||||
background: var(--tutu-color-shadow-l1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
& {
|
& {
|
||||||
transform: none;
|
transform: none;
|
||||||
height: auto;
|
height: unset;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -4,33 +4,44 @@ import {
|
||||||
createSignal,
|
createSignal,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
useTransition,
|
useTransition,
|
||||||
type JSX,
|
|
||||||
type ParentComponent,
|
type ParentComponent,
|
||||||
type ResolvedChildren,
|
type ResolvedChildren,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./BottomSheet.css";
|
import styles from "./BottomSheet.module.css";
|
||||||
import material from "./material.module.css";
|
import { useHeroSignal } from "../platform/anim";
|
||||||
import {
|
|
||||||
ANIM_CURVE_ACELERATION,
|
|
||||||
ANIM_CURVE_DECELERATION,
|
|
||||||
} from "./theme";
|
|
||||||
import {
|
|
||||||
animateSlideInFromRight,
|
|
||||||
animateSlideOutToRight,
|
|
||||||
} from "~platform/anim";
|
|
||||||
|
|
||||||
export type BottomSheetProps = {
|
export type BottomSheetProps = {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
bottomUp?: boolean;
|
bottomUp?: boolean;
|
||||||
class?: JSX.HTMLAttributes<HTMLElement>["class"];
|
|
||||||
onClose?(reason: "backdrop"): void;
|
onClose?(reason: "backdrop"): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOVE_SPEED = 1600;
|
export const HERO = Symbol("BottomSheet Hero Symbol");
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVE_SPEED = 1200;
|
||||||
|
|
||||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
let element: HTMLDialogElement;
|
let element: HTMLDialogElement;
|
||||||
let animation: Animation | undefined;
|
let animation: Animation | undefined;
|
||||||
|
const [hero, setHero] = useHeroSignal(HERO);
|
||||||
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
const [cache, setCache] = createSignal<ResolvedChildren | undefined>();
|
||||||
const ochildren = children(() => props.children);
|
const ochildren = children(() => props.children);
|
||||||
|
|
||||||
|
@ -51,52 +62,88 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
|
const srcElement = hero();
|
||||||
|
if (srcElement) {
|
||||||
|
srcElement.style.visibility = "unset";
|
||||||
|
}
|
||||||
|
|
||||||
element.close();
|
element.close();
|
||||||
|
setHero();
|
||||||
};
|
};
|
||||||
|
|
||||||
const animatedClose = () => {
|
const animatedClose = () => {
|
||||||
|
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) {
|
if (window.innerWidth > 560 && !props.bottomUp) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onAnimationEnd = () => {
|
const animation = props.bottomUp
|
||||||
element.classList.remove("animated");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
element.classList.add("animated");
|
|
||||||
animation = props.bottomUp
|
|
||||||
? animateSlideInFromBottom(element, true)
|
? animateSlideInFromBottom(element, true)
|
||||||
: animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
|
: animateSlideInFromRight(element, true);
|
||||||
animation.addEventListener("finish", onAnimationEnd);
|
animation.addEventListener("finish", onClose);
|
||||||
animation.addEventListener("cancel", onAnimationEnd);
|
animation.addEventListener("cancel", onClose);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const animatedOpen = () => {
|
const animatedOpen = () => {
|
||||||
element.showModal();
|
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);
|
animateSlideInFromBottom(element);
|
||||||
} else if (window.innerWidth <= 560) {
|
} else if (window.innerWidth <= 560) {
|
||||||
element.classList.add("animated");
|
animateSlideInFromRight(element);
|
||||||
const onAnimationEnd = () => {
|
|
||||||
element.classList.remove("animated");
|
|
||||||
};
|
|
||||||
animation = animateSlideInFromRight(element, {
|
|
||||||
easing: ANIM_CURVE_DECELERATION,
|
|
||||||
});
|
|
||||||
animation.addEventListener("finish", onAnimationEnd);
|
|
||||||
animation.addEventListener("cancel", onAnimationEnd);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = (
|
const animateSlideInFromBottom = (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
reserve?: boolean,
|
reserve?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||||
element.classList.add("animated");
|
element.classList.add(styles.animated);
|
||||||
const oldOverflow = document.body.style.overflow;
|
const oldOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
const distance = Math.abs(rect.top - window.innerHeight);
|
const distance = Math.abs(rect.top - window.innerHeight);
|
||||||
|
@ -104,14 +151,17 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
|
|
||||||
animation = element.animate(
|
animation = element.animate(
|
||||||
{
|
{
|
||||||
|
left: [`${rect.left}px`, `${rect.left}px`],
|
||||||
top: reserve
|
top: reserve
|
||||||
? [`${rect.top}px`, `${window.innerHeight}px`]
|
? [`${rect.top}px`, `${window.innerHeight}px`]
|
||||||
: [`${window.innerHeight}px`, `${rect.top}px`],
|
: [`${window.innerHeight}px`, `${rect.top}px`],
|
||||||
|
width: [`${rect.width}px`, `${rect.width}px`],
|
||||||
|
height: [`${rect.height}px`, `${rect.height}px`],
|
||||||
},
|
},
|
||||||
{ easing, duration },
|
{ easing, duration },
|
||||||
);
|
);
|
||||||
const onAnimationEnd = () => {
|
const onAnimationEnd = () => {
|
||||||
element.classList.remove("animated");
|
element.classList.remove(styles.animated);
|
||||||
document.body.style.overflow = oldOverflow;
|
document.body.style.overflow = oldOverflow;
|
||||||
animation = undefined;
|
animation = undefined;
|
||||||
};
|
};
|
||||||
|
@ -120,6 +170,35 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
return animation;
|
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(() => {
|
onCleanup(() => {
|
||||||
if (animation) {
|
if (animation) {
|
||||||
animation.cancel();
|
animation.cancel();
|
||||||
|
@ -129,35 +208,25 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
|
||||||
const onDialogClick = (
|
const onDialogClick = (
|
||||||
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
event: MouseEvent & { currentTarget: HTMLDialogElement },
|
||||||
) => {
|
) => {
|
||||||
if (event.target !== event.currentTarget) return;
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
const isNotInDialog =
|
const isInDialog =
|
||||||
event.clientY < rect.top ||
|
rect.top <= event.clientY &&
|
||||||
event.clientY > rect.bottom ||
|
event.clientY <= rect.top + rect.height &&
|
||||||
event.clientX < rect.left ||
|
rect.left <= event.clientX &&
|
||||||
event.clientX > rect.right;
|
event.clientX <= rect.left + rect.width;
|
||||||
if (isNotInDialog) {
|
if (!isInDialog) {
|
||||||
props.onClose?.("backdrop");
|
props.onClose?.("backdrop");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDialogCancel = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
props.onClose?.("backdrop");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
class={`BottomSheet ${material.surface} ${props.class || ""}`}
|
|
||||||
classList={{
|
classList={{
|
||||||
["bottom"]: props.bottomUp,
|
[styles.bottomSheet]: true,
|
||||||
|
[styles.bottom]: props.bottomUp,
|
||||||
}}
|
}}
|
||||||
onClick={onDialogClick}
|
onClick={onDialogClick}
|
||||||
onCancel={onDialogCancel}
|
|
||||||
ref={element!}
|
ref={element!}
|
||||||
tabIndex={-1}
|
|
||||||
role="presentation"
|
|
||||||
>
|
>
|
||||||
{ochildren() ?? cache()}
|
{ochildren() ?? cache()}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Component, JSX, splitProps } from "solid-js";
|
import { Component, JSX, splitProps } from "solid-js";
|
||||||
import materialStyles from "./material.module.css";
|
import materialStyles from "./material.module.css";
|
||||||
import "./typography.css";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material-styled button.
|
* Material-styled button.
|
||||||
|
@ -11,15 +10,12 @@ const Button: Component<JSX.ButtonHTMLAttributes<HTMLButtonElement>> = (
|
||||||
props,
|
props,
|
||||||
) => {
|
) => {
|
||||||
const [managed, passthough] = splitProps(props, ["class", "type"]);
|
const [managed, passthough] = splitProps(props, ["class", "type"]);
|
||||||
|
const classes = () =>
|
||||||
|
managed.class
|
||||||
|
? [materialStyles.button, managed.class].join(" ")
|
||||||
|
: materialStyles.button;
|
||||||
const type = () => managed.type ?? "button";
|
const type = () => managed.type ?? "button";
|
||||||
|
return <button type={type()} class={classes()} {...passthough}></button>;
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type={type()}
|
|
||||||
class={`${materialStyles.button} buttonText ${managed.class || ""}`}
|
|
||||||
{...passthough}
|
|
||||||
></button>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|
|
@ -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,44 +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);
|
|
||||||
|
|
||||||
>.MuiButtonBase-root {
|
|
||||||
&:first-child {
|
|
||||||
margin-left: -0.5em;
|
|
||||||
margin-right: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-right: -0.5em;
|
|
||||||
margin-left: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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%;
|
|
||||||
background-color: var(--tutu-color-surface);
|
|
||||||
}
|
|
|
@ -1,75 +1,68 @@
|
||||||
import { createElementSize } from "@solid-primitives/resize-observer";
|
import { createElementSize } from "@solid-primitives/resize-observer";
|
||||||
import {
|
import {
|
||||||
JSX,
|
|
||||||
Show,
|
Show,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createSignal,
|
createSignal,
|
||||||
splitProps,
|
onCleanup,
|
||||||
type Component,
|
type JSX,
|
||||||
type ParentProps,
|
type ParentComponent,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./Scaffold.css";
|
import { css } from "solid-styled";
|
||||||
|
|
||||||
type ScaffoldProps = ParentProps<
|
interface ScaffoldProps {
|
||||||
{
|
topbar?: JSX.Element;
|
||||||
topbar?: JSX.Element;
|
fab?: JSX.Element;
|
||||||
fab?: JSX.Element;
|
bottom?: JSX.Element;
|
||||||
bottom?: JSX.Element;
|
}
|
||||||
} & JSX.HTMLElementTags["div"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
const Scaffold: ParentComponent<ScaffoldProps> = (props) => {
|
||||||
* The passthrough props are passed to the content container.
|
|
||||||
*/
|
|
||||||
const Scaffold: Component<ScaffoldProps> = (props) => {
|
|
||||||
const [managed, rest] = splitProps(props, [
|
|
||||||
"topbar",
|
|
||||||
"fab",
|
|
||||||
"bottom",
|
|
||||||
"children",
|
|
||||||
"ref",
|
|
||||||
"class",
|
|
||||||
]);
|
|
||||||
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
|
const [topbarElement, setTopbarElement] = createSignal<HTMLElement>();
|
||||||
|
|
||||||
const topbarSize = createElementSize(topbarElement);
|
const topbarSize = createElementSize(topbarElement);
|
||||||
|
|
||||||
return (
|
css`
|
||||||
<div
|
.scaffold-content {
|
||||||
class={`Scaffold ${managed.class || ""}`}
|
--scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"};
|
||||||
ref={(e) => {
|
}
|
||||||
createRenderEffect(() => {
|
|
||||||
e.style.setProperty(
|
|
||||||
"--scaffold-topbar-height",
|
|
||||||
(topbarSize.height?.toString() ?? 0) + "px",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (managed.ref) {
|
.topbar {
|
||||||
(managed.ref as (val: typeof e) => void)(e);
|
position: sticky;
|
||||||
}
|
top: 0px;
|
||||||
}}
|
z-index: var(--tutu-zidx-nav, auto);
|
||||||
{...rest}
|
}
|
||||||
>
|
|
||||||
|
.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 (
|
||||||
|
<>
|
||||||
<Show when={props.topbar}>
|
<Show when={props.topbar}>
|
||||||
<div class="topbar" ref={setTopbarElement} role="presentation">
|
<div class="topbar" ref={setTopbarElement}>
|
||||||
{props.topbar}
|
{props.topbar}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.fab}>
|
<Show when={props.fab}>
|
||||||
<div class="fab-dock" role="presentation">
|
<div class="fab-dock">{props.fab}</div>
|
||||||
{props.fab}
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="scaffold-content">{props.children}</div>
|
||||||
{managed.children}
|
|
||||||
|
|
||||||
<Show when={props.bottom}>
|
<Show when={props.bottom}>
|
||||||
<div class="bottom-dock" role="presentation">
|
<div class="bottom-dock">{props.bottom}</div>
|
||||||
{props.bottom}
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
composes: buttonText from "./typography.module.css";
|
||||||
composes: touchTarget;
|
composes: touchTarget;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
@ -2,9 +2,6 @@ import { Theme, createTheme } from "@suid/material/styles";
|
||||||
import { deepPurple, amber } from "@suid/material/colors";
|
import { deepPurple, amber } from "@suid/material/colors";
|
||||||
import { Accessor } from "solid-js";
|
import { Accessor } from "solid-js";
|
||||||
|
|
||||||
/**
|
|
||||||
* The MUI theme.
|
|
||||||
*/
|
|
||||||
export function useRootTheme(): Accessor<Theme> {
|
export function useRootTheme(): Accessor<Theme> {
|
||||||
return () =>
|
return () =>
|
||||||
createTheme({
|
createTheme({
|
||||||
|
@ -18,8 +15,3 @@ export function useRootTheme(): Accessor<Theme> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";
|
|
||||||
export const ANIM_CURVE_DECELERATION = "cubic-bezier(0, 0, 0.2, 1)";
|
|
||||||
export const ANIM_CURVE_ACELERATION = "cubic-bezier(0.4, 0, 1, 1)";
|
|
||||||
export const ANIM_CURVE_SHARP = "cubic-bezier(0.4, 0, 0.6, 1)";
|
|
|
@ -82,44 +82,27 @@
|
||||||
--tutu-color-error-on-surface: #d32f2f;
|
--tutu-color-error-on-surface: #d32f2f;
|
||||||
--tutu-color-inactive-on-surface: #757575;
|
--tutu-color-inactive-on-surface: #757575;
|
||||||
|
|
||||||
--tutu-color-shadow: rgba(0, 0, 0, 0.45);
|
--tutu-shadow-e1: 0px 1px 2px 0px #9e9e9e;
|
||||||
--tutu-color-shadow-l1: rgba(0, 0, 0, 0.4);
|
|
||||||
--tutu-color-shadow-l2: rgba(0, 0, 0, 0.35);
|
|
||||||
|
|
||||||
/* Switch */
|
/* 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 */
|
/* (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) */
|
/* 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 */
|
/* 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) */
|
/* 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 */
|
/* 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) */
|
/* Submenu (+1dp for each submenu) */
|
||||||
--tutu-shadow-e9: 0px 9px 18px 0px var(--tutu-color-shadow);
|
--tutu-shadow-e12: 0px 12px 24px 0px #9e9e9e;
|
||||||
--tutu-shadow-e10: 0px 10px 18px 0px var(--tutu-color-shadow);
|
|
||||||
--tutu-shadow-e11: 0px 11px 18px 0px var(--tutu-color-shadow-l1);
|
|
||||||
|
|
||||||
/* (pressed) FAB */
|
/* (pressed) FAB */
|
||||||
--tutu-shadow-e12: 0px 12px 24px 0px var(--tutu-color-shadow-l1);
|
--tutu-shadow-e16: 0px 16px 32px 0px #9e9e9e;
|
||||||
|
|
||||||
/* Nav drawer, right drawer, modal bottom sheet */
|
/* 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 */
|
/* 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-std: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--tutu-anim-curve-deceleration: cubic-bezier(0, 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);
|
--tutu-anim-curve-aceleration: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
|
|
@ -29,11 +29,12 @@
|
||||||
font-size: var(--subheading-size);
|
font-size: var(--subheading-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body1, .body2 {
|
.body1 {
|
||||||
font-size: var(--body-size);
|
font-size: var(--body-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.body2 {
|
.body2 {
|
||||||
|
composes: body1;
|
||||||
font-weight: var(--body2-weight);
|
font-weight: var(--body2-weight);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
|
import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
|
||||||
import { Dynamic } from "solid-js/web";
|
import { Dynamic } from "solid-js/web";
|
||||||
import "./typography.css";
|
import typography from "./typography.module.css";
|
||||||
|
import { mergeClass } from "../utils";
|
||||||
|
|
||||||
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
|
type AnyElement = keyof JSX.IntrinsicElements | ParentComponent<any>;
|
||||||
|
|
||||||
|
@ -39,11 +40,13 @@ export function Typography<T extends AnyElement>(
|
||||||
"class",
|
"class",
|
||||||
"typography",
|
"typography",
|
||||||
]);
|
]);
|
||||||
|
const classes = () =>
|
||||||
|
mergeClass(managed.class, typography[managed.typography]);
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<Dynamic
|
||||||
ref={managed.ref}
|
ref={managed.ref}
|
||||||
component={managed.component ?? "span"}
|
component={managed.component ?? "span"}
|
||||||
class={`${managed.class || ""} ${managed.typography}`}
|
class={classes()}
|
||||||
{...passthough}
|
{...passthough}
|
||||||
></Dynamic>
|
></Dynamic>
|
||||||
);
|
);
|
||||||
|
|
12
src/overrides.d.ts
vendored
12
src/overrides.d.ts
vendored
|
@ -3,18 +3,6 @@
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly BUILT_AT: string;
|
readonly BUILT_AT: string;
|
||||||
readonly PACKAGE_VERSION: 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_PLATFROM_MASONRY_ALWAYS_COMPAT?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
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,16 +0,0 @@
|
||||||
.CompatMasonry>* {
|
|
||||||
margin-bottom: var(--Masonry-row-gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (grid-template-rows: masonry) {
|
|
||||||
.NativeMasonry {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(44px, min-content));
|
|
||||||
grid-template-rows: masonry;
|
|
||||||
|
|
||||||
&:has(> :last-child:nth-child(2n)) {
|
|
||||||
grid-template-columns: repeat(2, minmax(auto, min-content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,158 +0,0 @@
|
||||||
import {
|
|
||||||
type Component,
|
|
||||||
type JSX,
|
|
||||||
splitProps,
|
|
||||||
type Ref,
|
|
||||||
createRenderEffect,
|
|
||||||
onCleanup,
|
|
||||||
children,
|
|
||||||
createEffect,
|
|
||||||
createSignal,
|
|
||||||
onMount,
|
|
||||||
} 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;
|
|
||||||
children?: JSX.Element;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
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 createMasonry(element: Element, options: () => MasonryLayout.Options) {
|
|
||||||
const layout = new MasonryLayout(element, {
|
|
||||||
initLayout: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => layout.destroy?.());
|
|
||||||
|
|
||||||
const size = createElementSize(element);
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
const opts = options();
|
|
||||||
layout.option?.(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
const width = size.width; // only tracking width
|
|
||||||
layout.layout?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.on("vite:afterUpdate", () => {
|
|
||||||
layout.layout?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const supportsCSSMasonryLayout = /* @__PURE__ */ CSS.supports(
|
|
||||||
"grid-template-rows",
|
|
||||||
"masonry",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.debug("supports css masonry layout", supportsCSSMasonryLayout);
|
|
||||||
|
|
||||||
const useNativeImpl = import.meta.env.VITE_PLATFROM_MASONRY_ALWAYS_COMPAT
|
|
||||||
? false
|
|
||||||
: supportsCSSMasonryLayout;
|
|
||||||
|
|
||||||
if (import.meta.env.VITE_PLATFROM_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", "children", "class"]);
|
|
||||||
|
|
||||||
const childrenComponents = children(() => props.children);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
ref={(element: ElementOf<T>) => {
|
|
||||||
forwardRef(element, props.ref as Ref<typeof element> | undefined);
|
|
||||||
|
|
||||||
const [columnGap, setColumnGap] = createSignal<number>();
|
|
||||||
|
|
||||||
const layout = createMasonry(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)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
childrenComponents(); // just tracks
|
|
||||||
setTimeout(() => {
|
|
||||||
layout.reloadItems?.();
|
|
||||||
layout.layout?.();
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
class={`Masonry CompatMasonry ${props.class || ""}`}
|
|
||||||
{...rest}
|
|
||||||
children={childrenComponents}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* **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,68 +0,0 @@
|
||||||
.StackedPage {
|
|
||||||
container: StackedPage / size;
|
|
||||||
display: contents;
|
|
||||||
max-width: 100vw;
|
|
||||||
max-width: 100dvw;
|
|
||||||
|
|
||||||
contain: layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.StackedPage {
|
|
||||||
border: none;
|
|
||||||
position: fixed;
|
|
||||||
padding: 0;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
width: 560px;
|
|
||||||
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: auto 560px auto 100vh;
|
|
||||||
contain-intrinsic-size: auto 560px auto 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: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::backdrop {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.animating {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
* {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,592 +0,0 @@
|
||||||
import { StaticRouter, type RouterProps } from "@solidjs/router";
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
createContext,
|
|
||||||
createMemo,
|
|
||||||
createRenderEffect,
|
|
||||||
createUniqueId,
|
|
||||||
Index,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
untrack,
|
|
||||||
useContext,
|
|
||||||
type Accessor,
|
|
||||||
} 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";
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
replace?: boolean;
|
|
||||||
/**
|
|
||||||
* 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]>>) => Readonly<StackFrame>
|
|
||||||
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => 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();
|
|
||||||
const isNotInDialog =
|
|
||||||
event.clientY < rect.top ||
|
|
||||||
event.clientY > rect.bottom ||
|
|
||||||
event.clientX < rect.left ||
|
|
||||||
event.clientX > rect.right;
|
|
||||||
if (isNotInDialog) {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
|
||||||
untrack(() => {
|
|
||||||
const frame = {
|
|
||||||
path,
|
|
||||||
state: opts?.state,
|
|
||||||
rootId: createUniqueId(),
|
|
||||||
animateOpen: opts?.animateOpen,
|
|
||||||
animateClose: opts?.animateClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
|
|
||||||
if (opts?.replace) {
|
|
||||||
window.history.replaceState(serializableStack(stack), "", path);
|
|
||||||
} else {
|
|
||||||
window.history.pushState(serializableStack(stack), "", path);
|
|
||||||
}
|
|
||||||
return frame;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onlyPopFrame = (depth: number) => {
|
|
||||||
mutStack((o) => o.toSpliced(o.length - depth, depth));
|
|
||||||
window.history.go(-depth);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const lastFrame = stack[stack.length - 1];
|
|
||||||
const element = document.getElementById(
|
|
||||||
lastFrame.rootId,
|
|
||||||
)! as HTMLDialogElement;
|
|
||||||
const createAnimation = lastFrame.animateClose ?? animateClose;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
element.classList.add("animating");
|
|
||||||
const animation = createAnimation(element);
|
|
||||||
animation.addEventListener("finish", () => {
|
|
||||||
element.classList.remove("animating");
|
|
||||||
onlyPopFrame(depth);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onlyPopFrame(depth);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
if (stack.length === 0) {
|
|
||||||
mutStack(0, {
|
|
||||||
path: window.location.pathname,
|
|
||||||
rootId: createUniqueId(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
makeEventListener(window, "popstate", (event) => {
|
|
||||||
if (!event.state) return;
|
|
||||||
|
|
||||||
if (stack.length === 0) {
|
|
||||||
mutStack(event.state);
|
|
||||||
} else if (stack.length > event.state.length) {
|
|
||||||
popFrame(stack.length - event.state.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
frame: 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(
|
export type HeroSource = {
|
||||||
root: HTMLElement,
|
[key: string | symbol | number]: HTMLElement | undefined;
|
||||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
};
|
||||||
) {
|
|
||||||
const overflow = root.style.overflow;
|
|
||||||
root.style.overflow = "hidden";
|
|
||||||
|
|
||||||
const { height } = root.getBoundingClientRect();
|
const HeroSourceContext = createContext<Signal<HeroSource>>(
|
||||||
|
/* __@PURE__ */ undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const opts = Object.assign(
|
export const HeroSourceProvider = HeroSourceContext.Provider;
|
||||||
{
|
|
||||||
duration: Math.floor((height / 1600) * 1000),
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
const animation = root.animate(
|
function useHeroSource() {
|
||||||
{
|
return useContext(HeroSourceContext);
|
||||||
height: ["0px", `${height}px`],
|
|
||||||
},
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
|
|
||||||
const restore = () => (root.style.overflow = overflow);
|
|
||||||
|
|
||||||
animation.addEventListener("finish", restore);
|
|
||||||
animation.addEventListener("cancel", restore);
|
|
||||||
|
|
||||||
return animation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function animateRollInFromBottom(
|
/**
|
||||||
root: HTMLElement,
|
* Use hero value for the {@link key}.
|
||||||
options?: Omit<KeyframeAnimationOptions, "duration">,
|
*/
|
||||||
) {
|
export function useHeroSignal(
|
||||||
const overflow = root.style.overflow;
|
key: string | symbol | number,
|
||||||
root.style.overflow = "hidden";
|
): 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(
|
return [get, set];
|
||||||
{
|
|
||||||
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;
|
|
||||||
} else {
|
} 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,37 +1,12 @@
|
||||||
import { createContext, useContext, type Accessor } from "solid-js";
|
|
||||||
import { useRegisterSW } from "virtual:pwa-register/solid";
|
|
||||||
|
|
||||||
export function isiOS() {
|
export function isiOS() {
|
||||||
return (
|
return [
|
||||||
[
|
'iPad Simulator',
|
||||||
"iPad Simulator",
|
'iPhone Simulator',
|
||||||
"iPhone Simulator",
|
'iPod Simulator',
|
||||||
"iPod Simulator",
|
'iPad',
|
||||||
"iPad",
|
'iPhone',
|
||||||
"iPhone",
|
'iPod'
|
||||||
"iPod",
|
].includes(navigator.platform)
|
||||||
].includes(navigator.platform) ||
|
// iPad on iOS 13 detection
|
||||||
// iPad on iOS 13 detection
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||||
(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);
|
|
||||||
}
|
}
|
|
@ -11,11 +11,7 @@ import { $settings } from "../settings/stores";
|
||||||
import { enGB } from "date-fns/locale/en-GB";
|
import { enGB } from "date-fns/locale/en-GB";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import type { Locale } from "date-fns";
|
import type { Locale } from "date-fns";
|
||||||
import {
|
import { resolveTemplate, translator, type Template } from "@solid-primitives/i18n";
|
||||||
resolveTemplate,
|
|
||||||
translator,
|
|
||||||
type Template,
|
|
||||||
} from "@solid-primitives/i18n";
|
|
||||||
|
|
||||||
async function synchronised(
|
async function synchronised(
|
||||||
name: string,
|
name: string,
|
||||||
|
@ -38,53 +34,43 @@ export function autoMatchLangTag() {
|
||||||
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateFnLocaleCx = /* __@PURE__ */ createContext<Accessor<Locale>>(
|
const DateFnLocaleCx = /* __@PURE__ */createContext<Accessor<Locale>>(() => enGB);
|
||||||
() => enGB,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cachedDateFnLocale: Record<string, Locale> = {
|
const cachedDateFnLocale: Record<string, Locale> = {
|
||||||
enGB,
|
enGB,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function autoMatchRegion() {
|
export function autoMatchRegion() {
|
||||||
const specifiers = navigator.languages.map((x) => x.split("-"));
|
const regions = navigator.languages
|
||||||
|
.map((x) => {
|
||||||
for (const s of specifiers) {
|
const parts = x.split("_");
|
||||||
if (s.length === 1) {
|
if (parts.length > 1) {
|
||||||
const lang = s[0];
|
return parts[1];
|
||||||
for (const available of SUPPORTED_REGIONS) {
|
|
||||||
if (available.toLowerCase().startsWith(lang.toLowerCase())) {
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (s.length === 2) {
|
return undefined;
|
||||||
const [lang, region] = s;
|
})
|
||||||
for (const available of SUPPORTED_REGIONS) {
|
.filter((x): x is string => !!x);
|
||||||
if (available.toLowerCase() === `${lang}_${region}`.toLowerCase()) {
|
for (const r of regions) {
|
||||||
return available;
|
for (const available of SUPPORTED_REGIONS) {
|
||||||
}
|
if (available.toLowerCase().endsWith(r.toLowerCase())) {
|
||||||
|
return available;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "en_GB";
|
return "en_GB";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRegion() {
|
export function useRegion() {
|
||||||
const appSettings = useStore($settings);
|
const appSettings = useStore($settings);
|
||||||
|
|
||||||
return createMemo(
|
return createMemo(() => {
|
||||||
() => {
|
const settings = appSettings();
|
||||||
const settings = appSettings();
|
if (typeof settings.region !== "undefined") {
|
||||||
if (typeof settings.region !== "undefined") {
|
return settings.region;
|
||||||
return settings.region;
|
} else {
|
||||||
} else {
|
return autoMatchRegion();
|
||||||
return autoMatchRegion();
|
}
|
||||||
}
|
});
|
||||||
},
|
|
||||||
"en_GB",
|
|
||||||
{ name: "region" },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importDateFnLocale(tag: string): Promise<Locale> {
|
async function importDateFnLocale(tag: string): Promise<Locale> {
|
||||||
|
@ -92,7 +78,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
||||||
case "en_us":
|
case "en_us":
|
||||||
return (await import("date-fns/locale/en-US")).enUS;
|
return (await import("date-fns/locale/en-US")).enUS;
|
||||||
case "en_gb":
|
case "en_gb":
|
||||||
return enGB;
|
return (await import("date-fns/locale/en-GB")).enGB;
|
||||||
case "zh_cn":
|
case "zh_cn":
|
||||||
return (await import("date-fns/locale/zh-CN")).zhCN;
|
return (await import("date-fns/locale/zh-CN")).zhCN;
|
||||||
default:
|
default:
|
||||||
|
@ -104,9 +90,7 @@ async function importDateFnLocale(tag: string): Promise<Locale> {
|
||||||
* Provides runtime values and fetch dependencies for date-fns locale
|
* Provides runtime values and fetch dependencies for date-fns locale
|
||||||
*/
|
*/
|
||||||
export const DateFnScope: ParentComponent = (props) => {
|
export const DateFnScope: ParentComponent = (props) => {
|
||||||
const [dateFnLocale, setDateFnLocale] = createSignal(enGB, {
|
const [dateFnLocale, setDateFnLocale] = createSignal(enGB);
|
||||||
name: "dateFnLocale",
|
|
||||||
});
|
|
||||||
const region = useRegion();
|
const region = useRegion();
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
@ -164,22 +148,19 @@ export function useLanguage() {
|
||||||
return () => 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 []
|
type MergedImportedModule<T> =
|
||||||
? {}
|
T extends [] ? {} :
|
||||||
: T extends [infer I]
|
T extends [infer I] ? ImportedModule<I> :
|
||||||
? ImportedModule<I>
|
T extends [infer I, ...infer J] ? ImportedModule<I> & MergedImportedModule<J> : never
|
||||||
: T extends [infer I, ...infer J]
|
|
||||||
? ImportedModule<I> & MergedImportedModule<J>
|
|
||||||
: never;
|
|
||||||
|
|
||||||
export function createStringResource<
|
export function createStringResource<
|
||||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
||||||
>(...importFns: T) {
|
>(...importFns: T) {
|
||||||
const language = useLanguage(); // TODO: this function costs to much, provide a global cache
|
const language = useLanguage();
|
||||||
const cache: Record<string, MergedImportedModule<T>> = {};
|
const cache: Record<string, MergedImportedModule<T>> = {};
|
||||||
|
|
||||||
return createResource(
|
return createResource(
|
||||||
|
@ -189,11 +170,9 @@ export function createStringResource<
|
||||||
return cache[nlang];
|
return cache[nlang];
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(importFns.map(x => x(nlang).then(v => v.default)))
|
||||||
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;
|
cache[nlang] = merged;
|
||||||
|
|
||||||
|
@ -202,10 +181,8 @@ export function createStringResource<
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTranslator<
|
export function createTranslator<T extends ImportFn<Record<string, string | Template<any> | undefined>>[],>(...importFns: T) {
|
||||||
T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
|
const res = createStringResource(...importFns)
|
||||||
>(...importFns: T) {
|
|
||||||
const res = createStringResource(...importFns);
|
|
||||||
|
|
||||||
return [translator(res[0], resolveTemplate), res] as const;
|
return [translator(res[0], resolveTemplate), res] as const
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
//! This module has side effect.
|
//! This module has side effect.
|
||||||
//! It recommended to include the module by <script> tag.
|
//! It recommended to include the module by <script> tag.
|
||||||
|
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") {
|
if (typeof window.crypto.randomUUID === "undefined") {
|
||||||
// TODO: this polyfill can be removed in 2.0, see https://code.lightstands.xyz/Rubicon/tutu/issues/36
|
|
||||||
// Chrome/Edge 92+
|
// Chrome/Edge 92+
|
||||||
// https://stackoverflow.com/a/2117523/2800218
|
// https://stackoverflow.com/a/2117523/2800218
|
||||||
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
// LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||||
|
|
|
@ -4,8 +4,8 @@ import {
|
||||||
onCleanup,
|
onCleanup,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import BottomSheet from "~material/BottomSheet";
|
import BottomSheet from "../material/BottomSheet";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -14,9 +14,9 @@ import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Close as CloseIcon, ContentCopy } from "@suid/icons-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 { render } from "solid-js/web";
|
||||||
import { useRootTheme } from "~material/theme";
|
import { useRootTheme } from "../material/mui";
|
||||||
|
|
||||||
const ShareBottomSheet: Component<{
|
const ShareBottomSheet: Component<{
|
||||||
data?: ShareData;
|
data?: ShareData;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { usePageVisibility } from "@solid-primitives/page-visibility";
|
|
||||||
import {
|
import {
|
||||||
Accessor,
|
Accessor,
|
||||||
createContext,
|
createContext,
|
||||||
|
@ -16,30 +15,19 @@ export const TimeSourceProvider = TimeSourceContext.Provider;
|
||||||
export function createTimeSource() {
|
export function createTimeSource() {
|
||||||
let id: ReturnType<typeof setTimeout> | undefined;
|
let id: ReturnType<typeof setTimeout> | undefined;
|
||||||
const [get, set] = createSignal(new Date());
|
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") {
|
if (typeof id !== "undefined") {
|
||||||
clearInterval(id);
|
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;
|
return get;
|
||||||
|
|
|
@ -1,123 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Profile__page-title {
|
|
||||||
flex-grow: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
|
@ -1,553 +0,0 @@
|
||||||
import {
|
|
||||||
catchError,
|
|
||||||
createRenderEffect,
|
|
||||||
createResource,
|
|
||||||
createSignal,
|
|
||||||
createUniqueId,
|
|
||||||
For,
|
|
||||||
Switch,
|
|
||||||
Match,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
type Component,
|
|
||||||
createMemo,
|
|
||||||
} from "solid-js";
|
|
||||||
import Scaffold from "~material/Scaffold";
|
|
||||||
import {
|
|
||||||
AppBar,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
|
||||||
Divider,
|
|
||||||
IconButton,
|
|
||||||
ListItemAvatar,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemSecondaryAction,
|
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
|
||||||
Toolbar,
|
|
||||||
} 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 } 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";
|
|
||||||
|
|
||||||
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 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]) => {
|
|
||||||
return await client.v1.accounts.$select(id).fetch();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const profile = () => {
|
|
||||||
try {
|
|
||||||
return profileUncaught();
|
|
||||||
} catch (reason) {
|
|
||||||
console.error(reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentSessionProfile = () => {
|
|
||||||
return session().account?.inf?.url === profile()?.url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [recentTootFilter, setRecentTootFilter] = createSignal({
|
|
||||||
pinned: true,
|
|
||||||
boost: false,
|
|
||||||
reply: true,
|
|
||||||
original: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
|
|
||||||
createTimeline(
|
|
||||||
() => session().client.v1.accounts.$select(params.id).statuses,
|
|
||||||
() => {
|
|
||||||
const { boost, reply } = recentTootFilter();
|
|
||||||
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
|
|
||||||
() => session().client.v1.accounts.$select(params.id).statuses,
|
|
||||||
() => {
|
|
||||||
return { limit: 20, pinned: true };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
|
|
||||||
() => [session(), params.id] as const,
|
|
||||||
async ([sess, id]) => {
|
|
||||||
if (!sess.account) 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?.inf?.displayName || "",
|
|
||||||
session().account?.inf?.emojis ?? [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const useSessionDisplayName = (e: HTMLElement) => {
|
|
||||||
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSubscribeHome = async (event: Event) => {
|
|
||||||
const client = session().client;
|
|
||||||
if (!session().account) 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(params.id).unfollow();
|
|
||||||
mutateRelationship(nrel);
|
|
||||||
} else {
|
|
||||||
const nrel = await client.v1.accounts.$select(params.id).follow();
|
|
||||||
mutateRelationship(nrel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Scaffold
|
|
||||||
topbar={
|
|
||||||
<AppBar
|
|
||||||
role="navigation"
|
|
||||||
position="static"
|
|
||||||
color={scrolledPastBanner() ? "primary" : "transparent"}
|
|
||||||
elevation={scrolledPastBanner() ? undefined : 0}
|
|
||||||
>
|
|
||||||
<Toolbar
|
|
||||||
variant="dense"
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
color: scrolledPastBanner()
|
|
||||||
? undefined
|
|
||||||
: bannerSampledColors()?.text,
|
|
||||||
paddingTop: "var(--safe-area-inset-top)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton color="inherit" onClick={[pop, 1]} aria-label="Close">
|
|
||||||
<Close />
|
|
||||||
</IconButton>
|
|
||||||
<Title
|
|
||||||
class="Profile__page-title"
|
|
||||||
style={{
|
|
||||||
visibility: scrolledPastBanner() ? undefined : "hidden",
|
|
||||||
}}
|
|
||||||
ref={(e: HTMLElement) =>
|
|
||||||
createRenderEffect(() => (e.innerHTML = displayName()))
|
|
||||||
}
|
|
||||||
></Title>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
id={menuButId}
|
|
||||||
aria-controls={optMenuId}
|
|
||||||
color="inherit"
|
|
||||||
onClick={[setMenuOpen, true]}
|
|
||||||
aria-label="Open Options for the Profile"
|
|
||||||
>
|
|
||||||
<MoreVert />
|
|
||||||
</IconButton>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
}
|
|
||||||
class="Profile"
|
|
||||||
>
|
|
||||||
<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?.inf?.avatar} />
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText secondary={"Default account"}>
|
|
||||||
<span ref={useSessionDisplayName}></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?.inf?.avatar}></Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
secondary={
|
|
||||||
relationship()?.following
|
|
||||||
? undefined
|
|
||||||
: profile()?.locked
|
|
||||||
? "A request will be sent"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span ref={useSessionDisplayName}></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"
|
|
||||||
ref={(e: HTMLElement) =>
|
|
||||||
createRenderEffect(() => (e.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`}
|
|
||||||
ref={(e) =>
|
|
||||||
createRenderEffect(() => (e.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
|
|
||||||
ref={(e) => {
|
|
||||||
createRenderEffect(() => (e.innerHTML = item.value));
|
|
||||||
}}
|
|
||||||
></td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toot-list-toolbar">
|
|
||||||
<TootFilterButton
|
|
||||||
options={{
|
|
||||||
pinned: "Pinneds",
|
|
||||||
boost: "Boosts",
|
|
||||||
reply: "Replies",
|
|
||||||
original: "Originals",
|
|
||||||
}}
|
|
||||||
applied={recentTootFilter()}
|
|
||||||
onApply={setRecentTootFilter}
|
|
||||||
disabledKeys={["original"]}
|
|
||||||
></TootFilterButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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,34 +0,0 @@
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
|
||||||
import { clientsClaim } from "workbox-core";
|
|
||||||
import { dispatchCall, isJSONRPCCall, type Call } from "./workerrpc";
|
|
||||||
|
|
||||||
function isServiceWorker(
|
|
||||||
self: WorkerGlobalScope,
|
|
||||||
): self is ServiceWorkerGlobalScope {
|
|
||||||
return !!(self as unknown as ServiceWorkerGlobalScope).registration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServiceWorker(self)) {
|
|
||||||
cleanupOutdatedCaches();
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST, {
|
|
||||||
cleanURLs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// auto update
|
|
||||||
self.skipWaiting();
|
|
||||||
clientsClaim();
|
|
||||||
} else {
|
|
||||||
throw new TypeError("This entry point must be run in a service worker");
|
|
||||||
}
|
|
||||||
|
|
||||||
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>>);
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,3 +0,0 @@
|
||||||
export type Service = {
|
|
||||||
ping(): void
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext", "WebWorker"],
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,214 +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;
|
|
||||||
if (typeof callback !== "boolean") {
|
|
||||||
callback(message);
|
|
||||||
this.map.delete(id);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
let retried = 0;
|
|
||||||
|
|
||||||
const checkAndDispatch = () => {
|
|
||||||
const callback = this.map.get(id);
|
|
||||||
if (typeof callback !== "boolean") {
|
|
||||||
callback(message);
|
|
||||||
this.map.delete(id);
|
|
||||||
resolve();
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) | undefined>
|
|
||||||
|
|
||||||
export async function dispatchCall<
|
|
||||||
S extends AnyService,
|
|
||||||
>(service: S, event: MessageEvent<Call<unknown>>) {
|
|
||||||
try {
|
|
||||||
const fn = service[event.data.method];
|
|
||||||
if (!fn) {
|
|
||||||
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>);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { createMemo, For, type Component, type JSX } from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -19,17 +19,17 @@ import {
|
||||||
autoMatchLangTag,
|
autoMatchLangTag,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
SUPPORTED_LANGS,
|
SUPPORTED_LANGS,
|
||||||
} from "~platform/i18n";
|
} from "../platform/i18n";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
import type { Template } from "@solid-primitives/i18n";
|
import type { Template } from "@solid-primitives/i18n";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
|
||||||
const ChooseLang: Component = () => {
|
const ChooseLang: Component = () => {
|
||||||
const { pop } = useNavigator();
|
const navigate = useNavigate()
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
() => import("./i18n/generic.json"),
|
() => import("./i18n/lang-names.json"),
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Record<string, string | undefined> & {
|
default: Record<string, string | undefined> & {
|
||||||
|
@ -37,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(() => {
|
const unsupportedLangCodes = createMemo(() => {
|
||||||
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
|
return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x));
|
||||||
|
@ -48,8 +48,8 @@ const ChooseLang: Component = () => {
|
||||||
const matchedLangCode = createMemo(() => autoMatchLangTag());
|
const matchedLangCode = createMemo(() => autoMatchLangTag());
|
||||||
|
|
||||||
const onCodeChange = (code?: string) => {
|
const onCodeChange = (code?: string) => {
|
||||||
$settings.setKey("language", code);
|
$settings.setKey("language", code)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scaffold
|
<Scaffold
|
||||||
|
@ -59,7 +59,7 @@ const ChooseLang: Component = () => {
|
||||||
variant="dense"
|
variant="dense"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Title>{t("Choose Language")}</Title>
|
<Title>{t("Choose Language")}</Title>
|
||||||
|
@ -96,10 +96,7 @@ const ChooseLang: Component = () => {
|
||||||
<ListItemText>{t(`lang.${c}`)}</ListItemText>
|
<ListItemText>{t(`lang.${c}`)}</ListItemText>
|
||||||
<ListItemSecondaryAction>
|
<ListItemSecondaryAction>
|
||||||
<Radio
|
<Radio
|
||||||
checked={
|
checked={code() === c || (code() === undefined && matchedLangCode() == c)}
|
||||||
code() === c ||
|
|
||||||
(code() === undefined && matchedLangCode() == c)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItemSecondaryAction>
|
</ListItemSecondaryAction>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Divider,
|
Divider,
|
||||||
|
@ -12,15 +12,15 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} 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 { ArrowBack } from "@suid/icons-material";
|
||||||
import { createTranslator } from "~platform/i18n";
|
import { createTranslator } from "../platform/i18n";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
|
|
||||||
const Motions: Component = () => {
|
const Motions: Component = () => {
|
||||||
const {pop} = useNavigator();
|
const navigate = useNavigate();
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
|
@ -36,7 +36,7 @@ const Motions: Component = () => {
|
||||||
variant="dense"
|
variant="dense"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Title>{t("motions")}</Title>
|
<Title>{t("motions")}</Title>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createMemo, For, type Component, type JSX } from "solid-js";
|
import { createMemo, For, type Component, type JSX } from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -17,17 +17,17 @@ import {
|
||||||
autoMatchRegion,
|
autoMatchRegion,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
SUPPORTED_REGIONS,
|
SUPPORTED_REGIONS,
|
||||||
} from "~platform/i18n";
|
} from "../platform/i18n";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
import type { Template } from "@solid-primitives/i18n";
|
import type { Template } from "@solid-primitives/i18n";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
import { $settings } from "./stores";
|
import { $settings } from "./stores";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
|
|
||||||
const ChooseRegion: Component = () => {
|
const ChooseRegion: Component = () => {
|
||||||
const {pop} = useNavigator();
|
const navigate = useNavigate();
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
() => import("./i18n/generic.json"),
|
() => import("./i18n/lang-names.json"),
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Record<string, string | undefined> & {
|
default: Record<string, string | undefined> & {
|
||||||
|
@ -54,10 +54,10 @@ const ChooseRegion: Component = () => {
|
||||||
variant="dense"
|
variant="dense"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Title>{t("Choose Region")}</Title>
|
<Title>{t("Choose Language")}</Title>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { For, Show, type Component } from "solid-js";
|
import {
|
||||||
import Scaffold from "~material/Scaffold.js";
|
children,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
type ParentComponent,
|
||||||
|
} from "solid-js";
|
||||||
|
import Scaffold from "../material/Scaffold.js";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Divider,
|
Divider,
|
||||||
|
@ -11,7 +17,6 @@ import {
|
||||||
ListItemSecondaryAction,
|
ListItemSecondaryAction,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
ListSubheader,
|
ListSubheader,
|
||||||
NativeSelect,
|
|
||||||
Switch,
|
Switch,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
|
@ -23,157 +28,51 @@ import {
|
||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
Translate as TranslateIcon,
|
Translate as TranslateIcon,
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import A from "~platform/A.js";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { Title } from "~material/typography.js";
|
import { Title } from "../material/typography.jsx";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
|
import { useSignedInProfiles } from "../masto/acct.js";
|
||||||
import { signOut, type Account } from "../accounts/stores.js";
|
import { signOut, type Account } from "../accounts/stores.js";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { $settings } from "./stores.js";
|
import { $settings } from "./stores.js";
|
||||||
|
import { useRegisterSW } from "virtual:pwa-register/solid";
|
||||||
import {
|
import {
|
||||||
autoMatchLangTag,
|
autoMatchLangTag,
|
||||||
autoMatchRegion,
|
autoMatchRegion,
|
||||||
createTranslator,
|
createTranslator,
|
||||||
useDateFnLocale,
|
useDateFnLocale,
|
||||||
} from "~platform/i18n.jsx";
|
} from "../platform/i18n.jsx";
|
||||||
import { type Template } from "@solid-primitives/i18n";
|
import { type Template } from "@solid-primitives/i18n";
|
||||||
import { useServiceWorker } from "~platform/host.js";
|
import BottomSheet from "../material/BottomSheet.jsx";
|
||||||
import { useSessions } from "../masto/clients.js";
|
|
||||||
import { useNavigator } from "~platform/StackedRouter.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.accept((mod) => {
|
|
||||||
if (!mod) return;
|
|
||||||
if (screenOrientationCallback) {
|
|
||||||
mod["screenOrientationCallback"] = screenOrientationCallback;
|
|
||||||
setTimeout(screenOrientationCallback, 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type Strings = {
|
type Strings = {
|
||||||
["lang.auto"]: Template<{ detected: string }>;
|
["lang.auto"]: Template<{ detected: string }>;
|
||||||
} & Record<string, string | undefined>;
|
} & Record<string, string | undefined>;
|
||||||
|
|
||||||
const Settings: Component = () => {
|
const Settings: ParentComponent = (props) => {
|
||||||
const [t] = createTranslator(
|
const [t] = createTranslator(
|
||||||
(code) =>
|
(code) =>
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
import(`./i18n/${code}.json`) as Promise<{
|
||||||
default: Strings;
|
default: Strings;
|
||||||
}>,
|
}>,
|
||||||
() => import(`./i18n/generic.json`),
|
() => import(`./i18n/lang-names.json`),
|
||||||
);
|
);
|
||||||
const { pop } = useNavigator();
|
const navigate = useNavigate();
|
||||||
const settings$ = useStore($settings);
|
const settings$ = useStore($settings);
|
||||||
const { needRefresh } = useServiceWorker();
|
const {
|
||||||
|
needRefresh: [needRefresh],
|
||||||
|
} = useRegisterSW();
|
||||||
const dateFnLocale = useDateFnLocale();
|
const dateFnLocale = useDateFnLocale();
|
||||||
|
|
||||||
const profiles = useSessions();
|
const [profiles] = useSignedInProfiles();
|
||||||
|
|
||||||
const doSignOut = (acct: Account) => {
|
const doSignOut = (acct: Account) => {
|
||||||
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
|
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subpage = children(() => props.children);
|
||||||
|
|
||||||
css`
|
css`
|
||||||
ul {
|
ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -181,9 +80,6 @@ const Settings: Component = () => {
|
||||||
|
|
||||||
.setting-list {
|
.setting-list {
|
||||||
padding-bottom: calc(var(--safe-area-inset-bottom, 0px) + 16px);
|
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 (
|
return (
|
||||||
|
@ -194,7 +90,7 @@ const Settings: Component = () => {
|
||||||
variant="dense"
|
variant="dense"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<IconButton color="inherit" onClick={[pop, 1]} disableRipple>
|
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Title>{t("Settings")}</Title>
|
<Title>{t("Settings")}</Title>
|
||||||
|
@ -202,6 +98,10 @@ const Settings: Component = () => {
|
||||||
</AppBar>
|
</AppBar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<BottomSheet open={!!subpage()} onClose={() => navigate(-1)}>
|
||||||
|
{subpage()}
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
<List class="setting-list" use:solid-styled>
|
<List class="setting-list" use:solid-styled>
|
||||||
<li>
|
<li>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -219,9 +119,9 @@ const Settings: Component = () => {
|
||||||
<Divider />
|
<Divider />
|
||||||
</ul>
|
</ul>
|
||||||
<For each={profiles()}>
|
<For each={profiles()}>
|
||||||
{({ account: acct }) => (
|
{({ account: acct, inf }) => (
|
||||||
<ul data-site={acct.site} data-username={acct.inf?.username}>
|
<ul data-site={acct.site} data-username={inf?.username}>
|
||||||
<ListSubheader>{`@${acct.inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
|
<ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
|
||||||
<ListItemButton disabled>
|
<ListItemButton disabled>
|
||||||
<ListItemText>{t("Notifications")}</ListItemText>
|
<ListItemText>{t("Notifications")}</ListItemText>
|
||||||
<ListItemSecondaryAction>
|
<ListItemSecondaryAction>
|
||||||
|
@ -336,58 +236,7 @@ const Settings: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
{import.meta.env.VITE_CODE_VERSION ? (
|
|
||||||
<>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText secondary={import.meta.env.VITE_CODE_VERSION}>
|
|
||||||
{t("version.code")}
|
|
||||||
</ListItemText>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</li>
|
</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"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Safe Area Insets
|
|
||||||
</ListItemText>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
</Scaffold>
|
</Scaffold>
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
"updates.ready": "An update is ready, restart the Tutu to apply",
|
"updates.ready": "An update is ready, restart the Tutu to apply",
|
||||||
"updates.no": "No updates",
|
"updates.no": "No updates",
|
||||||
"version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})",
|
"version": "Using v{{packageVersion}} (built on {{builtAt}}, {{buildMode}})",
|
||||||
"version.code": "Code Version",
|
|
||||||
"Language": "Language",
|
"Language": "Language",
|
||||||
"Region": "Region",
|
"Region": "Region",
|
||||||
"lang.auto": "(Auto) {{detected}}",
|
"lang.auto": "(Auto) {{detected}}",
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
"updates.ready": "更新已准备好,下次开启会启动新版本",
|
"updates.ready": "更新已准备好,下次开启会启动新版本",
|
||||||
"updates.no": "已是最新版本",
|
"updates.no": "已是最新版本",
|
||||||
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
|
"version": "正在使用 v{{packageVersion}} ({{builtAt}}构建, {{buildMode}})",
|
||||||
"version.code": "代码版本",
|
|
||||||
"Language": "语言",
|
"Language": "语言",
|
||||||
"Region": "区域",
|
"Region": "区域",
|
||||||
"lang.auto": "(自动){{detected}}",
|
"lang.auto": "(自动){{detected}}",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {
|
||||||
type Component,
|
type Component,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
@ -17,8 +17,8 @@ import {
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Close as CloseIcon } from "@suid/icons-material";
|
import { Close as CloseIcon } from "@suid/icons-material";
|
||||||
import iso639_1 from "iso-639-1";
|
import iso639_1 from "iso-639-1";
|
||||||
import { createTranslator } from "~platform/i18n";
|
import { createTranslator } from "../platform/i18n";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
|
|
||||||
type ChooseTootLangProps = {
|
type ChooseTootLangProps = {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
@ -2,8 +2,8 @@ import type { mastodon } from "masto";
|
||||||
import { Show, type Component } from "solid-js";
|
import { Show, type Component } from "solid-js";
|
||||||
import tootStyle from "./toot.module.css";
|
import tootStyle from "./toot.module.css";
|
||||||
import { formatRelative } from "date-fns";
|
import { formatRelative } from "date-fns";
|
||||||
import Img from "~material/Img";
|
import Img from "../material/Img";
|
||||||
import { Body2 } from "~material/typography";
|
import { Body2 } from "../material/typography";
|
||||||
import { appliedCustomEmoji } from "../masto/toot";
|
import { appliedCustomEmoji } from "../masto/toot";
|
||||||
import { TootPreviewCard } from "./RegularToot";
|
import { TootPreviewCard } from "./RegularToot";
|
||||||
|
|
||||||
|
|
|
@ -3,10 +3,12 @@ import {
|
||||||
Show,
|
Show,
|
||||||
onMount,
|
onMount,
|
||||||
type ParentComponent,
|
type ParentComponent,
|
||||||
createRenderEffect,
|
children,
|
||||||
|
Suspense,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useDocumentTitle } from "../utils";
|
import { useDocumentTitle } from "../utils";
|
||||||
import Scaffold from "~material/Scaffold";
|
import { type mastodon } from "masto";
|
||||||
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
ListItemSecondaryAction,
|
ListItemSecondaryAction,
|
||||||
|
@ -16,16 +18,22 @@ import {
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { TimeSourceProvider, createTimeSource } from "~platform/timesrc";
|
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
||||||
import ProfileMenuButton from "./ProfileMenuButton";
|
import ProfileMenuButton from "./ProfileMenuButton";
|
||||||
import Tabs from "~material/Tabs";
|
import Tabs from "../material/Tabs";
|
||||||
import Tab from "~material/Tab";
|
import Tab from "../material/Tab";
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
|
import BottomSheet, {
|
||||||
|
HERO as BOTTOM_SHEET_HERO,
|
||||||
|
} from "../material/BottomSheet";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { useStore } from "@nanostores/solid";
|
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 TrendTimelinePanel from "./TrendTimelinePanel";
|
||||||
import TimelinePanel from "./TimelinePanel";
|
import TimelinePanel from "./TimelinePanel";
|
||||||
import { useSessions } from "../masto/clients";
|
|
||||||
|
|
||||||
const Home: ParentComponent = (props) => {
|
const Home: ParentComponent = (props) => {
|
||||||
let panelList: HTMLDivElement;
|
let panelList: HTMLDivElement;
|
||||||
|
@ -34,18 +42,30 @@ const Home: ParentComponent = (props) => {
|
||||||
|
|
||||||
const settings$ = useStore($settings);
|
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 client = () => {
|
||||||
const all = profiles();
|
const all = profiles();
|
||||||
return all?.[0]?.client;
|
return all?.[0]?.client;
|
||||||
};
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
|
||||||
|
const [panelOffset, setPanelOffset] = createSignal(0);
|
||||||
const prefetching = () => !settings$().prefetchTootsDisabled;
|
const prefetching = () => !settings$().prefetchTootsDisabled;
|
||||||
|
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
|
||||||
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
|
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const child = children(() => props.children);
|
||||||
|
|
||||||
let scrollEventLockReleased = true;
|
let scrollEventLockReleased = true;
|
||||||
|
|
||||||
const recalculateTabIndicator = () => {
|
const recalculateTabIndicator = () => {
|
||||||
|
@ -82,17 +102,17 @@ const Home: ParentComponent = (props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestRecalculateTabIndicator = () => {
|
|
||||||
if (scrollEventLockReleased) {
|
|
||||||
requestAnimationFrame(recalculateTabIndicator);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
makeEventListener(window, "resize", requestRecalculateTabIndicator);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
makeEventListener(panelList, "scroll", () => {
|
||||||
|
if (scrollEventLockReleased) {
|
||||||
|
requestAnimationFrame(recalculateTabIndicator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
makeEventListener(window, "resize", () => {
|
||||||
|
if (scrollEventLockReleased) {
|
||||||
|
requestAnimationFrame(recalculateTabIndicator);
|
||||||
|
}
|
||||||
|
});
|
||||||
requestAnimationFrame(recalculateTabIndicator);
|
requestAnimationFrame(recalculateTabIndicator);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -115,6 +135,30 @@ 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`
|
css`
|
||||||
.tab-panel {
|
.tab-panel {
|
||||||
|
@ -124,9 +168,6 @@ const Home: ParentComponent = (props) => {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
overscroll-behavior-block: none;
|
overscroll-behavior-block: none;
|
||||||
contain: strict;
|
|
||||||
contain-intrinsic-size: auto 560px auto 100vh;
|
|
||||||
contain-intrinsic-size: auto 560px auto 100dvh;
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -137,7 +178,7 @@ const Home: ParentComponent = (props) => {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-columns: 560px;
|
grid-auto-columns: 560px;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
overflow: auto hidden;
|
overflow-x: auto;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
height: calc(100vh - var(--scaffold-topbar-height, 0px));
|
height: calc(100vh - var(--scaffold-topbar-height, 0px));
|
||||||
|
@ -145,10 +186,6 @@ const Home: ParentComponent = (props) => {
|
||||||
padding-left: var(--safe-area-inset-left, 0);
|
padding-left: var(--safe-area-inset-left, 0);
|
||||||
padding-right: var(--safe-area-inset-right, 0);
|
padding-right: var(--safe-area-inset-right, 0);
|
||||||
|
|
||||||
& > * {
|
|
||||||
content-visibility: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
grid-auto-columns: 100%;
|
grid-auto-columns: 100%;
|
||||||
}
|
}
|
||||||
|
@ -165,7 +202,7 @@ const Home: ParentComponent = (props) => {
|
||||||
class="responsive"
|
class="responsive"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<Tabs>
|
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
|
||||||
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
|
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
|
||||||
Home
|
Home
|
||||||
</Tab>
|
</Tab>
|
||||||
|
@ -176,7 +213,7 @@ const Home: ParentComponent = (props) => {
|
||||||
Public
|
Public
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<ProfileMenuButton profile={profiles()[0]}>
|
<ProfileMenuButton profile={profile()}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={(e) =>
|
onClick={(e) =>
|
||||||
$settings.setKey(
|
$settings.setKey(
|
||||||
|
@ -197,23 +234,24 @@ const Home: ParentComponent = (props) => {
|
||||||
>
|
>
|
||||||
<TimeSourceProvider value={now}>
|
<TimeSourceProvider value={now}>
|
||||||
<Show when={!!client()}>
|
<Show when={!!client()}>
|
||||||
<div
|
<div class="panel-list" ref={panelList!}>
|
||||||
class="panel-list"
|
|
||||||
ref={panelList!}
|
|
||||||
onScroll={requestRecalculateTabIndicator}
|
|
||||||
>
|
|
||||||
<div class="tab-panel">
|
<div class="tab-panel">
|
||||||
<div>
|
<div>
|
||||||
<TimelinePanel
|
<TimelinePanel
|
||||||
client={client()}
|
client={client()}
|
||||||
name="home"
|
name="home"
|
||||||
prefetch={prefetching()}
|
prefetch={prefetching()}
|
||||||
|
openFullScreenToot={openFullScreenToot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-panel">
|
<div class="tab-panel">
|
||||||
<div>
|
<div>
|
||||||
<TrendTimelinePanel client={client()} />
|
<TrendTimelinePanel
|
||||||
|
client={client()}
|
||||||
|
prefetch={prefetching()}
|
||||||
|
openFullScreenToot={openFullScreenToot}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-panel">
|
<div class="tab-panel">
|
||||||
|
@ -222,6 +260,7 @@ const Home: ParentComponent = (props) => {
|
||||||
client={client()}
|
client={client()}
|
||||||
name="public"
|
name="public"
|
||||||
prefetch={prefetching()}
|
prefetch={prefetching()}
|
||||||
|
openFullScreenToot={openFullScreenToot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -229,6 +268,13 @@ const Home: ParentComponent = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</TimeSourceProvider>
|
</TimeSourceProvider>
|
||||||
|
<Suspense>
|
||||||
|
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
|
||||||
|
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
|
||||||
|
{child()}
|
||||||
|
</BottomSheet>
|
||||||
|
</HeroSourceProvider>
|
||||||
|
</Suspense>
|
||||||
</Scaffold>
|
</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;
|
|
@ -5,111 +5,122 @@ import {
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import { Show, createUniqueId, type ParentComponent } from "solid-js";
|
import {
|
||||||
|
ErrorBoundary,
|
||||||
|
Show,
|
||||||
|
createSignal,
|
||||||
|
createUniqueId,
|
||||||
|
type ParentComponent,
|
||||||
|
} from "solid-js";
|
||||||
import {
|
import {
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Bookmark as BookmarkIcon,
|
Bookmark as BookmarkIcon,
|
||||||
Star as LikeIcon,
|
Star as LikeIcon,
|
||||||
FeaturedPlayList as ListIcon,
|
FeaturedPlayList as ListIcon,
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import A from "~platform/A";
|
import { A } from "@solidjs/router";
|
||||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
|
||||||
|
|
||||||
const ProfileMenuButton: ParentComponent<{
|
const ProfileMenuButton: ParentComponent<{
|
||||||
profile?: {
|
profile?: { displayName: string; avatar: string; username: string };
|
||||||
account: {
|
onClick?: () => void;
|
||||||
site: string;
|
onClose?: () => void;
|
||||||
inf?: {
|
|
||||||
displayName: string;
|
|
||||||
avatar: string;
|
|
||||||
username: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const menuId = createUniqueId();
|
const menuId = createUniqueId();
|
||||||
const buttonId = createUniqueId();
|
const buttonId = createUniqueId();
|
||||||
|
|
||||||
const [open, state] = createManagedMenuState();
|
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
|
||||||
|
const open = () => !!anchor();
|
||||||
|
|
||||||
const onClick = (event: { currentTarget: HTMLElement }) => {
|
const onClick = (
|
||||||
open(event.currentTarget.getBoundingClientRect());
|
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||||
|
) => {
|
||||||
|
setAnchor(event.currentTarget);
|
||||||
|
props.onClick?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const inf = () => props.profile?.account.inf;
|
const onClose = () => {
|
||||||
|
props.onClick?.();
|
||||||
|
setAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ButtonBase
|
<ButtonBase
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
sx={{ borderRadius: "50%" }}
|
sx={{ borderRadius: "50%" }}
|
||||||
id={buttonId}
|
id={buttonId}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-controls={state.open ? menuId : undefined}
|
aria-controls={open() ? menuId : undefined}
|
||||||
aria-expanded={state.open ? "true" : "false"}
|
aria-expanded={open() ? "true" : undefined}
|
||||||
>
|
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
<ListItemAvatar>
|
<Avatar
|
||||||
<Avatar src={inf()?.avatar}></Avatar>
|
alt={`${props.profile?.displayName}'s avatar`}
|
||||||
</ListItemAvatar>
|
src={props.profile?.avatar}
|
||||||
<ListItemText
|
></Avatar>
|
||||||
primary={inf()?.displayName}
|
</ButtonBase>
|
||||||
secondary={`@${inf()?.username}`}
|
<Menu
|
||||||
></ListItemText>
|
id={menuId}
|
||||||
</MenuItem>
|
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>
|
<MenuItem>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<BookmarkIcon />
|
<BookmarkIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Bookmarks</ListItemText>
|
<ListItemText>Bookmarks</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem disabled>
|
<MenuItem>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<LikeIcon />
|
<LikeIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Likes</ListItemText>
|
<ListItemText>Likes</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem disabled>
|
<MenuItem>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<ListIcon />
|
<ListIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>Lists</ListItemText>
|
<ListItemText>Lists</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
|
||||||
<Show when={props.children}>
|
|
||||||
{props.children}
|
|
||||||
<Divider />
|
<Divider />
|
||||||
</Show>
|
<Show when={props.children}>
|
||||||
<MenuItem component={A} href="/settings">
|
{props.children}
|
||||||
<ListItemIcon>
|
<Divider />
|
||||||
<SettingsIcon />
|
</Show>
|
||||||
</ListItemIcon>
|
<MenuItem component={A} href="/settings" onClick={onClose}>
|
||||||
<ListItemText>Settings</ListItemText>
|
<ListItemIcon>
|
||||||
</MenuItem>
|
<SettingsIcon />
|
||||||
</Menu>
|
</ListItemIcon>
|
||||||
|
<ListItemText>Settings</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,24 @@
|
||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
|
createRenderEffect,
|
||||||
createSignal,
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
untrack,
|
untrack,
|
||||||
type Component,
|
type Component,
|
||||||
|
type Signal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { Refresh as RefreshIcon } from "@suid/icons-material";
|
import { Refresh as RefreshIcon } from "@suid/icons-material";
|
||||||
import { CircularProgress } from "@suid/material";
|
import { CircularProgress } from "@suid/material";
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
import {
|
||||||
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
createEventListener,
|
||||||
import { useIsFrameSuspended } from "~platform/StackedRouter";
|
makeEventListener,
|
||||||
|
} from "@solid-primitives/event-listener";
|
||||||
|
import {
|
||||||
|
createViewportObserver,
|
||||||
|
createVisibilityObserver,
|
||||||
|
} from "@solid-primitives/intersection-observer";
|
||||||
|
|
||||||
const PullDownToRefresh: Component<{
|
const PullDownToRefresh: Component<{
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
@ -34,7 +42,6 @@ const PullDownToRefresh: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootVisible = obvx(() => rootElement);
|
const rootVisible = obvx(() => rootElement);
|
||||||
const isFrameSuspended = useIsFrameSuspended()
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!rootVisible()) setPullDown(0);
|
if (!rootVisible()) setPullDown(0);
|
||||||
|
@ -107,16 +114,14 @@ const PullDownToRefresh: Component<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect((cleanup?: () => void) => {
|
||||||
if (!rootVisible()) {
|
if (!rootVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isFrameSuspended()) {
|
cleanup?.();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const element = props.linkedElement;
|
const element = props.linkedElement;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
makeEventListener(element, "wheel", handleLinkedWheel);
|
return makeEventListener(element, "wheel", handleLinkedWheel);
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastTouchId: number | undefined = undefined;
|
let lastTouchId: number | undefined = undefined;
|
||||||
|
@ -160,17 +165,16 @@ const PullDownToRefresh: Component<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect((cleanup?: () => void) => {
|
||||||
if (!rootVisible()) {
|
if (!rootVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isFrameSuspended()) {
|
cleanup?.();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const element = props.linkedElement;
|
const element = props.linkedElement;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
makeEventListener(element, "touchmove", handleTouch);
|
const cleanup0 = makeEventListener(element, "touchmove", handleTouch);
|
||||||
makeEventListener(element, "touchend", handleTouchEnd);
|
const cleanup1 = makeEventListener(element, "touchend", handleTouchEnd);
|
||||||
|
return () => (cleanup0(), cleanup1());
|
||||||
});
|
});
|
||||||
|
|
||||||
css`
|
css`
|
||||||
|
|
|
@ -6,12 +6,18 @@ import {
|
||||||
Show,
|
Show,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createSignal,
|
createSignal,
|
||||||
type Setter,
|
createEffect,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import tootStyle from "./toot.module.css";
|
import tootStyle from "./toot.module.css";
|
||||||
import { formatRelative } from "date-fns";
|
import { formatRelative } from "date-fns";
|
||||||
import Img from "~material/Img.js";
|
import Img from "../material/Img.js";
|
||||||
import { Body2 } from "~material/typography.js";
|
import {
|
||||||
|
Body1,
|
||||||
|
Body2,
|
||||||
|
Caption,
|
||||||
|
Subheading,
|
||||||
|
Title,
|
||||||
|
} from "../material/typography.js";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import {
|
import {
|
||||||
BookmarkAddOutlined,
|
BookmarkAddOutlined,
|
||||||
|
@ -20,22 +26,86 @@ import {
|
||||||
Star,
|
Star,
|
||||||
StarOutline,
|
StarOutline,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
Reply,
|
||||||
Share,
|
Share,
|
||||||
SmartToySharp,
|
|
||||||
Lock,
|
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import { useTimeSource } from "~platform/timesrc.js";
|
import { useTimeSource } from "../platform/timesrc.js";
|
||||||
import { resolveCustomEmoji } from "../masto/toot.js";
|
import { resolveCustomEmoji } from "../masto/toot.js";
|
||||||
import { Divider } from "@suid/material";
|
import { Divider, IconButton } from "@suid/material";
|
||||||
import cardStyle from "~material/cards.module.css";
|
import cardStyle from "../material/cards.module.css";
|
||||||
import Button from "~material/Button.js";
|
import Button from "../material/Button.js";
|
||||||
import MediaAttachmentGrid from "./toots/MediaAttachmentGrid.jsx";
|
import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
|
||||||
import { useDateFnLocale } from "~platform/i18n";
|
import { FastAverageColor } from "fast-average-color";
|
||||||
import { canShare, share } from "~platform/share";
|
import Color from "colorjs.io";
|
||||||
import { makeAcctText, useDefaultSession } from "../masto/clients";
|
import { useDateFnLocale } from "../platform/i18n";
|
||||||
import TootContent from "./toots/TootContent";
|
import { canShare, share } from "../platform/share";
|
||||||
import BoostIcon from "./toots/BoostIcon";
|
|
||||||
import PreviewCard from "./toots/PreviewCard";
|
type TootContentViewProps = {
|
||||||
|
source?: string;
|
||||||
|
emojis?: mastodon.v1.CustomEmoji[];
|
||||||
|
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const TootContentView: Component<TootContentViewProps> = (props) => {
|
||||||
|
const [managed, rest] = splitProps(props, ["source", "emojis"]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(ref) => {
|
||||||
|
createRenderEffect(() => {
|
||||||
|
ref.innerHTML = managed.source
|
||||||
|
? managed.emojis
|
||||||
|
? resolveCustomEmoji(managed.source, managed.emojis)
|
||||||
|
: managed.source
|
||||||
|
: "";
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RetootIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
||||||
|
const [managed, rest] = splitProps(props, ["class"]);
|
||||||
|
css`
|
||||||
|
.retoot-icon {
|
||||||
|
padding: 0;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
> :global(svg) {
|
||||||
|
color: green;
|
||||||
|
font-size: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return (
|
||||||
|
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
|
||||||
|
<Repeat />
|
||||||
|
</i>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReplyIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
||||||
|
const [managed, rest] = splitProps(props, ["class"]);
|
||||||
|
css`
|
||||||
|
.retoot-icon {
|
||||||
|
padding: 0;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
> :global(svg) {
|
||||||
|
color: var(--tutu-color-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return (
|
||||||
|
<i class={["retoot-icon", managed.class].join(" ")} {...rest}>
|
||||||
|
<Reply />
|
||||||
|
</i>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
||||||
onRetoot?: (value: T) => void;
|
onRetoot?: (value: T) => void;
|
||||||
|
@ -47,7 +117,7 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RegularTootProps = {
|
type TootCardProps = {
|
||||||
status: mastodon.v1.Status;
|
status: mastodon.v1.Status;
|
||||||
actionable?: boolean;
|
actionable?: boolean;
|
||||||
evaluated?: boolean;
|
evaluated?: boolean;
|
||||||
|
@ -139,41 +209,27 @@ function TootActionGroup<T extends mastodon.v1.Status>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TootAuthorGroup(
|
function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) {
|
||||||
props: {
|
const toot = () => props.status;
|
||||||
status: mastodon.v1.Status;
|
|
||||||
now: Date;
|
|
||||||
} & JSX.HTMLElementTags["div"],
|
|
||||||
) {
|
|
||||||
const [managed, rest] = splitProps(props, ["status", "now"]);
|
|
||||||
const toot = () => managed.status;
|
|
||||||
const dateFnLocale = useDateFnLocale();
|
const dateFnLocale = useDateFnLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={tootStyle.tootAuthorGrp} {...rest}>
|
<div class={tootStyle.tootAuthorGrp}>
|
||||||
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
|
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
|
||||||
<div class={tootStyle.tootAuthorNameGrp}>
|
<div class={tootStyle.tootAuthorNameGrp}>
|
||||||
<div class={tootStyle.tootAuthorNamePrimary}>
|
<Body2
|
||||||
<Show when={toot().account.bot}>
|
class={tootStyle.tootAuthorNamePrimary}
|
||||||
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
ref={(e: { innerHTML: string }) => {
|
||||||
</Show>
|
createRenderEffect(() => {
|
||||||
<Show when={toot().account.locked}>
|
e.innerHTML = resolveCustomEmoji(
|
||||||
<Lock class="acct-mark" aria-label="Locked" />
|
toot().account.displayName,
|
||||||
</Show>
|
toot().account.emojis,
|
||||||
<Body2
|
);
|
||||||
component="span"
|
});
|
||||||
ref={(e: { innerHTML: string }) => {
|
}}
|
||||||
createRenderEffect(() => {
|
/>
|
||||||
e.innerHTML = resolveCustomEmoji(
|
|
||||||
toot().account.displayName,
|
|
||||||
toot().account.emojis,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<time datetime={toot().createdAt}>
|
<time datetime={toot().createdAt}>
|
||||||
{formatRelative(toot().createdAt, managed.now, {
|
{formatRelative(toot().createdAt, props.now, {
|
||||||
locale: dateFnLocale(),
|
locale: dateFnLocale(),
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
|
@ -185,57 +241,75 @@ function TootAuthorGroup(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function TootPreviewCard(props: {
|
||||||
* find bottom-to-top the element with `data-action`.
|
src: mastodon.v1.PreviewCard;
|
||||||
*/
|
alwaysCompact?: boolean;
|
||||||
export function findElementActionable(
|
}) {
|
||||||
element: HTMLElement,
|
let root: HTMLAnchorElement;
|
||||||
top: HTMLElement,
|
|
||||||
): HTMLElement | undefined {
|
createEffect(() => {
|
||||||
let current = element;
|
if (props.alwaysCompact) {
|
||||||
while (!current.dataset.action) {
|
root.classList.add(tootStyle.compact);
|
||||||
if (!current.parentElement || current.parentElement === top) {
|
return;
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
current = current.parentElement;
|
if (!props.src.width) return;
|
||||||
}
|
const width = root.getBoundingClientRect().width;
|
||||||
return current;
|
if (width > props.src.width) {
|
||||||
|
root.classList.add(tootStyle.compact);
|
||||||
|
} else {
|
||||||
|
root.classList.remove(tootStyle.compact);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => {
|
||||||
|
// TODO: better extraction algorithm
|
||||||
|
// I'd like to use a pattern panel and match one in the panel from the extracted color
|
||||||
|
const fac = new FastAverageColor();
|
||||||
|
const result = fac.getColor(event.currentTarget);
|
||||||
|
if (result.error) {
|
||||||
|
console.error(result.error);
|
||||||
|
fac.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.style.setProperty("--tutu-color-surface", result.hex);
|
||||||
|
const focusSurface = result.isDark
|
||||||
|
? new Color(result.hex).darken(0.2)
|
||||||
|
: new Color(result.hex).lighten(0.2);
|
||||||
|
root.style.setProperty("--tutu-color-surface-d", focusSurface.toString());
|
||||||
|
const textColor = result.isDark ? "white" : "black";
|
||||||
|
const secondaryTextColor = new Color(textColor);
|
||||||
|
secondaryTextColor.alpha = 0.75;
|
||||||
|
root.style.setProperty("--tutu-color-on-surface", textColor);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--tutu-color-secondary-text-on-surface",
|
||||||
|
secondaryTextColor.toString(),
|
||||||
|
);
|
||||||
|
fac.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
ref={root!}
|
||||||
|
class={tootStyle.previewCard}
|
||||||
|
href={props.src.url}
|
||||||
|
target="_blank"
|
||||||
|
referrerPolicy="unsafe-url"
|
||||||
|
>
|
||||||
|
<Show when={props.src.image}>
|
||||||
|
<img
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
src={props.src.image!}
|
||||||
|
onLoad={onImgLoad}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Title component="h1">{props.src.title}</Title>
|
||||||
|
<Body1 component="p">{props.src.description}</Body1>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleReveal(setValue: Setter<boolean>, event: Event) {
|
const RegularToot: Component<TootCardProps> = (props) => {
|
||||||
event.stopPropagation();
|
|
||||||
setValue((x) => !x);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for a toot.
|
|
||||||
*
|
|
||||||
* If the session involved is not the first session, you must wrap
|
|
||||||
* this component under a `<DefaultSessionProvier />` with correct
|
|
||||||
* session.
|
|
||||||
*
|
|
||||||
* **Handling Clicks**
|
|
||||||
* There are multiple actions supported in the component. Some handlers
|
|
||||||
* are passed in, some should be handled as the click event.
|
|
||||||
*
|
|
||||||
* For those handler directly passed in, see the props starts with "on".
|
|
||||||
* We are moving to the new method below.
|
|
||||||
*
|
|
||||||
* The following actions are handled by the click event:
|
|
||||||
* - `[data-action="acct"]`: open the profile page of a account
|
|
||||||
* - `[data-acct-id]` is the account id for the client
|
|
||||||
* - `[data-client]` is the client perferred
|
|
||||||
* - `[href]` is the url of the account
|
|
||||||
*
|
|
||||||
* Handling the click event for this component, you should use
|
|
||||||
* {@link findElementActionable} to find out if the click event has
|
|
||||||
* additional intent. If the event's target is any from
|
|
||||||
* the subtree of any "actionable" element, the function returns the element.
|
|
||||||
*
|
|
||||||
* You can extract the intent from the attributes of the "actionable" element.
|
|
||||||
* The action type is the dataset's `action`.
|
|
||||||
*/
|
|
||||||
const RegularToot: Component<RegularTootProps> = (props) => {
|
|
||||||
let rootRef: HTMLElement;
|
let rootRef: HTMLElement;
|
||||||
const [managed, managedActionGroup, rest] = splitProps(
|
const [managed, managedActionGroup, rest] = splitProps(
|
||||||
props,
|
props,
|
||||||
|
@ -245,8 +319,6 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
||||||
const now = useTimeSource();
|
const now = useTimeSource();
|
||||||
const status = () => managed.status;
|
const status = () => managed.status;
|
||||||
const toot = () => status().reblog ?? status();
|
const toot = () => status().reblog ?? status();
|
||||||
const session = useDefaultSession();
|
|
||||||
const [reveal, setReveal] = createSignal(false);
|
|
||||||
|
|
||||||
css`
|
css`
|
||||||
.reply-sep {
|
.reply-sep {
|
||||||
|
@ -293,7 +365,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<article
|
<section
|
||||||
classList={{
|
classList={{
|
||||||
[tootStyle.toot]: true,
|
[tootStyle.toot]: true,
|
||||||
[tootStyle.expanded]: managed.evaluated,
|
[tootStyle.expanded]: managed.evaluated,
|
||||||
|
@ -308,49 +380,33 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
||||||
>
|
>
|
||||||
<Show when={!!status().reblog}>
|
<Show when={!!status().reblog}>
|
||||||
<div class={tootStyle.tootRetootGrp}>
|
<div class={tootStyle.tootRetootGrp}>
|
||||||
<BoostIcon />
|
<RetootIcon />
|
||||||
<Body2
|
<span>
|
||||||
ref={(e: { innerHTML: string }) => {
|
<Body2
|
||||||
createRenderEffect(() => {
|
ref={(e: { innerHTML: string }) => {
|
||||||
e.innerHTML = resolveCustomEmoji(
|
createRenderEffect(() => {
|
||||||
status().account.displayName,
|
e.innerHTML = resolveCustomEmoji(
|
||||||
toot().emojis,
|
status().account.displayName,
|
||||||
);
|
toot().emojis,
|
||||||
});
|
);
|
||||||
}}
|
});
|
||||||
></Body2>
|
}}
|
||||||
<span>boosts</span>
|
></Body2>{" "}
|
||||||
|
boosted
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<TootAuthorGroup
|
<TootAuthorGroup status={toot()} now={now()} />
|
||||||
status={toot()}
|
<TootContentView
|
||||||
now={now()}
|
|
||||||
data-action="acct"
|
|
||||||
data-client={session() ? makeAcctText(session()!) : undefined}
|
|
||||||
data-acct-id={toot().account.id}
|
|
||||||
/>
|
|
||||||
<TootContent
|
|
||||||
source={toot().content}
|
source={toot().content}
|
||||||
emojis={toot().emojis}
|
emojis={toot().emojis}
|
||||||
mentions={toot().mentions}
|
class={tootStyle.tootContent}
|
||||||
class={cardStyle.cardNoPad}
|
|
||||||
sensitive={toot().sensitive}
|
|
||||||
spoilerText={toot().spoilerText}
|
|
||||||
reveal={reveal()}
|
|
||||||
onToggleReveal={[onToggleReveal, setReveal]}
|
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show when={toot().card}>
|
||||||
when={
|
<TootPreviewCard src={toot().card!} />
|
||||||
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PreviewCard src={toot().card!} />
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={toot().mediaAttachments.length > 0}>
|
<Show when={toot().mediaAttachments.length > 0}>
|
||||||
<MediaAttachmentGrid
|
<MediaAttachmentGrid attachments={toot().mediaAttachments} />
|
||||||
attachments={toot().mediaAttachments}
|
|
||||||
sensitive={toot().sensitive}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={managed.actionable}>
|
<Show when={managed.actionable}>
|
||||||
<Divider
|
<Divider
|
||||||
|
@ -359,7 +415,7 @@ const RegularToot: Component<RegularTootProps> = (props) => {
|
||||||
/>
|
/>
|
||||||
<TootActionGroup value={toot()} {...managedActionGroup} />
|
<TootActionGroup value={toot()} {...managedActionGroup} />
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
92
src/timelines/Thread.tsx
Normal file
92
src/timelines/Thread.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import type { mastodon } from "masto";
|
||||||
|
import {
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
type Component,
|
||||||
|
type Ref,
|
||||||
|
} from "solid-js";
|
||||||
|
import CompactToot from "./CompactToot";
|
||||||
|
import { useTimeSource } from "../platform/timesrc";
|
||||||
|
import RegularToot, { findRootToot } from "./RegularToot";
|
||||||
|
import cardStyle from "../material/cards.module.css";
|
||||||
|
import { css } from "solid-styled";
|
||||||
|
|
||||||
|
type TootActionTarget = {
|
||||||
|
client: mastodon.rest.Client;
|
||||||
|
status: mastodon.v1.Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TootActions = {
|
||||||
|
onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||||
|
onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||||
|
onReply(target: TootActionTarget, element: HTMLElement): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThreadProps = {
|
||||||
|
ref?: Ref<HTMLElement>;
|
||||||
|
client: mastodon.rest.Client;
|
||||||
|
toots: readonly mastodon.v1.Status[];
|
||||||
|
isExpended: (status: mastodon.v1.Status) => boolean;
|
||||||
|
|
||||||
|
onItemClick(status: mastodon.v1.Status, event: MouseEvent): void;
|
||||||
|
} & TootActions;
|
||||||
|
|
||||||
|
const Thread: Component<ThreadProps> = (props) => {
|
||||||
|
const boost = (status: mastodon.v1.Status) => {
|
||||||
|
props.onBoost(props.client, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookmark = (status: mastodon.v1.Status) => {
|
||||||
|
props.onBookmark(props.client, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = (
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
event: MouseEvent & { currentTarget: HTMLElement },
|
||||||
|
) => {
|
||||||
|
const element = findRootToot(event.currentTarget);
|
||||||
|
props.onReply({ client: props.client, status }, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
css`
|
||||||
|
.thread {
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
return (
|
||||||
|
<article ref={props.ref} class="thread">
|
||||||
|
<For each={props.toots}>
|
||||||
|
{(status, index) => {
|
||||||
|
const useThread = props.toots.length > 1;
|
||||||
|
const threadPosition = useThread
|
||||||
|
? index() === 0
|
||||||
|
? "top"
|
||||||
|
: index() === props.toots.length - 1
|
||||||
|
? "bottom"
|
||||||
|
: "middle"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<RegularToot
|
||||||
|
data-status-id={status.id}
|
||||||
|
data-thread-sort={index()}
|
||||||
|
status={status}
|
||||||
|
thread={threadPosition}
|
||||||
|
class={cardStyle.card}
|
||||||
|
evaluated={props.isExpended(status)}
|
||||||
|
actionable={props.isExpended(status)}
|
||||||
|
onBookmark={(s) => bookmark(s)}
|
||||||
|
onRetoot={(s) => boost(s)}
|
||||||
|
onReply={reply}
|
||||||
|
onClick={[props.onItemClick, status]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Thread;
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
For,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
createSignal,
|
createSignal,
|
||||||
Show,
|
Show,
|
||||||
|
@ -11,30 +12,75 @@ import {
|
||||||
import { type mastodon } from "masto";
|
import { type mastodon } from "masto";
|
||||||
import { Button, LinearProgress } from "@suid/material";
|
import { Button, LinearProgress } from "@suid/material";
|
||||||
import { createTimeline } from "../masto/timelines";
|
import { createTimeline } from "../masto/timelines";
|
||||||
|
import { vibrate } from "../platform/hardware";
|
||||||
import PullDownToRefresh from "./PullDownToRefresh";
|
import PullDownToRefresh from "./PullDownToRefresh";
|
||||||
import TootComposer from "./TootComposer";
|
import TootComposer from "./TootComposer";
|
||||||
import TootList from "./TootList";
|
import Thread from "./Thread.jsx";
|
||||||
|
|
||||||
const TimelinePanel: Component<{
|
const TimelinePanel: Component<{
|
||||||
client: mastodon.rest.Client;
|
client: mastodon.rest.Client;
|
||||||
name: "home" | "public";
|
name: "home" | "public";
|
||||||
prefetch?: boolean;
|
prefetch?: boolean;
|
||||||
|
|
||||||
|
openFullScreenToot: (
|
||||||
|
toot: mastodon.v1.Status,
|
||||||
|
srcElement?: HTMLElement,
|
||||||
|
reply?: boolean,
|
||||||
|
) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
||||||
|
|
||||||
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
|
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
|
||||||
() => props.client.v1.timelines[props.name],
|
() => props.client.v1.timelines[props.name],
|
||||||
() => ({ limit: 20 }),
|
() => 20,
|
||||||
);
|
);
|
||||||
|
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
||||||
|
const [typing, setTyping] = createSignal(false);
|
||||||
|
|
||||||
const tlEndObserver = new IntersectionObserver(() => {
|
const tlEndObserver = new IntersectionObserver(() => {
|
||||||
if (untrack(() => props.prefetch) && !snapshot.loading)
|
if (untrack(() => props.prefetch) && !snapshot.loading)
|
||||||
refetchTimeline("prev");
|
refetchTimeline("next");
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => tlEndObserver.disconnect());
|
onCleanup(() => tlEndObserver.disconnect());
|
||||||
|
|
||||||
|
const onBookmark = async (
|
||||||
|
client: mastodon.rest.Client,
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
) => {
|
||||||
|
const result = await (status.bookmarked
|
||||||
|
? client.v1.statuses.$select(status.id).unbookmark()
|
||||||
|
: client.v1.statuses.$select(status.id).bookmark());
|
||||||
|
timeline.set(result.id, result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBoost = async (
|
||||||
|
client: mastodon.rest.Client,
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
) => {
|
||||||
|
vibrate(50);
|
||||||
|
const rootStatus = status.reblog ? status.reblog : status;
|
||||||
|
const reblogged = rootStatus.reblogged;
|
||||||
|
if (status.reblog) {
|
||||||
|
status.reblog = { ...status.reblog, reblogged: !reblogged };
|
||||||
|
timeline.set(status.id, status);
|
||||||
|
} else {
|
||||||
|
timeline.set(
|
||||||
|
status.id,
|
||||||
|
Object.assign(status, {
|
||||||
|
reblogged: !reblogged,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = reblogged
|
||||||
|
? await client.v1.statuses.$select(status.id).unreblog()
|
||||||
|
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
||||||
|
timeline.set(
|
||||||
|
status.id,
|
||||||
|
Object.assign(status.reblog ?? status, result.reblog),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(err, reset) => {
|
fallback={(err, reset) => {
|
||||||
|
@ -58,16 +104,42 @@ const TimelinePanel: Component<{
|
||||||
style={{
|
style={{
|
||||||
"--scaffold-topbar-height": "0px",
|
"--scaffold-topbar-height": "0px",
|
||||||
}}
|
}}
|
||||||
|
isTyping={typing()}
|
||||||
|
onTypingChange={setTyping}
|
||||||
client={props.client}
|
client={props.client}
|
||||||
onSent={() => refetchTimeline("prev")}
|
onSent={() => refetchTimeline("prev")}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
<For each={timeline.list}>
|
||||||
|
{(itemId, index) => {
|
||||||
|
const path = timeline.getPath(itemId)!;
|
||||||
|
const toots = path.reverse().map((x) => x.value);
|
||||||
|
|
||||||
<TootList
|
return (
|
||||||
threads={timeline.list}
|
<Thread
|
||||||
onUnknownThread={timeline.getPath}
|
toots={toots}
|
||||||
onChangeToot={timeline.set}
|
onBoost={onBoost}
|
||||||
></TootList>
|
onBookmark={onBookmark}
|
||||||
|
onReply={({ status }, element) =>
|
||||||
|
props.openFullScreenToot(status, element, true)
|
||||||
|
}
|
||||||
|
client={props.client}
|
||||||
|
isExpended={(status) => status.id === expandedThreadId()}
|
||||||
|
onItemClick={(status, event) => {
|
||||||
|
setTyping(false);
|
||||||
|
if (status.id !== expandedThreadId()) {
|
||||||
|
setExpandedThreadId((x) => (x ? undefined : status.id));
|
||||||
|
} else {
|
||||||
|
props.openFullScreenToot(
|
||||||
|
status,
|
||||||
|
event.currentTarget as HTMLElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
.TootBottomSheet {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.Scrollable {
|
|
||||||
padding-bottom: var(--safe-area-inset-bottom, 0);
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior-y: contain;
|
|
||||||
height: calc(100% - var(--scaffold-topbar-height, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +1,29 @@
|
||||||
import { useLocation, useParams } from "@solidjs/router";
|
import { useLocation, useNavigate, useParams } from "@solidjs/router";
|
||||||
import {
|
import {
|
||||||
catchError,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createResource,
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
Show,
|
Show,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
|
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material";
|
||||||
import { Title } from "~material/typography";
|
import { Title } from "../material/typography";
|
||||||
import { Close as CloseIcon } from "@suid/icons-material";
|
import {
|
||||||
import { useSessionForAcctStr } from "../masto/clients";
|
ArrowBack as BackIcon,
|
||||||
|
Close as CloseIcon,
|
||||||
|
} from "@suid/icons-material";
|
||||||
|
import { createUnauthorizedClient, useSessions } from "../masto/clients";
|
||||||
import { resolveCustomEmoji } from "../masto/toot";
|
import { resolveCustomEmoji } from "../masto/toot";
|
||||||
import RegularToot, { findElementActionable } from "./RegularToot";
|
import RegularToot from "./RegularToot";
|
||||||
import type { mastodon } from "masto";
|
import type { mastodon } from "masto";
|
||||||
import cards from "~material/cards.module.css";
|
import cards from "../material/cards.module.css";
|
||||||
import { css } from "solid-styled";
|
import { css } from "solid-styled";
|
||||||
import { vibrate } from "~platform/hardware";
|
import { vibrate } from "../platform/hardware";
|
||||||
import { createTimeSource, TimeSourceProvider } from "~platform/timesrc";
|
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
||||||
import TootComposer from "./TootComposer";
|
import TootComposer from "./TootComposer";
|
||||||
import { useDocumentTitle } from "../utils";
|
|
||||||
import { createTimelineControlsForArray } from "../masto/timelines";
|
|
||||||
import TootList from "./TootList";
|
|
||||||
import "./TootBottomSheet.css";
|
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
import BackButton from "~platform/BackButton";
|
|
||||||
|
|
||||||
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
let cachedEntry: [string, mastodon.v1.Status] | undefined;
|
||||||
|
|
||||||
|
@ -42,12 +40,32 @@ function getCache(acct: string, id: string) {
|
||||||
const TootBottomSheet: Component = (props) => {
|
const TootBottomSheet: Component = (props) => {
|
||||||
const params = useParams<{ acct: string; id: string }>();
|
const params = useParams<{ acct: string; id: string }>();
|
||||||
const location = useLocation<{
|
const location = useLocation<{
|
||||||
|
tootBottomSheetPushedCount?: number;
|
||||||
tootReply?: boolean;
|
tootReply?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const { pop, push } = useNavigator();
|
const navigate = useNavigate();
|
||||||
|
const allSession = useSessions();
|
||||||
const time = createTimeSource();
|
const time = createTimeSource();
|
||||||
|
const [isInTyping, setInTyping] = createSignal(false);
|
||||||
const acctText = () => decodeURIComponent(params.acct);
|
const acctText = () => decodeURIComponent(params.acct);
|
||||||
const session = useSessionForAcctStr(acctText);
|
const session = () => {
|
||||||
|
const [inputUsername, inputSite] = acctText().split("@", 2);
|
||||||
|
const authedSession = allSession().find(
|
||||||
|
(x) =>
|
||||||
|
x.account.site === inputSite &&
|
||||||
|
x.account.inf?.username === inputUsername,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
authedSession ?? {
|
||||||
|
client: createUnauthorizedClient(inputSite),
|
||||||
|
account: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushedCount = () => {
|
||||||
|
return location.state?.tootBottomSheetPushedCount || 0;
|
||||||
|
};
|
||||||
|
|
||||||
const [remoteToot, { mutate: setRemoteToot }] = createResource(
|
const [remoteToot, { mutate: setRemoteToot }] = createResource(
|
||||||
() => [session().client, params.id] as const,
|
() => [session().client, params.id] as const,
|
||||||
|
@ -56,10 +74,7 @@ const TootBottomSheet: Component = (props) => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const toot = () =>
|
const toot = () => remoteToot() ?? getCache(acctText(), params.id);
|
||||||
catchError(remoteToot, (error) => {
|
|
||||||
console.error(error);
|
|
||||||
}) ?? getCache(acctText(), params.id);
|
|
||||||
|
|
||||||
createEffect((lastTootId?: string) => {
|
createEffect((lastTootId?: string) => {
|
||||||
const tootId = toot()?.id;
|
const tootId = toot()?.id;
|
||||||
|
@ -69,40 +84,30 @@ const TootBottomSheet: Component = (props) => {
|
||||||
return tootId;
|
return tootId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tootContextErrorUncaught, { refetch: refetchContext }] =
|
createEffect(() => {
|
||||||
createResource(
|
if (location.state?.tootReply) {
|
||||||
() => [session().client, params.id] as const,
|
setInTyping(true);
|
||||||
async ([client, id]) => {
|
}
|
||||||
return await client.v1.statuses.$select(id).context.fetch();
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const tootContext = () =>
|
const [tootContext, { refetch: refetchContext }] = createResource(
|
||||||
catchError(tootContextErrorUncaught, (error) => {
|
() => [session().client, params.id] as const,
|
||||||
console.error(error);
|
async ([client, id]) => {
|
||||||
});
|
return await client.v1.statuses.$select(id).context.fetch();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const ancestors = createTimelineControlsForArray(
|
const ancestors = () => tootContext()?.ancestors ?? [];
|
||||||
() => tootContext()?.ancestors,
|
const descendants = () => tootContext()?.descendants ?? [];
|
||||||
);
|
|
||||||
const descendants = createTimelineControlsForArray(
|
|
||||||
() => tootContext()?.descendants,
|
|
||||||
);
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (ancestors.list.length > 0) {
|
if (ancestors().length > 0) {
|
||||||
document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView();
|
document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useDocumentTitle(() => {
|
|
||||||
const t = toot()?.reblog ?? toot();
|
|
||||||
const name = t?.account.displayName ?? "Someone";
|
|
||||||
return `${name}'s toot`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tootDisplayName = () => {
|
const tootDisplayName = () => {
|
||||||
const t = toot()?.reblog ?? toot();
|
const t = toot();
|
||||||
if (t) {
|
if (t) {
|
||||||
return resolveCustomEmoji(t.account.displayName, t.account.emojis);
|
return resolveCustomEmoji(t.account.displayName, t.account.emojis);
|
||||||
}
|
}
|
||||||
|
@ -157,45 +162,31 @@ const TootBottomSheet: Component = (props) => {
|
||||||
setRemoteToot(result);
|
setRemoteToot(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const switchContext = (status: mastodon.v1.Status) => {
|
||||||
|
if (isInTyping()) {
|
||||||
|
setInTyping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCache(params.acct, status);
|
||||||
|
navigate(`/${params.acct}/${status.id}`, {
|
||||||
|
state: {
|
||||||
|
tootBottomSheetPushedCount: pushedCount() + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const defaultMentions = () => {
|
const defaultMentions = () => {
|
||||||
const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account;
|
const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account;
|
||||||
if (!tootAcct) {
|
if (!tootAcct) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const others = ancestors.list.map((x) => ancestors.get(x)!.value.account);
|
const others = ancestors().map((x) => x.account);
|
||||||
|
|
||||||
const values = [tootAcct, ...others].map((x) => `@${x.acct}`);
|
const values = [tootAcct, ...others].map((x) => `@${x.acct}`);
|
||||||
return Array.from(new Set(values).keys());
|
return Array.from(new Set(values).keys());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMainTootClick = (
|
|
||||||
event: MouseEvent & { currentTarget: HTMLElement },
|
|
||||||
) => {
|
|
||||||
const actionableElement = findElementActionable(
|
|
||||||
event.target as HTMLElement,
|
|
||||||
event.currentTarget,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (actionableElement) {
|
|
||||||
if (actionableElement.dataset.action === "acct") {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const target = actionableElement as HTMLAnchorElement;
|
|
||||||
|
|
||||||
const acct = encodeURIComponent(
|
|
||||||
target.dataset.client || `@${new URL(target.href).origin}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
push(`/${acct}/profile/${target.dataset.acctId}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.warn("unknown action", actionableElement.dataset.rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
css`
|
css`
|
||||||
.name :global(img) {
|
.name :global(img) {
|
||||||
max-height: 1em;
|
max-height: 1em;
|
||||||
|
@ -229,8 +220,14 @@ const TootBottomSheet: Component = (props) => {
|
||||||
variant="dense"
|
variant="dense"
|
||||||
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
|
||||||
>
|
>
|
||||||
<BackButton color="inherit" />
|
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
|
||||||
<Title component="div" class="name" use:solid-styled>
|
{pushedCount() > 0 ? <BackIcon /> : <CloseIcon />}
|
||||||
|
</IconButton>
|
||||||
|
<Title
|
||||||
|
component="div"
|
||||||
|
class="name"
|
||||||
|
use:solid-styled
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
ref={(e: HTMLElement) =>
|
ref={(e: HTMLElement) =>
|
||||||
createRenderEffect(
|
createRenderEffect(
|
||||||
|
@ -243,62 +240,77 @@ const TootBottomSheet: Component = (props) => {
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
}
|
}
|
||||||
class="TootBottomSheet"
|
|
||||||
>
|
>
|
||||||
<div class="Scrollable">
|
<TimeSourceProvider value={time}>
|
||||||
<TimeSourceProvider value={time}>
|
<For each={ancestors()}>
|
||||||
<TootList
|
{(item) => (
|
||||||
threads={ancestors.list}
|
<RegularToot
|
||||||
onUnknownThread={ancestors.getPath}
|
id={`toot-${item.id}`}
|
||||||
onChangeToot={ancestors.set}
|
class={cards.card}
|
||||||
/>
|
status={item}
|
||||||
|
actionable={false}
|
||||||
|
onClick={[switchContext, item]}
|
||||||
|
></RegularToot>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<Show when={toot()}>
|
<Show when={toot()}>
|
||||||
<RegularToot
|
<RegularToot
|
||||||
id={`toot-${toot()!.id}`}
|
id={`toot-${toot()!.id}`}
|
||||||
class={cards.card}
|
class={cards.card}
|
||||||
style={{
|
style={{
|
||||||
"scroll-margin-top":
|
"scroll-margin-top":
|
||||||
"calc(var(--scaffold-topbar-height) + 20px)",
|
"calc(var(--scaffold-topbar-height) + 20px)",
|
||||||
"cursor": "auto",
|
}}
|
||||||
"user-select": "auto",
|
status={toot()!}
|
||||||
}}
|
actionable={!!actSession()}
|
||||||
status={toot()!}
|
evaluated={true}
|
||||||
actionable={!!actSession()}
|
onBookmark={onBookmark}
|
||||||
evaluated={true}
|
onRetoot={onBoost}
|
||||||
onBookmark={onBookmark}
|
onFavourite={onFav}
|
||||||
onRetoot={onBoost}
|
></RegularToot>
|
||||||
onFavourite={onFav}
|
|
||||||
onClick={handleMainTootClick}
|
|
||||||
></RegularToot>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<Show when={session()!.account}>
|
|
||||||
<TootComposer
|
|
||||||
mentions={defaultMentions()}
|
|
||||||
profile={session().account!}
|
|
||||||
replyToDisplayName={toot()?.account?.displayName || ""}
|
|
||||||
client={session().client}
|
|
||||||
onSent={() => refetchContext()}
|
|
||||||
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
</article>
|
||||||
|
|
||||||
<Show when={tootContextErrorUncaught.loading}>
|
<Show when={session()!.account}>
|
||||||
<div class="progress-line">
|
<TootComposer
|
||||||
<CircularProgress style="width: 1.5em; height: 1.5em;" />
|
isTyping={isInTyping()}
|
||||||
</div>
|
onTypingChange={setInTyping}
|
||||||
</Show>
|
mentions={defaultMentions()}
|
||||||
|
profile={session().account!}
|
||||||
<TootList
|
replyToDisplayName={toot()?.account?.displayName || ""}
|
||||||
threads={descendants.list}
|
client={session().client}
|
||||||
onUnknownThread={descendants.getPath}
|
onSent={() => refetchContext()}
|
||||||
onChangeToot={descendants.set}
|
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
|
||||||
/>
|
/>
|
||||||
</TimeSourceProvider>
|
</Show>
|
||||||
</div>
|
|
||||||
|
<Show when={tootContext.loading}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
"justify-content": "center",
|
||||||
|
"margin-block": "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress style="width: 1.5em; height: 1.5em;" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={descendants()}>
|
||||||
|
{(item) => (
|
||||||
|
<RegularToot
|
||||||
|
id={`toot-${item.id}`}
|
||||||
|
class={cards.card}
|
||||||
|
status={item}
|
||||||
|
actionable={false}
|
||||||
|
onClick={[switchContext, item]}
|
||||||
|
></RegularToot>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TimeSourceProvider>
|
||||||
|
<div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div>
|
||||||
</Scaffold>
|
</Scaffold>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
.TootComposer {
|
|
||||||
--card-gut: 8px;
|
|
||||||
contain: content;
|
|
||||||
|
|
||||||
> .MuiToolbar-root {
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
> :first-child {
|
|
||||||
margin-left: -0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
> :last-child {
|
|
||||||
margin-right: -0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-input {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 16px;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
padding-top: 16px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
margin-left: -0.5em;
|
|
||||||
margin-right: -0.5em;
|
|
||||||
|
|
||||||
animation: TootComposerFadeIn 110ms var(--tutu-anim-curve-sharp) both;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes TootComposerFadeIn {
|
|
||||||
0% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
11
src/timelines/TootComposer.module.css
Normal file
11
src/timelines/TootComposer.module.css
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
composes: card from "../material/cards.module.css";
|
||||||
|
--card-gut: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replyInput {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
|
@ -1,15 +1,15 @@
|
||||||
import {
|
import {
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
|
||||||
createSignal,
|
createSignal,
|
||||||
|
createUniqueId,
|
||||||
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
type Accessor,
|
|
||||||
type Component,
|
type Component,
|
||||||
type JSX,
|
type JSX,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import Scaffold from "~material/Scaffold";
|
import Scaffold from "../material/Scaffold";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
|
@ -23,9 +23,6 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Divider,
|
Divider,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Toolbar,
|
|
||||||
MenuItem,
|
|
||||||
ListItemAvatar,
|
|
||||||
} from "@suid/material";
|
} from "@suid/material";
|
||||||
import {
|
import {
|
||||||
ArrowDropDown,
|
ArrowDropDown,
|
||||||
|
@ -36,27 +33,20 @@ import {
|
||||||
ListAlt as ListAltIcon,
|
ListAlt as ListAltIcon,
|
||||||
Visibility,
|
Visibility,
|
||||||
Translate,
|
Translate,
|
||||||
Close,
|
|
||||||
MoreVert,
|
|
||||||
} from "@suid/icons-material";
|
} from "@suid/icons-material";
|
||||||
import type { Account } from "../accounts/stores";
|
import type { Account } from "../accounts/stores";
|
||||||
import "./TootComposer.css";
|
import tootComposers from "./TootComposer.module.css";
|
||||||
import BottomSheet from "~material/BottomSheet";
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
import { useLanguage } from "~platform/i18n";
|
import BottomSheet from "../material/BottomSheet";
|
||||||
|
import { useLanguage } from "../platform/i18n";
|
||||||
import iso639_1 from "iso-639-1";
|
import iso639_1 from "iso-639-1";
|
||||||
import ChooseTootLang from "./ChooseTootLang";
|
import ChooseTootLang from "./ChooseTootLang";
|
||||||
import type { mastodon } from "masto";
|
import type { mastodon } from "masto";
|
||||||
import cardStyles from "~material/cards.module.css";
|
|
||||||
import Menu, { createManagedMenuState } from "~material/Menu";
|
|
||||||
import { useDefaultSession } from "../masto/clients";
|
|
||||||
import { resolveCustomEmoji } from "../masto/toot";
|
|
||||||
import SizedTextarea from "~platform/SizedTextarea";
|
|
||||||
|
|
||||||
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
type TootVisibility = "public" | "unlisted" | "private" | "direct";
|
||||||
|
|
||||||
const TootVisibilityPickerDialog: Component<{
|
const TootVisibilityPickerDialog: Component<{
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
class?: string;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
visibility: TootVisibility;
|
visibility: TootVisibility;
|
||||||
onVisibilityChange: (value: TootVisibility) => void;
|
onVisibilityChange: (value: TootVisibility) => void;
|
||||||
|
@ -86,19 +76,14 @@ const TootVisibilityPickerDialog: Component<{
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheet
|
<BottomSheet open={props.open} onClose={props.onClose} bottomUp>
|
||||||
open={props.open}
|
|
||||||
onClose={props.onClose}
|
|
||||||
bottomUp
|
|
||||||
class={props.class}
|
|
||||||
>
|
|
||||||
<Scaffold
|
<Scaffold
|
||||||
bottom={
|
bottom={
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
"border-top": "1px solid #ddd",
|
"border-top": "1px solid #ddd",
|
||||||
background: "var(--tutu-color-surface)",
|
background: "var(--tutu-color-surface)",
|
||||||
padding: "8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))",
|
padding: "8px 16px",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
"text-align": "end",
|
"text-align": "end",
|
||||||
}}
|
}}
|
||||||
|
@ -178,13 +163,12 @@ const TootVisibilityPickerDialog: Component<{
|
||||||
|
|
||||||
const TootLanguagePickerDialog: Component<{
|
const TootLanguagePickerDialog: Component<{
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
class?: string;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
code: string;
|
code: string;
|
||||||
onCodeChange: (nval: string) => void;
|
onCodeChange: (nval: string) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
return (
|
return (
|
||||||
<BottomSheet open={props.open} onClose={props.onClose} class={props.class}>
|
<BottomSheet open={props.open} onClose={props.onClose}>
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<ChooseTootLang
|
<ChooseTootLang
|
||||||
code={props.code}
|
code={props.code}
|
||||||
|
@ -204,41 +188,33 @@ function randomChoose<T extends any[]>(
|
||||||
return K[idx];
|
return K[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
function useRandomChoice<T>(choices: () => T[]): Accessor<T> {
|
|
||||||
return createMemo(() => randomChoose(Math.random(), choices()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEvent(event: Event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
const TootComposer: Component<{
|
const TootComposer: Component<{
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
style?: JSX.CSSProperties;
|
style?: JSX.CSSProperties;
|
||||||
profile?: Account;
|
profile?: Account;
|
||||||
replyToDisplayName?: string;
|
replyToDisplayName?: string;
|
||||||
mentions?: readonly string[];
|
mentions?: readonly string[];
|
||||||
|
isTyping?: boolean;
|
||||||
|
onTypingChange: (value: boolean) => void;
|
||||||
client?: mastodon.rest.Client;
|
client?: mastodon.rest.Client;
|
||||||
inReplyToId?: string;
|
inReplyToId?: string;
|
||||||
onSent?: (status: mastodon.v1.Status) => void;
|
onSent?: (status: mastodon.v1.Status) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
let inputRef: HTMLTextAreaElement;
|
let inputRef: HTMLTextAreaElement;
|
||||||
|
let sendKey: string | undefined;
|
||||||
|
|
||||||
const session = useDefaultSession();
|
const typing = () => props.isTyping;
|
||||||
|
const setTyping = (v: boolean) => props.onTypingChange(v);
|
||||||
const [active, setActive] = createSignal(false);
|
|
||||||
const [sending, setSending] = createSignal(false);
|
const [sending, setSending] = createSignal(false);
|
||||||
const [visibility, setVisibility] = createSignal<TootVisibility>("public");
|
const [visibility, setVisibility] = createSignal<TootVisibility>("public");
|
||||||
const [permPicker, setPermPicker] = createSignal(false);
|
const [permPicker, setPermPicker] = createSignal(false);
|
||||||
const [language, setLanguage] = createSignal("en");
|
const [language, setLanguage] = createSignal("en");
|
||||||
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
const [langPickerOpen, setLangPickerOpen] = createSignal(false);
|
||||||
const appLanguage = useLanguage();
|
const appLanguage = useLanguage();
|
||||||
const [openMenu, menuState] = createManagedMenuState();
|
|
||||||
|
|
||||||
const randomPlaceholder = useRandomChoice(() => [
|
const randomPlaceholder = createMemo(() =>
|
||||||
"What's happening?",
|
randomChoose(Math.random(), ["What's happening?", "What do your think?"]),
|
||||||
"What do you think?",
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lang = appLanguage().split("-")[0];
|
const lang = appLanguage().split("-")[0];
|
||||||
|
@ -246,11 +222,15 @@ const TootComposer: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (active()) {
|
if (typing()) {
|
||||||
setTimeout(() => inputRef.focus(), 0);
|
setTimeout(() => inputRef.focus(), 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
makeEventListener(inputRef, "focus", () => setTyping(true));
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (inputRef.value !== "") return;
|
if (inputRef.value !== "") return;
|
||||||
if (props.mentions) {
|
if (props.mentions) {
|
||||||
|
@ -260,7 +240,7 @@ const TootComposer: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = () =>
|
const containerStyle = () =>
|
||||||
active() || permPicker()
|
typing() || permPicker()
|
||||||
? {
|
? {
|
||||||
position: "sticky" as const,
|
position: "sticky" as const,
|
||||||
top: "var(--scaffold-topbar-height, 0)",
|
top: "var(--scaffold-topbar-height, 0)",
|
||||||
|
@ -283,15 +263,17 @@ const TootComposer: Component<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const idempotencyKey = createMemo(() => window.crypto.randomUUID());
|
const getOrGenSendKey = () => {
|
||||||
|
if (sendKey === undefined) {
|
||||||
|
sendKey = window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return sendKey;
|
||||||
|
};
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const client = session()?.client;
|
|
||||||
if (!client) return;
|
|
||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const status = await client.v1.statuses.create(
|
const status = await props.client!.v1.statuses.create(
|
||||||
{
|
{
|
||||||
status: inputRef.value,
|
status: inputRef.value,
|
||||||
language: language(),
|
language: language(),
|
||||||
|
@ -301,7 +283,7 @@ const TootComposer: Component<{
|
||||||
{
|
{
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers: {
|
headers: {
|
||||||
["Idempotency-Key"]: idempotencyKey(),
|
["Idempotency-Key"]: getOrGenSendKey(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -317,72 +299,27 @@ const TootComposer: Component<{
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.ref}
|
ref={props.ref}
|
||||||
class={/* @once */ `TootComposer ${cardStyles.card}`}
|
class={tootComposers.composer}
|
||||||
style={containerStyle()}
|
style={containerStyle()}
|
||||||
on:touchend={
|
onClick={(e) => inputRef.focus()}
|
||||||
cancelEvent
|
|
||||||
/* on: is required to register the event handler on the exact element */
|
|
||||||
}
|
|
||||||
on:touchmove={cancelEvent}
|
|
||||||
on:wheel={cancelEvent}
|
|
||||||
>
|
>
|
||||||
<Show when={active()}>
|
<div class={tootComposers.replyInput}>
|
||||||
<Toolbar class={cardStyles.cardNoPad}>
|
|
||||||
<IconButton
|
|
||||||
onClick={[setActive, false]}
|
|
||||||
aria-label="Close the composer"
|
|
||||||
>
|
|
||||||
<Close />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={(e) => openMenu(e.currentTarget.getBoundingClientRect())}
|
|
||||||
>
|
|
||||||
<MoreVert />
|
|
||||||
</IconButton>
|
|
||||||
</Toolbar>
|
|
||||||
<div class={cardStyles.cardNoPad}>
|
|
||||||
<Menu {...menuState}>
|
|
||||||
<MenuItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar src={session()?.account.inf?.avatar}></Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText secondary={"Default account"}>
|
|
||||||
<span
|
|
||||||
ref={(e) => {
|
|
||||||
createRenderEffect(() => {
|
|
||||||
const inf = session()?.account.inf;
|
|
||||||
return (e.innerHTML = resolveCustomEmoji(
|
|
||||||
inf?.displayName || "",
|
|
||||||
inf?.emojis ?? [],
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
></span>
|
|
||||||
</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="reply-input">
|
|
||||||
<Show when={props.profile}>
|
<Show when={props.profile}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={props.profile!.inf?.avatar}
|
src={props.profile!.inf?.avatar}
|
||||||
sx={{ marginLeft: "-0.25em" }}
|
sx={{ marginLeft: "-0.25em" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<SizedTextarea
|
<textarea
|
||||||
ref={inputRef!}
|
ref={inputRef!}
|
||||||
placeholder={
|
placeholder={
|
||||||
props.replyToDisplayName
|
props.replyToDisplayName
|
||||||
? `Reply to ${props.replyToDisplayName}...`
|
? `Reply to ${props.replyToDisplayName}...`
|
||||||
: randomPlaceholder()
|
: randomPlaceholder()
|
||||||
}
|
}
|
||||||
onFocus={[setActive, true]}
|
|
||||||
style={{ width: "100%", border: "none" }}
|
style={{ width: "100%", border: "none" }}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
autocomplete="off"
|
></textarea>
|
||||||
></SizedTextarea>
|
|
||||||
<Show when={props.client}>
|
<Show when={props.client}>
|
||||||
<Show
|
<Show
|
||||||
when={!sending()}
|
when={!sending()}
|
||||||
|
@ -398,43 +335,36 @@ const TootComposer: Component<{
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton sx={{ marginRight: "-0.5em" }} onClick={send}>
|
||||||
sx={{ marginRight: "-0.5em" }}
|
|
||||||
onClick={send}
|
|
||||||
aria-label="Send"
|
|
||||||
>
|
|
||||||
<Send />
|
<Send />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={active()}>
|
<Show when={typing()}>
|
||||||
<div class="options">
|
<div
|
||||||
<Button
|
style={{
|
||||||
startIcon={<Translate />}
|
display: "flex",
|
||||||
endIcon={<ArrowDropDown />}
|
"justify-content": "flex-end",
|
||||||
onClick={[setLangPickerOpen, true]}
|
"margin-top": "8px",
|
||||||
disabled={sending()}
|
gap: "16px",
|
||||||
>
|
"flex-flow": "row wrap",
|
||||||
<span style={{ "vertical-align": "bottom" }}>
|
}}
|
||||||
{iso639_1.getNativeName(language())}
|
>
|
||||||
</span>
|
<Button onClick={[setLangPickerOpen, true]} disabled={sending()}>
|
||||||
|
<Translate sx={{ marginTop: "-0.25em", marginRight: "0.25em" }} />
|
||||||
|
{iso639_1.getNativeName(language())}
|
||||||
|
<ArrowDropDown sx={{ marginTop: "-0.25em" }} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={[setPermPicker, true]} disabled={sending()}>
|
||||||
startIcon={<Visibility />}
|
<Visibility sx={{ marginTop: "-0.15em", marginRight: "0.25em" }} />
|
||||||
endIcon={<ArrowDropDown />}
|
{visibilityText()}
|
||||||
onClick={[setPermPicker, true]}
|
<ArrowDropDown sx={{ marginTop: "-0.25em" }} />
|
||||||
disabled={sending()}
|
|
||||||
>
|
|
||||||
<span style={{ "vertical-align": "bottom" }}>
|
|
||||||
{visibilityText()}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TootVisibilityPickerDialog
|
<TootVisibilityPickerDialog
|
||||||
class={cardStyles.cardNoPad}
|
|
||||||
open={permPicker()}
|
open={permPicker()}
|
||||||
onClose={() => setPermPicker(false)}
|
onClose={() => setPermPicker(false)}
|
||||||
visibility={visibility()}
|
visibility={visibility()}
|
||||||
|
@ -442,7 +372,6 @@ const TootComposer: Component<{
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TootLanguagePickerDialog
|
<TootLanguagePickerDialog
|
||||||
class={cardStyles.cardNoPad}
|
|
||||||
open={langPickerOpen()}
|
open={langPickerOpen()}
|
||||||
onClose={() => setLangPickerOpen(false)}
|
onClose={() => setLangPickerOpen(false)}
|
||||||
code={language()}
|
code={language()}
|
||||||
|
|
|
@ -1,288 +0,0 @@
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
createSignal,
|
|
||||||
ErrorBoundary,
|
|
||||||
type Ref,
|
|
||||||
createSelector,
|
|
||||||
Index,
|
|
||||||
createMemo,
|
|
||||||
} from "solid-js";
|
|
||||||
import { type mastodon } from "masto";
|
|
||||||
import { vibrate } from "~platform/hardware";
|
|
||||||
import { useDefaultSession } from "../masto/clients";
|
|
||||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
|
||||||
import RegularToot, {
|
|
||||||
findElementActionable,
|
|
||||||
findRootToot,
|
|
||||||
} from "./RegularToot";
|
|
||||||
import cardStyle from "~material/cards.module.css";
|
|
||||||
import type { ThreadNode } from "../masto/timelines";
|
|
||||||
import { useNavigator } from "~platform/StackedRouter";
|
|
||||||
import { ANIM_CURVE_STD } from "~material/theme";
|
|
||||||
|
|
||||||
function durationOf(rect0: DOMRect, rect1: DOMRect) {
|
|
||||||
const distancelt = Math.sqrt(
|
|
||||||
Math.pow(Math.abs(rect0.top - rect1.top), 2) +
|
|
||||||
Math.pow(Math.abs(rect0.left - rect1.left), 2),
|
|
||||||
);
|
|
||||||
const distancerb = Math.sqrt(
|
|
||||||
Math.pow(Math.abs(rect0.bottom - rect1.bottom), 2) +
|
|
||||||
Math.pow(Math.abs(rect0.right - rect1.right), 2),
|
|
||||||
);
|
|
||||||
const distance = distancelt + distancerb;
|
|
||||||
const duration = distance / 1.6;
|
|
||||||
return duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionTootInThread(index: number, threadLength: number) {
|
|
||||||
if (index === 0) {
|
|
||||||
return "top";
|
|
||||||
} else if (index === threadLength - 1) {
|
|
||||||
return "bottom";
|
|
||||||
}
|
|
||||||
return "middle";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TootList: Component<{
|
|
||||||
ref?: Ref<HTMLDivElement>;
|
|
||||||
id?: string;
|
|
||||||
threads: readonly string[];
|
|
||||||
onUnknownThread: (id: string) => ThreadNode[] | undefined;
|
|
||||||
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
|
|
||||||
}> = (props) => {
|
|
||||||
const session = useDefaultSession();
|
|
||||||
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
|
|
||||||
const { push } = useNavigator();
|
|
||||||
|
|
||||||
const onBookmark = async (status: mastodon.v1.Status) => {
|
|
||||||
const client = session()?.client;
|
|
||||||
if (!client) return;
|
|
||||||
|
|
||||||
const result = await (status.bookmarked
|
|
||||||
? client.v1.statuses.$select(status.id).unbookmark()
|
|
||||||
: client.v1.statuses.$select(status.id).bookmark());
|
|
||||||
props.onChangeToot(result.id, result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBoost = async (status: mastodon.v1.Status) => {
|
|
||||||
const client = session()?.client;
|
|
||||||
if (!client) return;
|
|
||||||
|
|
||||||
vibrate(50);
|
|
||||||
const rootStatus = status.reblog ? status.reblog : status;
|
|
||||||
const reblogged = rootStatus.reblogged;
|
|
||||||
if (status.reblog) {
|
|
||||||
props.onChangeToot(status.id, {
|
|
||||||
...status,
|
|
||||||
reblog: { ...status.reblog, reblogged: !reblogged },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
props.onChangeToot(status.id, {
|
|
||||||
...status,
|
|
||||||
reblogged: !reblogged,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = reblogged
|
|
||||||
? await client.v1.statuses.$select(status.id).unreblog()
|
|
||||||
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
|
||||||
|
|
||||||
if (status.reblog) {
|
|
||||||
props.onChangeToot(status.id, {
|
|
||||||
...status,
|
|
||||||
reblog: result,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
props.onChangeToot(status.id, result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFavourite = async (status: mastodon.v1.Status) => {
|
|
||||||
const client = session()?.client;
|
|
||||||
if (!client) return;
|
|
||||||
const ovalue = status.favourited;
|
|
||||||
props.onChangeToot(status.id, { ...status, favourited: !ovalue });
|
|
||||||
|
|
||||||
const result = ovalue
|
|
||||||
? await client.v1.statuses.$select(status.id).unfavourite()
|
|
||||||
: await client.v1.statuses.$select(status.id).favourite();
|
|
||||||
props.onChangeToot(status.id, result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFullScreenToot = (
|
|
||||||
toot: mastodon.v1.Status,
|
|
||||||
srcElement: HTMLElement,
|
|
||||||
reply?: boolean,
|
|
||||||
) => {
|
|
||||||
const p = session()?.account;
|
|
||||||
if (!p) return;
|
|
||||||
const inf = p.inf;
|
|
||||||
if (!inf) {
|
|
||||||
console.warn("no account info?");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const acct = `${inf.username}@${p.site}`;
|
|
||||||
setTootBottomSheetCache(acct, toot);
|
|
||||||
|
|
||||||
push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
|
|
||||||
animateOpen(element) {
|
|
||||||
const rect0 = srcElement.getBoundingClientRect(); // the start rect
|
|
||||||
const rect1 = element.getBoundingClientRect(); // the end rect
|
|
||||||
|
|
||||||
const duration = durationOf(rect0, rect1);
|
|
||||||
|
|
||||||
const keyframes = {
|
|
||||||
top: [`${rect0.top}px`, `${rect1.top}px`],
|
|
||||||
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
|
|
||||||
left: [`${rect0.left}px`, `${rect1.left}px`],
|
|
||||||
right: [`${rect0.right}px`, `${rect1.right}px`],
|
|
||||||
height: [`${rect0.height}px`, `${rect1.height}px`],
|
|
||||||
margin: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
srcElement.style.visibility = "hidden";
|
|
||||||
|
|
||||||
const animation = element.animate(keyframes, {
|
|
||||||
duration,
|
|
||||||
easing: ANIM_CURVE_STD,
|
|
||||||
});
|
|
||||||
return animation;
|
|
||||||
},
|
|
||||||
|
|
||||||
animateClose(element) {
|
|
||||||
const rect0 = element.getBoundingClientRect(); // the start rect
|
|
||||||
const rect1 = srcElement.getBoundingClientRect(); // the end rect
|
|
||||||
|
|
||||||
const duration = durationOf(rect0, rect1);
|
|
||||||
|
|
||||||
const keyframes = {
|
|
||||||
top: [`${rect0.top}px`, `${rect1.top}px`],
|
|
||||||
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
|
|
||||||
left: [`${rect0.left}px`, `${rect1.left}px`],
|
|
||||||
right: [`${rect0.right}px`, `${rect1.right}px`],
|
|
||||||
height: [`${rect0.height}px`, `${rect1.height}px`],
|
|
||||||
margin: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
srcElement.style.visibility = "";
|
|
||||||
|
|
||||||
const animation = element.animate(keyframes, {
|
|
||||||
duration,
|
|
||||||
easing: ANIM_CURVE_STD,
|
|
||||||
});
|
|
||||||
return animation;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onItemClick = (
|
|
||||||
status: mastodon.v1.Status,
|
|
||||||
event: MouseEvent & { target: EventTarget; currentTarget: HTMLElement },
|
|
||||||
) => {
|
|
||||||
if (!(event.target instanceof HTMLElement)) {
|
|
||||||
throw new Error("target is not an element");
|
|
||||||
}
|
|
||||||
const actionableElement = findElementActionable(
|
|
||||||
event.target,
|
|
||||||
event.currentTarget,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (actionableElement && checkIsExpended(status)) {
|
|
||||||
if (actionableElement.dataset.action === "acct") {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const target = actionableElement as HTMLAnchorElement;
|
|
||||||
|
|
||||||
const acct = encodeURIComponent(
|
|
||||||
target.dataset.client || `@${new URL(target.href).origin}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
push(`/${acct}/profile/${target.dataset.acctId}`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.warn("unknown action", actionableElement.dataset.rel);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
event.target.parentElement &&
|
|
||||||
event.target.parentElement.tagName === "A"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// else if (!actionableElement || !checkIsExpended(status) || <rel is not one of known action>)
|
|
||||||
if (status.id !== expandedThreadId()) {
|
|
||||||
setExpandedThreadId((x) => (x ? undefined : status.id));
|
|
||||||
} else {
|
|
||||||
openFullScreenToot(status, event.currentTarget as HTMLElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkIsExpendedId = createSelector(expandedThreadId);
|
|
||||||
|
|
||||||
const checkIsExpended = (status: mastodon.v1.Status) =>
|
|
||||||
checkIsExpendedId(status.id);
|
|
||||||
|
|
||||||
const reply = (
|
|
||||||
status: mastodon.v1.Status,
|
|
||||||
event: { currentTarget: HTMLElement },
|
|
||||||
) => {
|
|
||||||
const element = findRootToot(event.currentTarget);
|
|
||||||
openFullScreenToot(status, element, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary
|
|
||||||
fallback={(err, reset) => {
|
|
||||||
console.error(err);
|
|
||||||
return <p>Oops: {String(err)}</p>;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={props.ref} id={props.id} class="toot-list">
|
|
||||||
<Index each={props.threads}>
|
|
||||||
{(threadId, threadIdx) => {
|
|
||||||
const thread = createMemo(() =>
|
|
||||||
props.onUnknownThread(threadId())?.reverse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const threadLength = () => thread()?.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Index each={thread()}>
|
|
||||||
{(threadNode, index) => {
|
|
||||||
const status = () => threadNode().value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RegularToot
|
|
||||||
data-status-id={status().id}
|
|
||||||
data-thread={threadIdx}
|
|
||||||
data-thread-len={threadLength()}
|
|
||||||
data-thread-sort={index}
|
|
||||||
status={status()}
|
|
||||||
thread={
|
|
||||||
threadLength() > 1
|
|
||||||
? positionTootInThread(index, threadLength())
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
class={cardStyle.card}
|
|
||||||
evaluated={checkIsExpended(status())}
|
|
||||||
actionable={checkIsExpended(status())}
|
|
||||||
onBookmark={onBookmark}
|
|
||||||
onRetoot={toggleBoost}
|
|
||||||
onFavourite={toggleFavourite}
|
|
||||||
onReply={reply}
|
|
||||||
onClick={[onItemClick, status()]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TootList;
|
|
102
src/timelines/TootThread.tsx
Normal file
102
src/timelines/TootThread.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import type { mastodon } from "masto";
|
||||||
|
import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
|
||||||
|
import CompactToot from "./CompactToot";
|
||||||
|
import { useTimeSource } from "../platform/timesrc";
|
||||||
|
import RegularToot from "./RegularToot";
|
||||||
|
import cardStyle from "../material/cards.module.css";
|
||||||
|
import { css } from "solid-styled";
|
||||||
|
|
||||||
|
type TootThreadProps = {
|
||||||
|
ref?: Ref<HTMLElement>,
|
||||||
|
status: mastodon.v1.Status;
|
||||||
|
client: mastodon.rest.Client;
|
||||||
|
expanded?: 0 | 1 | 2;
|
||||||
|
|
||||||
|
onBoost?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||||
|
onBookmark?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
|
||||||
|
onReply?(client: mastodon.rest.Client, status: mastodon.v1.Status): void
|
||||||
|
onExpandChange?(level: 0 | 1 | 2): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TootThread: Component<TootThreadProps> = (props) => {
|
||||||
|
const status = () => props.status;
|
||||||
|
const now = useTimeSource();
|
||||||
|
const expanded = () => props.expanded ?? 0;
|
||||||
|
|
||||||
|
const [inReplyTo] = createResource(
|
||||||
|
() => [props.client, status().inReplyToId || null] as const,
|
||||||
|
async ([client, replyToId]) => {
|
||||||
|
if (!(client && replyToId)) return null;
|
||||||
|
return await client.v1.statuses.$select(replyToId).fetch();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const boost = (status: mastodon.v1.Status) => {
|
||||||
|
props.onBoost?.(props.client, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookmark = (status: mastodon.v1.Status) => {
|
||||||
|
props.onBookmark?.(props.client, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = (status: mastodon.v1.Status) => {
|
||||||
|
props.onReply?.(props.client, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
css`
|
||||||
|
article {
|
||||||
|
transition:
|
||||||
|
margin 90ms var(--tutu-anim-curve-sharp),
|
||||||
|
var(--tutu-transition-shadow);
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-line {
|
||||||
|
position: relative;
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 36px;
|
||||||
|
top: 16px;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--tutu-color-secondary);
|
||||||
|
width: 2px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
margin-block: 20px;
|
||||||
|
box-shadow: var(--tutu-shadow-e9);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const nextExpandLevel = [1, 2, 2] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={props.ref}
|
||||||
|
classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
|
||||||
|
onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
|
||||||
|
>
|
||||||
|
<Show when={inReplyTo()}>
|
||||||
|
<CompactToot
|
||||||
|
status={inReplyTo()!}
|
||||||
|
now={now()}
|
||||||
|
class={[cardStyle.card, cardStyle.manualMargin].join(" ")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<RegularToot
|
||||||
|
status={status()}
|
||||||
|
class={cardStyle.card}
|
||||||
|
actionable={expanded() > 0}
|
||||||
|
onBookmark={(s) => bookmark(s)}
|
||||||
|
onRetoot={(s) => boost(s)}
|
||||||
|
onReply={s => reply(s)}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TootThread;
|
|
@ -1,24 +1,100 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
createSignal,
|
createSignal,
|
||||||
|
untrack,
|
||||||
Match,
|
Match,
|
||||||
Switch as JsSwitch,
|
Switch as JsSwitch,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
|
createSelector,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { type mastodon } from "masto";
|
import { type mastodon } from "masto";
|
||||||
import { Button } from "@suid/material";
|
import { Button } from "@suid/material";
|
||||||
import { createTimelineSnapshot } from "../masto/timelines.js";
|
import { createTimelineSnapshot } from "../masto/timelines.js";
|
||||||
|
import { vibrate } from "../platform/hardware.js";
|
||||||
import PullDownToRefresh from "./PullDownToRefresh.jsx";
|
import PullDownToRefresh from "./PullDownToRefresh.jsx";
|
||||||
import TootList from "./TootList.jsx";
|
import Thread from "./Thread.jsx";
|
||||||
|
|
||||||
const TrendTimelinePanel: Component<{
|
const TrendTimelinePanel: Component<{
|
||||||
client: mastodon.rest.Client;
|
client: mastodon.rest.Client;
|
||||||
|
prefetch?: boolean;
|
||||||
|
|
||||||
|
openFullScreenToot: (
|
||||||
|
toot: mastodon.v1.Status,
|
||||||
|
srcElement?: HTMLElement,
|
||||||
|
reply?: boolean,
|
||||||
|
) => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
||||||
const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot(
|
const [
|
||||||
|
timeline,
|
||||||
|
snapshot,
|
||||||
|
{ refetch: refetchTimeline, mutate: mutateTimeline },
|
||||||
|
] = createTimelineSnapshot(
|
||||||
() => props.client.v1.trends.statuses,
|
() => props.client.v1.trends.statuses,
|
||||||
() => ({ limit: 120 }),
|
() => 120,
|
||||||
);
|
);
|
||||||
|
const [expandedId, setExpandedId] = createSignal<string>();
|
||||||
|
|
||||||
|
const tlEndObserver = new IntersectionObserver(() => {
|
||||||
|
if (untrack(() => props.prefetch) && !snapshot.loading)
|
||||||
|
refetchTimeline();
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => tlEndObserver.disconnect());
|
||||||
|
|
||||||
|
const isExpandedId = createSelector(expandedId);
|
||||||
|
|
||||||
|
const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id);
|
||||||
|
|
||||||
|
const onBookmark = async (
|
||||||
|
index: number,
|
||||||
|
client: mastodon.rest.Client,
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
) => {
|
||||||
|
const result = await (status.bookmarked
|
||||||
|
? client.v1.statuses.$select(status.id).unbookmark()
|
||||||
|
: client.v1.statuses.$select(status.id).bookmark());
|
||||||
|
mutateTimeline((o) => {
|
||||||
|
o![index] = [result];
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBoost = async (
|
||||||
|
index: number,
|
||||||
|
client: mastodon.rest.Client,
|
||||||
|
status: mastodon.v1.Status,
|
||||||
|
) => {
|
||||||
|
const reblogged = status.reblog
|
||||||
|
? status.reblog.reblogged
|
||||||
|
: status.reblogged;
|
||||||
|
vibrate(50);
|
||||||
|
mutateTimeline(index, (th) => {
|
||||||
|
const x = th[0];
|
||||||
|
if (x.reblog) {
|
||||||
|
x.reblog = { ...x.reblog, reblogged: !reblogged };
|
||||||
|
return [Object.assign({}, x)];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
Object.assign({}, x, {
|
||||||
|
reblogged: !reblogged,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = reblogged
|
||||||
|
? await client.v1.statuses.$select(status.id).unreblog()
|
||||||
|
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
|
||||||
|
mutateTimeline(index, (th) => {
|
||||||
|
Object.assign(th[0].reblog ?? th[0], {
|
||||||
|
reblogged: result.reblogged,
|
||||||
|
reblogsCount: result.reblogsCount,
|
||||||
|
});
|
||||||
|
return th;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
|
@ -29,7 +105,7 @@ const TrendTimelinePanel: Component<{
|
||||||
<PullDownToRefresh
|
<PullDownToRefresh
|
||||||
linkedElement={scrollLinked()}
|
linkedElement={scrollLinked()}
|
||||||
loading={snapshot.loading}
|
loading={snapshot.loading}
|
||||||
onRefresh={() => refetchTimeline("next")}
|
onRefresh={() => refetchTimeline({ direction: "new" })}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={(e) =>
|
ref={(e) =>
|
||||||
|
@ -38,12 +114,34 @@ const TrendTimelinePanel: Component<{
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TootList
|
<For each={timeline}>
|
||||||
threads={tl.list}
|
{(item, index) => {
|
||||||
onUnknownThread={tl.getPath}
|
let element: HTMLElement | undefined;
|
||||||
onChangeToot={tl.set}
|
return (
|
||||||
/>
|
<Thread
|
||||||
|
ref={element}
|
||||||
|
toots={item}
|
||||||
|
onBoost={(...args) => onBoost(index(), ...args)}
|
||||||
|
onBookmark={(...args) => onBookmark(index(), ...args)}
|
||||||
|
onReply={(client, status) =>
|
||||||
|
props.openFullScreenToot(status, element, true)
|
||||||
|
}
|
||||||
|
client={props.client}
|
||||||
|
isExpended={isExpanded}
|
||||||
|
onItemClick={(x) => {
|
||||||
|
if (x.id !== expandedId()) {
|
||||||
|
setExpandedId((o) => (o ? undefined : x.id));
|
||||||
|
} else {
|
||||||
|
props.openFullScreenToot(x, element);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -51,7 +149,7 @@ const TrendTimelinePanel: Component<{
|
||||||
"align-items": "center",
|
"align-items": "center",
|
||||||
"justify-content": "center",
|
"justify-content": "center",
|
||||||
"flex-flow": "column",
|
"flex-flow": "column",
|
||||||
gap: "20px",
|
gap: "20px"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JsSwitch>
|
<JsSwitch>
|
||||||
|
@ -64,6 +162,16 @@ const TrendTimelinePanel: Component<{
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={[refetchTimeline, undefined]}
|
||||||
|
disabled={snapshot.loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</Match>
|
</Match>
|
||||||
</JsSwitch>
|
</JsSwitch>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,15 +4,12 @@
|
||||||
--toot-avatar-size: 40px;
|
--toot-avatar-size: 40px;
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
contain: content;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.toot {
|
&.toot {
|
||||||
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
|
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
|
||||||
transition:
|
transition:
|
||||||
margin-top 60ms var(--tutu-anim-curve-sharp),
|
margin-block 125ms var(--tutu-anim-curve-std),
|
||||||
margin-bottom 60ms var(--tutu-anim-curve-sharp),
|
height 225ms var(--tutu-anim-curve-std),
|
||||||
height 60ms var(--tutu-anim-curve-sharp),
|
|
||||||
var(--tutu-transition-shadow);
|
var(--tutu-transition-shadow);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
@ -41,7 +38,6 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
contain: layout style;
|
|
||||||
|
|
||||||
> :not(:first-child) {
|
> :not(:first-child) {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -51,7 +47,10 @@
|
||||||
.tootAuthorNameGrp {
|
.tootAuthorNameGrp {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
|
>* {
|
||||||
|
color: var(--tutu-color-secondary-text-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
> :last-child {
|
> :last-child {
|
||||||
grid-column: 1 /3;
|
grid-column: 1 /3;
|
||||||
|
@ -69,14 +68,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tootAuthorNamePrimary {
|
.tootAuthorNamePrimary {
|
||||||
color: var(--tutu-color-on-surface);
|
color: revert;
|
||||||
|
|
||||||
> :global(.acct-mark) {
|
|
||||||
font-size: 1.2em;
|
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
vertical-align: sub;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tootAvatar {
|
.tootAvatar {
|
||||||
|
@ -89,6 +81,97 @@
|
||||||
background-color: var(--tutu-color-surface-d);
|
background-color: var(--tutu-color-surface-d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tootContent {
|
||||||
|
composes: cardNoPad from "../material/cards.module.css";
|
||||||
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
|
margin-right: var(--card-pad, 0);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
& a {
|
||||||
|
color: var(--tutu-color-primary-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
& :global(a[target="_blank"]) {
|
||||||
|
> :global(.invisible) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :global(.ellipsis) {
|
||||||
|
&::after {
|
||||||
|
display: inline;
|
||||||
|
content: "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewCard {
|
||||||
|
composes: cardGutSkip from "../material/cards.module.css";
|
||||||
|
display: block;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
background-color: var(--tutu-color-surface);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
color: var(--tutu-color-secondary-text-on-surface);
|
||||||
|
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--tutu-color-surface-d);
|
||||||
|
color: var(--tutu-color-on-surface);
|
||||||
|
|
||||||
|
>h1 {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>h1 {
|
||||||
|
color: var(--tutu-color-on-surface);
|
||||||
|
max-height: calc(4 * var(--title-line-height) * var(--title-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
|
max-height: calc(8 * var(--body-line-height) * var(--body-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
>h1,
|
||||||
|
>p {
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(10%, 30%) 1fr;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
|
||||||
|
>img:first-child {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
>h1,
|
||||||
|
>p {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toot.compact {
|
.toot.compact {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
@ -124,19 +207,48 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tootRetootGrp {
|
.compactTootContent {
|
||||||
display: flex;
|
composes: tootContent;
|
||||||
gap: 0.25em;
|
margin-left: 0;
|
||||||
margin-bottom: 8px;
|
margin-right: 0;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
> :first-child {
|
.tootRetootGrp {
|
||||||
margin-right: 0.25em;
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tootAttachmentGrp {
|
||||||
|
composes: cardNoPad from "../material/cards.module.css";
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
|
margin-right: var(--card-pad, 0);
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
> :where(img, video) {
|
||||||
|
max-height: 35vh;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 100%;
|
||||||
|
background-color: var(--tutu-color-surface-d);
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: border-color 220ms var(--tutu-anim-curve-std),
|
||||||
|
box-shadow 220ms var(--tutu-anim-curve-std);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border: 1px solid var(--tutu-color-surface-dd);
|
||||||
|
box-shadow: var(--tutu-shadow-e1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tootBottomActionGrp {
|
.tootBottomActionGrp {
|
||||||
composes: cardGutSkip from "~material/cards.module.css";
|
composes: cardGutSkip from "../material/cards.module.css";
|
||||||
padding-block: calc((var(--card-gut) - 10px) / 2);
|
padding-block: calc((var(--card-gut) - 10px) / 2);
|
||||||
|
|
||||||
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
|
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
.BoostIcon {
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 2px;
|
|
||||||
background-color: green;
|
|
||||||
padding: 0.125em;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
color: white;
|
|
||||||
font-size: 1em;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import {
|
|
||||||
splitProps,
|
|
||||||
type Component,
|
|
||||||
type JSX,
|
|
||||||
} from "solid-js";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Repeat,
|
|
||||||
} from "@suid/icons-material";
|
|
||||||
import "./BoostIcon.css";
|
|
||||||
|
|
||||||
|
|
||||||
const BoostIcon: Component<JSX.HTMLElementTags["i"]> = (props) => {
|
|
||||||
const [managed, rest] = splitProps(props, ["class"]);
|
|
||||||
return (
|
|
||||||
<i class={["BoostIcon", managed.class].join(" ")} {...rest}>
|
|
||||||
<Repeat />
|
|
||||||
</i>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BoostIcon;
|
|
|
@ -1,48 +0,0 @@
|
||||||
.MediaAttachmentGrid {
|
|
||||||
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-left: var(--card-pad, 0);
|
|
||||||
margin-right: var(--card-pad, 0);
|
|
||||||
contain: layout style;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
max-height: 35vh;
|
|
||||||
min-height: 40px;
|
|
||||||
min-width: 40px;
|
|
||||||
max-width: 100%;
|
|
||||||
contain: strict;
|
|
||||||
content-visibility: auto;
|
|
||||||
background-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
|
||||||
border-radius: 2px;
|
|
||||||
border: 1px solid var(--tutu-color-surface-d);
|
|
||||||
transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
|
|
||||||
border-color: var(--media-color-accent, var(--tutu-color-surface-d));
|
|
||||||
z-index: calc(var(--tutu-zidx-nav) - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> * > * {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
> * > :where(img, video) {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
> * >.sensitive-placeholder {
|
|
||||||
display: inline-flex;
|
|
||||||
display: inline flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(.thread-top, .thread-mid)>.MediaAttachmentGrid {
|
|
||||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
|
||||||
}
|
|
|
@ -1,244 +0,0 @@
|
||||||
import type { mastodon } from "masto";
|
|
||||||
import {
|
|
||||||
type Component,
|
|
||||||
Index,
|
|
||||||
Match,
|
|
||||||
Switch,
|
|
||||||
createMemo,
|
|
||||||
createRenderEffect,
|
|
||||||
createSignal,
|
|
||||||
onCleanup,
|
|
||||||
untrack,
|
|
||||||
} from "solid-js";
|
|
||||||
import MediaViewer from "../MediaViewer";
|
|
||||||
import { render } from "solid-js/web";
|
|
||||||
import {
|
|
||||||
createElementSize,
|
|
||||||
useWindowSize,
|
|
||||||
} from "@solid-primitives/resize-observer";
|
|
||||||
import { useStore } from "@nanostores/solid";
|
|
||||||
import { $settings } from "../../settings/stores";
|
|
||||||
import { averageColorHex } from "~platform/blurhash";
|
|
||||||
import "./MediaAttachmentGrid.css";
|
|
||||||
import cardStyle from "~material/cards.module.css";
|
|
||||||
import { Preview } from "@suid/icons-material";
|
|
||||||
import { IconButton } from "@suid/material";
|
|
||||||
import Masonry from "~platform/Masonry";
|
|
||||||
|
|
||||||
type ElementSize = { width: number; height: number };
|
|
||||||
|
|
||||||
function constraintedSize(
|
|
||||||
{ width: owidth, height: oheight }: Readonly<ElementSize>, // originalSize
|
|
||||||
{ width: mwidth, height: mheight }: Readonly<Partial<ElementSize>>, // modifier
|
|
||||||
{ width: maxWidth, height: maxHeight }: Readonly<ElementSize>, // maxSize
|
|
||||||
) {
|
|
||||||
const ySize = owidth + (mwidth ?? 0);
|
|
||||||
const yScale = ySize > maxWidth ? ySize / maxWidth : 1;
|
|
||||||
const xSize = oheight + (mheight ?? 0);
|
|
||||||
const xScale = xSize > maxHeight ? xSize / maxHeight : 1;
|
|
||||||
|
|
||||||
const maxScale = Math.max(yScale, xScale);
|
|
||||||
const scaledWidth = owidth / maxScale;
|
|
||||||
const scaledHeight = oheight / maxScale;
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: scaledWidth,
|
|
||||||
height: scaledHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isolateCallback(event: Event) {
|
|
||||||
if (event.target !== event.currentTarget) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MediaAttachmentGrid: Component<{
|
|
||||||
attachments: mastodon.v1.MediaAttachment[];
|
|
||||||
sensitive?: boolean;
|
|
||||||
}> = (props) => {
|
|
||||||
const [rootRef, setRootRef] = createSignal<HTMLElement>();
|
|
||||||
const [viewerIndex, setViewerIndex] = createSignal<number>();
|
|
||||||
const viewerOpened = () => typeof viewerIndex() !== "undefined";
|
|
||||||
const settings = useStore($settings);
|
|
||||||
const windowSize = useWindowSize();
|
|
||||||
const [reveal, setReveal] = createSignal([] as number[]);
|
|
||||||
|
|
||||||
createRenderEffect(() => {
|
|
||||||
const vidx = viewerIndex();
|
|
||||||
if (typeof vidx === "undefined") return;
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.setAttribute("role", "presentation");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const dispose = render(() => {
|
|
||||||
onCleanup(() => {
|
|
||||||
document.body.removeChild(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MediaViewer
|
|
||||||
show={viewerOpened()}
|
|
||||||
index={viewerIndex() || 0}
|
|
||||||
onIndexUpdated={setViewerIndex}
|
|
||||||
media={props.attachments}
|
|
||||||
onClose={() => setViewerIndex()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}, container);
|
|
||||||
|
|
||||||
onCleanup(dispose);
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawElementSize = createElementSize(rootRef);
|
|
||||||
|
|
||||||
const elementWidth = () => rawElementSize.width;
|
|
||||||
|
|
||||||
const itemMaxSize = createMemo(() => {
|
|
||||||
const ewidth = elementWidth();
|
|
||||||
const width = ewidth
|
|
||||||
? (ewidth - (columnCount() - 1) * 4) / columnCount()
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
height: windowSize.height * 0.35,
|
|
||||||
width,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemStyle = (item: mastodon.v1.MediaAttachment) => {
|
|
||||||
const { width, height } = constraintedSize(
|
|
||||||
item.meta?.small || { width: 1, height: 1 },
|
|
||||||
{ width: 2, height: 2 },
|
|
||||||
itemMaxSize(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const accentColor =
|
|
||||||
item.meta?.colors?.accent ??
|
|
||||||
(item.blurhash ? averageColorHex(item.blurhash) : undefined);
|
|
||||||
|
|
||||||
return Object.assign(
|
|
||||||
{
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`,
|
|
||||||
"contain-intrinsic-size": `${width}px ${height}px`,
|
|
||||||
},
|
|
||||||
accentColor ? { "--media-color-accent": accentColor } : {},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReveal = (idx: number) => {
|
|
||||||
return reveal().includes(idx);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addReveal = (idx: number) => {
|
|
||||||
if (!untrack(() => isReveal(idx))) {
|
|
||||||
setReveal((x) => [...x, idx]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Masonry
|
|
||||||
component="section"
|
|
||||||
ref={setRootRef}
|
|
||||||
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
|
|
||||||
classList={{
|
|
||||||
sensitive: props.sensitive,
|
|
||||||
}}
|
|
||||||
onClick={isolateCallback}
|
|
||||||
>
|
|
||||||
<Index each={props.attachments}>
|
|
||||||
{(item, index) => {
|
|
||||||
const itemType = () => item().type;
|
|
||||||
|
|
||||||
const style = createMemo(() => itemStyle(item()));
|
|
||||||
return (
|
|
||||||
<div style={style()} role="presentation">
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.sensitive && !isReveal(index)}>
|
|
||||||
<div
|
|
||||||
class="sensitive-placeholder"
|
|
||||||
data-sort={index}
|
|
||||||
data-media-type={item().type}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
size="large"
|
|
||||||
onClick={[addReveal, index]}
|
|
||||||
aria-label="Reveal this media"
|
|
||||||
>
|
|
||||||
<Preview />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={itemType() === "image"}>
|
|
||||||
<img
|
|
||||||
src={item().previewUrl}
|
|
||||||
width={item().meta?.small?.width}
|
|
||||||
height={item().meta?.small?.height}
|
|
||||||
alt={item().description || undefined}
|
|
||||||
onClick={[openViewerFor, index]}
|
|
||||||
loading="lazy"
|
|
||||||
data-sort={index}
|
|
||||||
data-media-type={item().type}
|
|
||||||
></img>
|
|
||||||
</Match>
|
|
||||||
<Match when={itemType() === "video"}>
|
|
||||||
<video
|
|
||||||
src={item().url || undefined}
|
|
||||||
autoplay={!props.sensitive && settings().autoPlayVideos}
|
|
||||||
playsinline={settings().autoPlayVideos ? true : undefined}
|
|
||||||
controls
|
|
||||||
poster={item().previewUrl}
|
|
||||||
width={item().meta?.small?.width}
|
|
||||||
height={item().meta?.small?.height}
|
|
||||||
data-sort={index}
|
|
||||||
data-media-type={item().type}
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={itemType() === "gifv"}>
|
|
||||||
<video
|
|
||||||
src={item().url || undefined}
|
|
||||||
autoplay={!props.sensitive && settings().autoPlayGIFs}
|
|
||||||
controls
|
|
||||||
playsinline /* or safari on iOS will play in full-screen */
|
|
||||||
loop
|
|
||||||
poster={item().previewUrl}
|
|
||||||
width={item().meta?.small?.width}
|
|
||||||
height={item().meta?.small?.height}
|
|
||||||
data-sort={index}
|
|
||||||
data-media-type={item().type}
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={itemType() === "audio"}>
|
|
||||||
<audio
|
|
||||||
src={item().url || undefined}
|
|
||||||
controls
|
|
||||||
data-sort={index}
|
|
||||||
data-media-type={item().type}
|
|
||||||
></audio>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
</Masonry>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MediaAttachmentGrid;
|
|
|
@ -1,71 +0,0 @@
|
||||||
.PreviewCard {
|
|
||||||
display: block;
|
|
||||||
border: 1px solid #eeeeee;
|
|
||||||
background-color: var(--tutu-color-surface-d);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 1.5em;
|
|
||||||
color: var(--tutu-color-secondary-text-on-surface);
|
|
||||||
transition: color 220ms var(--tutu-anim-curve-std), background-color 220ms var(--tutu-anim-curve-std);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
contain: layout style;
|
|
||||||
|
|
||||||
>img {
|
|
||||||
background-color: var(--tutu-color-surface);
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
|
|
||||||
&.loaded {
|
|
||||||
background-color: #eeeeee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
color: var(--tutu-color-on-surface);
|
|
||||||
|
|
||||||
>h1 {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1 {
|
|
||||||
color: var(--tutu-color-on-surface);
|
|
||||||
max-height: calc(4 * var(--title-line-height) * var(--title-size));
|
|
||||||
}
|
|
||||||
|
|
||||||
>p {
|
|
||||||
max-height: calc(8 * var(--body-line-height) * var(--body-size));
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1,
|
|
||||||
>p {
|
|
||||||
margin-left: 16px;
|
|
||||||
margin-right: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.compact {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(10%, 30%) 1fr;
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
padding-top: 8px;
|
|
||||||
|
|
||||||
>img:first-child {
|
|
||||||
grid-row: 1 / 3;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
>h1,
|
|
||||||
>p {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
import Color from "colorjs.io";
|
|
||||||
import type { mastodon } from "masto";
|
|
||||||
import { createEffect, createMemo, Show } from "solid-js";
|
|
||||||
import { Title, Body1 } from "~material/typography";
|
|
||||||
import { averageColorHex } from "~platform/blurhash";
|
|
||||||
import "./PreviewCard.css";
|
|
||||||
|
|
||||||
function onResetImg(event: Event & { currentTarget: HTMLImageElement }) {
|
|
||||||
event.currentTarget.classList.remove("loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
function onImgLoaded(event: Event & { currentTarget: HTMLImageElement }) {
|
|
||||||
event.currentTarget.classList.add("loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PreviewCard(props: {
|
|
||||||
src: mastodon.v1.PreviewCard;
|
|
||||||
alwaysCompact?: boolean;
|
|
||||||
}) {
|
|
||||||
let root: HTMLAnchorElement;
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.alwaysCompact) {
|
|
||||||
root.classList.add("compact");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!props.src.width) return;
|
|
||||||
const width = root.getBoundingClientRect().width;
|
|
||||||
if (width > props.src.width) {
|
|
||||||
root.classList.add("compact");
|
|
||||||
} else {
|
|
||||||
root.classList.remove("compact");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const imgAverageColor = createMemo(() => {
|
|
||||||
if (!props.src.image) return;
|
|
||||||
return new Color(averageColorHex(props.src.blurhash));
|
|
||||||
});
|
|
||||||
|
|
||||||
const prefersWhiteText = createMemo(() => {
|
|
||||||
const oc = imgAverageColor();
|
|
||||||
if (!oc) return;
|
|
||||||
const colorWhite = new Color("white");
|
|
||||||
|
|
||||||
return colorWhite.luminance / oc.luminance > 3.5;
|
|
||||||
});
|
|
||||||
|
|
||||||
const focusSurfaceColor = createMemo(() => {
|
|
||||||
const oc = imgAverageColor();
|
|
||||||
if (!oc) return;
|
|
||||||
if (prefersWhiteText()) {
|
|
||||||
return new Color(oc).darken(0.2);
|
|
||||||
} else {
|
|
||||||
return new Color(oc).lighten(0.2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const textColorName = createMemo(() => {
|
|
||||||
const useWhiteText = prefersWhiteText();
|
|
||||||
if (typeof useWhiteText === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return useWhiteText ? "white" : "black";
|
|
||||||
});
|
|
||||||
|
|
||||||
const secondaryTextColor = createMemo(() => {
|
|
||||||
const tcn = textColorName();
|
|
||||||
if (!tcn) return;
|
|
||||||
const tc = new Color(tcn);
|
|
||||||
tc.alpha = 0.75;
|
|
||||||
return tc;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
ref={root!}
|
|
||||||
class={"PreviewCard"}
|
|
||||||
href={props.src.url}
|
|
||||||
target="_blank"
|
|
||||||
referrerPolicy="unsafe-url"
|
|
||||||
style={{
|
|
||||||
"--tutu-color-surface": imgAverageColor()?.toString(),
|
|
||||||
"--tutu-color-surface-d": focusSurfaceColor()?.toString(),
|
|
||||||
"--tutu-color-on-surface": textColorName(),
|
|
||||||
"--tutu-color-secondary-text-on-surface":
|
|
||||||
secondaryTextColor()?.toString(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.src.image}>
|
|
||||||
<img
|
|
||||||
onLoadStart={onResetImg}
|
|
||||||
onLoad={onImgLoaded}
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
src={props.src.image!}
|
|
||||||
width={props.src.width || undefined}
|
|
||||||
height={props.src.height || undefined}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Title component="h1">{props.src.title}</Title>
|
|
||||||
<Body1 component="p">{props.src.description}</Body1>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PreviewCard;
|
|
|
@ -1,36 +0,0 @@
|
||||||
.TootContent {
|
|
||||||
margin-left: var(--card-pad, 0);
|
|
||||||
margin-right: var(--card-pad, 0);
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
> .content {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
& * {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
& a {
|
|
||||||
color: var(--tutu-color-primary-d);
|
|
||||||
}
|
|
||||||
|
|
||||||
& a[target="_blank"] {
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
>.invisible {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
>.ellipsis {
|
|
||||||
&::after {
|
|
||||||
display: inline;
|
|
||||||
content: "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(.thread-top, .thread-mid) > .TootContent {
|
|
||||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
import type { mastodon } from "masto";
|
|
||||||
import {
|
|
||||||
splitProps,
|
|
||||||
type Component,
|
|
||||||
type JSX,
|
|
||||||
createRenderEffect,
|
|
||||||
createMemo,
|
|
||||||
Show,
|
|
||||||
} from "solid-js";
|
|
||||||
import { resolveCustomEmoji } from "../../masto/toot.js";
|
|
||||||
import { makeAcctText, useDefaultSession } from "../../masto/clients.js";
|
|
||||||
import "./TootContent.css";
|
|
||||||
import { Button } from "@suid/material";
|
|
||||||
import { createTranslator } from "~platform/i18n.js";
|
|
||||||
|
|
||||||
function preventDefault(event: Event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TootContentProps = JSX.HTMLAttributes<HTMLDivElement> & {
|
|
||||||
source?: string;
|
|
||||||
emojis?: mastodon.v1.CustomEmoji[];
|
|
||||||
mentions: mastodon.v1.StatusMention[];
|
|
||||||
sensitive?: boolean;
|
|
||||||
spoilerText?: string;
|
|
||||||
reveal?: boolean;
|
|
||||||
onToggleReveal?: JSX.EventHandlerUnion<HTMLElement, Event>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TootContent: Component<TootContentProps> = (oprops) => {
|
|
||||||
const [t] = createTranslator(
|
|
||||||
(code) =>
|
|
||||||
import(`./i18n/${code}.json`) as Promise<{
|
|
||||||
default: {
|
|
||||||
cw: string;
|
|
||||||
};
|
|
||||||
}>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const session = useDefaultSession();
|
|
||||||
const [props, rest] = splitProps(oprops, [
|
|
||||||
"source",
|
|
||||||
"emojis",
|
|
||||||
"mentions",
|
|
||||||
"class",
|
|
||||||
"sensitive",
|
|
||||||
"spoilerText",
|
|
||||||
"reveal",
|
|
||||||
"onToggleReveal",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const clientFinder = createMemo(() =>
|
|
||||||
session() ? makeAcctText(session()!) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldRevealContent = () => {
|
|
||||||
return !props.sensitive || (props.sensitive && props.reveal);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(ref) => {
|
|
||||||
createRenderEffect(() => {
|
|
||||||
const finder = clientFinder();
|
|
||||||
for (const mention of props.mentions) {
|
|
||||||
const elements = ref.querySelectorAll<HTMLAnchorElement>(
|
|
||||||
`a[href='${mention.url}']`,
|
|
||||||
);
|
|
||||||
for (const e of elements) {
|
|
||||||
e.onclick = preventDefault;
|
|
||||||
e.dataset.action = "acct";
|
|
||||||
e.dataset.client = finder;
|
|
||||||
e.dataset.acctId = mention.id.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
class={`TootContent ${props.class || ""}`}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<Show when={props.sensitive}>
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
ref={(ref) => {
|
|
||||||
createRenderEffect(() => {
|
|
||||||
ref.innerHTML = props.spoilerText
|
|
||||||
? props.emojis
|
|
||||||
? resolveCustomEmoji(props.spoilerText, props.emojis)
|
|
||||||
: props.spoilerText
|
|
||||||
: "";
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
></span>
|
|
||||||
<Button onClick={props.onToggleReveal}>{t("cw")}</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={shouldRevealContent()}>
|
|
||||||
<div
|
|
||||||
class="content"
|
|
||||||
ref={(ref) =>
|
|
||||||
createRenderEffect(() => {
|
|
||||||
ref.innerHTML = props.source
|
|
||||||
? props.emojis
|
|
||||||
? resolveCustomEmoji(props.source, props.emojis)
|
|
||||||
: props.source
|
|
||||||
: "";
|
|
||||||
})
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TootContent;
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"cw": "\"Content Warning\""
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"cw": "“内容警告”"
|
|
||||||
}
|
|
|
@ -1,23 +1,18 @@
|
||||||
import {
|
import { createRenderEffect, createSignal, onCleanup } from "solid-js";
|
||||||
createRenderEffect,
|
|
||||||
onCleanup,
|
|
||||||
type Accessor,
|
|
||||||
} from "solid-js";
|
|
||||||
|
|
||||||
export function useDocumentTitle(newTitle?: string | Accessor<string>) {
|
export function useDocumentTitle(newTitle?: string) {
|
||||||
const capturedTitle = document.title;
|
const capturedTitle = document.title;
|
||||||
|
const [title, setTitle] = createSignal(newTitle ?? capturedTitle);
|
||||||
|
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (newTitle)
|
document.title = title();
|
||||||
document.title = typeof newTitle === "string" ? newTitle : newTitle();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.title = capturedTitle;
|
document.title = capturedTitle;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (x: ((x: string) => string) | string) =>
|
return setTitle;
|
||||||
(document.title = typeof x === "string" ? x : x(document.title));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeClass(c1: string | undefined, c2: string | undefined) {
|
export function mergeClass(c1: string | undefined, c2: string | undefined) {
|
||||||
|
|
100
tools/certs/localhost.direct.crt
Normal file
100
tools/certs/localhost.direct.crt
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIGaDCCBVCgAwIBAgIMcgWlFk4ihQWQO96fMA0GCSqGSIb3DQEBCwUAMFUxCzAJ
|
||||||
|
BgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSswKQYDVQQDEyJH
|
||||||
|
bG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAyMDIzMB4XDTI0MDQxNzE5MTkz
|
||||||
|
OVoXDTI1MDUxOTE5MTkzOFowHTEbMBkGA1UEAwwSKi5sb2NhbGhvc3QuZGlyZWN0
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl7j/nKHNPbO+9oCQyKOV
|
||||||
|
sbSe3lJLSUup2Tr/nCBgPUdBJE4ZrrhgLYz49qU9d/tXQG2thywa3bcVMq6Vv7Wl
|
||||||
|
pzPEJzsGNgAp1e8Z++aN8VoUb46BlsvAOAUEOYcfk3SVfM85orEhBVYswUfunptM
|
||||||
|
LW75zAO+kLgbgzpAVk6vtgWWEXNMVVdA6hOitNWKbR6s5Qh8wGJ+YmhYMfn+lcxX
|
||||||
|
e9e8gmPFZ6EegGSu1ZFP9KlSq8X6udSYSOZPccjdLcjbznx4opbRfgfT09O5IZw2
|
||||||
|
SSoHvRotDxY/BiPaubmQnhz/xrMoXyJm6TDibYnfPvVQD5946+euP3gS4IfA6C5O
|
||||||
|
VwIDAQABo4IDbjCCA2owDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwgZkG
|
||||||
|
CCsGAQUFBwEBBIGMMIGJMEkGCCsGAQUFBzAChj1odHRwOi8vc2VjdXJlLmdsb2Jh
|
||||||
|
bHNpZ24uY29tL2NhY2VydC9nc2djY3I2YWxwaGFzc2xjYTIwMjMuY3J0MDwGCCsG
|
||||||
|
AQUFBzABhjBodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNvbS9nc2djY3I2YWxwaGFz
|
||||||
|
c2xjYTIwMjMwVwYDVR0gBFAwTjAIBgZngQwBAgEwQgYKKwYBBAGgMgoBAzA0MDIG
|
||||||
|
CCsGAQUFBwIBFiZodHRwczovL3d3dy5nbG9iYWxzaWduLmNvbS9yZXBvc2l0b3J5
|
||||||
|
LzBEBgNVHR8EPTA7MDmgN6A1hjNodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL2dz
|
||||||
|
Z2NjcjZhbHBoYXNzbGNhMjAyMy5jcmwwLwYDVR0RBCgwJoISKi5sb2NhbGhvc3Qu
|
||||||
|
ZGlyZWN0ghBsb2NhbGhvc3QuZGlyZWN0MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
|
||||||
|
BgEFBQcDAjAfBgNVHSMEGDAWgBS9BbfzipM8c8t5+g+FEqF3lhiRdDAdBgNVHQ4E
|
||||||
|
FgQUKI953RG67hpYzTKIgOFfjOhCFHUwggF9BgorBgEEAdZ5AgQCBIIBbQSCAWkB
|
||||||
|
ZwB2AKLjCuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABju2AJRgAAAQD
|
||||||
|
AEcwRQIhAM6LOxsJZpalFLVL5gxcPVg/esjcs77aMy55RbEsugIcAiA+eXsLDS0l
|
||||||
|
L2LAVio6ccRujBXv4AVX8+UMEjxTCJOK8gB1ABNK3xq1mEIJeAxv70x6kaQWtyNJ
|
||||||
|
zlhXat+u2qfCq+AiAAABju2AJUoAAAQDAEYwRAIgafNBrzSDplrC/23Al8N62TGN
|
||||||
|
df6/I3sFbRdK1WjBrCsCIEXSOPPQrhEEoMZN8ZGRzkY7znL0zWuJsDA2IDj7+mUQ
|
||||||
|
AHYATnWjJ1yaEMM4W2zU3z9S6x3w4I4bjWnAsfpksWKaOd8AAAGO7YAmKAAABAMA
|
||||||
|
RzBFAiEA72BBCCM0QbJ1iN6jr9xBf51RNDjI6vV3me/v2m0CjvACICazjNaoB080
|
||||||
|
cqeQVF9ROzyHkaYUkb7vpeDd+EZeMhhWMA0GCSqGSIb3DQEBCwUAA4IBAQBBImJh
|
||||||
|
WM2CJEEALTrfPO4qiTig0jr9GoIhW0Vy31qiIfOchv8yNBTCc01Zd4LKqnpNId7K
|
||||||
|
a3TmMEyt/kf5PUSVkoVhBlk2wOdbtNvzxmc1VgUteBcng99GQNs4TJ6kOTuz9T0P
|
||||||
|
ycvgB48A7cjLtQ/bQSYWvJkn46VgYAIofBUrX7Bc4gLCs/XobADO5iLm9vvmvhlM
|
||||||
|
TigYA6vG4jgSOHnNOyAgus3FVupFA7Xsyo3lxo8BKD2/DkeJykc505i+s3xF6Tn0
|
||||||
|
sv7t7GQAukAu/AUiPIvRYYXzFBebx14/nuCjwRvhYt5O/At2dzt+ctNmyfpD/NAa
|
||||||
|
1cuNyikOi8Y/8hUQ
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFjDCCA3SgAwIBAgIQfx8skC6D0OO2+zvuR4tegDANBgkqhkiG9w0BAQsFADBM
|
||||||
|
MSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xv
|
||||||
|
YmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0yMzA3MTkwMzQzMjVaFw0y
|
||||||
|
NjA3MTkwMDAwMDBaMFUxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWdu
|
||||||
|
IG52LXNhMSswKQYDVQQDEyJHbG9iYWxTaWduIEdDQyBSNiBBbHBoYVNTTCBDQSAy
|
||||||
|
MDIzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA00Jvk5ADppO0rgDn
|
||||||
|
j1M14XIb032Aas409JJFAb8cUjipFOth7ySLdaWLe3s63oSs5x3eWwzTpX4BFkzZ
|
||||||
|
bxT1eoJSHfT2M0wZ5QOPcCIjsr+YB8TAvV2yJSyq+emRrN/FtgCSTaWXSJ5jipW8
|
||||||
|
SJ/VAuXPMzuAP2yYpuPcjjQ5GyrssDXgu+FhtYxqyFP7BSvx9jQhh5QV5zhLycua
|
||||||
|
n8n+J0Uw09WRQK6JGQ5HzDZQinkNel+fZZNRG1gE9Qeh+tHBplrkalB1g85qJkPO
|
||||||
|
J7SoEvKsmDkajggk/sSq7NPyzFaa/VBGZiRRG+FkxCBniGD5618PQ4trcwHyMojS
|
||||||
|
FObOHQIDAQABo4IBXzCCAVswDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG
|
||||||
|
AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS9
|
||||||
|
BbfzipM8c8t5+g+FEqF3lhiRdDAfBgNVHSMEGDAWgBSubAWjkxPioufi1xzWx/B/
|
||||||
|
yGdToDB7BggrBgEFBQcBAQRvMG0wLgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwMi5n
|
||||||
|
bG9iYWxzaWduLmNvbS9yb290cjYwOwYIKwYBBQUHMAKGL2h0dHA6Ly9zZWN1cmUu
|
||||||
|
Z2xvYmFsc2lnbi5jb20vY2FjZXJ0L3Jvb3QtcjYuY3J0MDYGA1UdHwQvMC0wK6Ap
|
||||||
|
oCeGJWh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20vcm9vdC1yNi5jcmwwIQYDVR0g
|
||||||
|
BBowGDAIBgZngQwBAgEwDAYKKwYBBAGgMgoBAzANBgkqhkiG9w0BAQsFAAOCAgEA
|
||||||
|
fMkkMo5g4mn1ft4d4xR2kHzYpDukhC1XYPwfSZN3A9nEBadjdKZMH7iuS1vF8uSc
|
||||||
|
g26/30DRPen2fFRsr662ECyUCR4OfeiiGNdoQvcesM9Xpew3HLQP4qHg+s774hNL
|
||||||
|
vGRD4aKSKwFqLMrcqCw6tEAfX99tFWsD4jzbC6k8tjSLzEl0fTUlfkJaWpvLVkpg
|
||||||
|
9et8tD8d51bymCg5J6J6wcXpmsSGnksBobac1+nXmgB7jQC9edU8Z41FFo87BV3k
|
||||||
|
CtrWWsdkQavObMsXUPl/AO8y/jOuAWz0wyvPnKom+o6W4vKDY6/6XPypNdebOJ6m
|
||||||
|
jyaILp0quoQvhjx87BzENh5s57AIOyIGpS0sDEChVDPzLEfRsH2FJ8/W5woF0nvs
|
||||||
|
BTqfYSCqblQbHeDDtCj7Mlf8JfqaMuqcbE4rMSyfeHyCdZQwnc/r9ujnth691AJh
|
||||||
|
xyYeCM04metJIe7cB6d4dFm+Pd5ervY4x32r0uQ1Q0spy1VjNqUJjussYuXNyMmF
|
||||||
|
HSuLQQ6PrePmH5lcSMQpYKzPoD/RiNVD/PK0O3vuO5vh3o7oKb1FfzoanDsFFTrw
|
||||||
|
0aLOdRW/tmLPWVNVlAb8ad+B80YJsL4HXYnQG8wYAFb8LhwSDyT9v+C1C1lcIHE7
|
||||||
|
nE0AAp9JSHxDYsma9pi4g0Phg3BgOm2euTRzw7R0SzU=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFUTCCBDmgAwIBAgIQdR4/VknnTLv2nQAmtnyqjDANBgkqhkiG9w0BAQwFADBX
|
||||||
|
MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE
|
||||||
|
CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTE5MDYx
|
||||||
|
OTAwMDAwMFoXDTI4MDEyODEyMDAwMFowTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBS
|
||||||
|
b290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh
|
||||||
|
bFNpZ24wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCVB+hzymb57BTK
|
||||||
|
ezz3DQjxtEULLIK0SMbrWzyug7hBkjMUpG9/6SrMxrCIa8W2idHGsv8UzlEUIexK
|
||||||
|
3RtaxtaH7k06FQbtZGYLkoDKRN5zlE7zp4l/T3hjCMgSUG1CZi9NuXkoTVIaihqA
|
||||||
|
txmBDn7EirxkTCEcQ2jXPTyKxbJm1ZCatzEGxb7ibTIGph75ueuqo7i/voJjUNDw
|
||||||
|
GInf5A959eqiHyrScC5757yTu21T4kh8jBAHOP9msndhfuDqjDyqtKT285VKEgdt
|
||||||
|
/Yyyic/QoGF3yFh0sNQjOvddOsqi250J3l1ELZDxgc1Xkvp+vFAEYzTfa5MYvms2
|
||||||
|
sjnkrCQ2t/DvthwTV5O23rL44oW3c6K4NapF8uCdNqFvVIrxclZuLojFUUJEFZTu
|
||||||
|
o8U4lptOTloLR/MGNkl3MLxxN+Wm7CEIdfzmYRY/d9XZkZeECmzUAk10wBTt/Tn7
|
||||||
|
g/JeFKEEsAvp/u6P4W4LsgizYWYJarEGOmWWWcDwNf3J2iiNGhGHcIEKqJp1HZ46
|
||||||
|
hgUAntuA1iX53AWeJ1lMdjlb6vmlodiDD9H/3zAR+YXPM0j1ym1kFCx6WE/TSwhJ
|
||||||
|
xZVkGmMOeT31s4zKWK2cQkV5bg6HGVxUsWW2v4yb3BPpDW+4LtxnbsmLEbWEFIoA
|
||||||
|
GXCDeZGXkdQaJ783HjIH2BRjPChMrwIDAQABo4IBIjCCAR4wDgYDVR0PAQH/BAQD
|
||||||
|
AgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFK5sBaOTE+Ki5+LXHNbH8H/I
|
||||||
|
Z1OgMB8GA1UdIwQYMBaAFGB7ZhpFDZfKiVAvfQTNNKj//P1LMD0GCCsGAQUFBwEB
|
||||||
|
BDEwLzAtBggrBgEFBQcwAYYhaHR0cDovL29jc3AuZ2xvYmFsc2lnbi5jb20vcm9v
|
||||||
|
dHIxMDMGA1UdHwQsMCowKKAmoCSGImh0dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20v
|
||||||
|
cm9vdC5jcmwwRwYDVR0gBEAwPjA8BgRVHSAAMDQwMgYIKwYBBQUHAgEWJmh0dHBz
|
||||||
|
Oi8vd3d3Lmdsb2JhbHNpZ24uY29tL3JlcG9zaXRvcnkvMA0GCSqGSIb3DQEBDAUA
|
||||||
|
A4IBAQDHrE3fEsZgYRw59I03e5wt03B45il4hAHmquLc33pbkGZn6r3GgoKVzvwC
|
||||||
|
aBgtl6Jp93gZD8G5UjAFLj840jWDhOP7KSX6Q7rGjOsWNFFDJJLDUKQeJpB1PTRu
|
||||||
|
HqVI15zxiCl/VCP7mbTW7X/pILaFOPO+T0Qj+TUOU37WOjk6wdeyyOFiDhKSwH2Y
|
||||||
|
VE4YlAo0R10Jo3uNnSCFBgPw7gy1xt1+ajCbnzZYpQNXFy/0Lp9h3JOClE7TGvli
|
||||||
|
FUazCjxvhHm5YWqulA51wFT2K9LRiiEWw3UJAgTTmxASitVHHLb3erkETk6SCwGv
|
||||||
|
OG1eD0qLwuSeARZmhw3xFOCvMHeQ
|
||||||
|
-----END CERTIFICATE-----
|
28
tools/certs/localhost.direct.key
Normal file
28
tools/certs/localhost.direct.key
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCXuP+coc09s772
|
||||||
|
gJDIo5WxtJ7eUktJS6nZOv+cIGA9R0EkThmuuGAtjPj2pT13+1dAba2HLBrdtxUy
|
||||||
|
rpW/taWnM8QnOwY2ACnV7xn75o3xWhRvjoGWy8A4BQQ5hx+TdJV8zzmisSEFVizB
|
||||||
|
R+6em0wtbvnMA76QuBuDOkBWTq+2BZYRc0xVV0DqE6K01YptHqzlCHzAYn5iaFgx
|
||||||
|
+f6VzFd717yCY8VnoR6AZK7VkU/0qVKrxfq51JhI5k9xyN0tyNvOfHiiltF+B9PT
|
||||||
|
07khnDZJKge9Gi0PFj8GI9q5uZCeHP/GsyhfImbpMOJtid8+9VAPn3jr564/eBLg
|
||||||
|
h8DoLk5XAgMBAAECggEAEFqIcsGd9cCiHL/O21GGmRj25s/H/aaCMEADvThgJzq+
|
||||||
|
8sLYUdTdyQsg6rT04zHcPb2UrrU6UBuj1UqsKXXS3SrfQbtC+B8cY0raaiR3uEQV
|
||||||
|
X9DkdvSPS3p+8hR2etZeJo3PkJG3FXQsbsjqF351v4/urObaa47sqEBnHuZsWhol
|
||||||
|
jMEfztg5NWHz1r02UuUCPGy/uIn5MBQVcawn1LZuEepUc+El+fdwlFIC/TQj2Zuh
|
||||||
|
EZUjMTFzaPDKhoXUvNlIi9YqchRsyB72IDJf/XxAKIp1QXqMcIH4c4sgwB7rbLiD
|
||||||
|
G9xu7B68OTAhUdF0SJnuz3MhLlc0fqD0xRJoC8M5gQKBgQDBtHrPfqw61dDf3ghr
|
||||||
|
EaCLDknPtmgtcH1tED3e6YfxEaQOnP/O03eigpakMBGbxpcGsSnkAD8KkBbWE9Wp
|
||||||
|
FXo4xI4D4e4XZ0r7UciChXamxOWwwv5yUbkB+hie1WZSl5pVqc4ZS7p/1D4mDD8Y
|
||||||
|
fnbXInF+mOtSSNZJ9RL3hTKLuQKBgQDIhChy+3yibuQz4hHwrSEr7yKMYFAR9TSl
|
||||||
|
7AqjQ/Vw1uhb6dOD8nrcG9zaQhZsprKGpNuyw0Xy0U0skBfnv6nEMbKBNdP3YyQe
|
||||||
|
/T3xUu7Z2kdRnaF1Vn/+g6bECXJs3FRIioBbA9XFKfRibxuWjPPpXZk5w3YF73nx
|
||||||
|
SkpF1DlSjwKBgQCB0MNxZbJlJ8B5F6NKpiCSsLu00ckVksrsGbNtPdLWM31gMcWa
|
||||||
|
Rcxqg9wTIwfZ/whd+sNZQvT8zj4PsHFDhNpJSyjl3zciRh5ROakIGAvBjjlk8fl2
|
||||||
|
geBcO9DeOaP+fA15lXhDKaZOXt5bv19VugNJAJNRRYiHt7qtC+pvKbwLOQKBgCP+
|
||||||
|
NRSOuAygQy5dAkNlkHLGdjkkgLr4fP7bo/0ykbgzm3oEOweQWyVvivFSs5vFQH6S
|
||||||
|
0S0BiGjR0TySkPf0m5CwKw6ujuH1VeKKKrhK3r0URYEM/pKFeGxDTYga+gM4eZib
|
||||||
|
4/Zydcjygv+4WgdoPdBCEOMhhuoB1q3NXA+0zKVZAoGBAIHR7Udk8rzwuno5IGc8
|
||||||
|
AgdMhkDojqQaDJdJlAAmmX1IoqJOpyGdws+uUtQ/YnnNqQn2eTTlPxnl5ldtBYmR
|
||||||
|
iT8XWLi4jIsY0jh6fLCaZwcu4RB0Rrw1N6nsZQFr/PG61ZPa8RJBfAGRE3QWc3WN
|
||||||
|
L/Q1JdU0auJcvQn78yA/gLVp
|
||||||
|
-----END PRIVATE KEY-----
|
|
@ -12,9 +12,5 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"paths": {
|
|
||||||
"~platform/*": ["./src/platform/*"],
|
|
||||||
"~material/*": ["./src/material/*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
185
vite.config.ts
185
vite.config.ts
|
@ -1,159 +1,44 @@
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import solid from "vite-plugin-solid";
|
import solid from "vite-plugin-solid";
|
||||||
import solidStyled from "vite-plugin-solid-styled";
|
import solidStyled from "vite-plugin-solid-styled";
|
||||||
import suid from "@suid/vite-plugin";
|
import suid from "@suid/vite-plugin";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import version from "vite-plugin-package-version";
|
import version from "vite-plugin-package-version";
|
||||||
import manifest from "./manifest.config";
|
|
||||||
import { GetManualChunk } from "rollup";
|
|
||||||
import devtools from "solid-devtools/vite";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
/**
|
export default defineConfig(({ mode }) => ({
|
||||||
* Put all strings (/i18n/{key}.<json|js|ts>) into separated chunks based on the key.
|
plugins: [
|
||||||
*/
|
suid(),
|
||||||
const chunkStrs: GetManualChunk = (id, { getModuleInfo }) => {
|
solid(),
|
||||||
const match = /.*\/i18n\/(.*)\.[jt]s.*$/.exec(id);
|
solidStyled({
|
||||||
if (match) {
|
filter: {
|
||||||
const key = match[1];
|
include: "src/**/*.{tsx,jsx}",
|
||||||
|
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
|
||||||
const dependentEntryPoints = [];
|
|
||||||
|
|
||||||
// we use a Set here so we handle each module at most once. This
|
|
||||||
// prevents infinite loops in case of circular dependencies
|
|
||||||
const idsToHandle = new Set(getModuleInfo(id)!.dynamicImporters);
|
|
||||||
|
|
||||||
for (const moduleId of idsToHandle) {
|
|
||||||
const { isEntry, dynamicImporters, importers } = getModuleInfo(moduleId)!;
|
|
||||||
if (isEntry || dynamicImporters.length > 0)
|
|
||||||
dependentEntryPoints.push(moduleId);
|
|
||||||
|
|
||||||
// The Set iterator is intelligent enough to iterate over
|
|
||||||
// elements that are added during iteration
|
|
||||||
for (const importerId of importers) idsToHandle.add(importerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there is a unique entry, we put it into a chunk based on the
|
|
||||||
// entry name
|
|
||||||
if (dependentEntryPoints.length === 1) {
|
|
||||||
return `${key}.${
|
|
||||||
dependentEntryPoints[0].split("/").slice(-1)[0].split(".")[0]
|
|
||||||
}.strings`;
|
|
||||||
}
|
|
||||||
// For multiple entries, we put it into a "shared" chunk
|
|
||||||
if (dependentEntryPoints.length > 1) {
|
|
||||||
return `${key}.shared.strings`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const manualChunks: GetManualChunk = (id, meta) => {
|
|
||||||
return chunkStrs(id, meta);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const devConf = loadEnv(mode, import.meta.dirname, "DEV");
|
|
||||||
|
|
||||||
const serverHttpCertBase = devConf["DEV_SERVER_HTTP_CERT_BASE"];
|
|
||||||
const serverHttpCertPassword = devConf["DEV_SERVER_HTTP_CERT_PASS"];
|
|
||||||
|
|
||||||
const serverHttpCertKey = serverHttpCertBase
|
|
||||||
? `${serverHttpCertBase}.key`
|
|
||||||
: undefined;
|
|
||||||
const serverHttpCertCrt = serverHttpCertBase
|
|
||||||
? `${serverHttpCertBase}.crt`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const isTestBuild = ["development", "staging"].includes(mode);
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
devtools({
|
|
||||||
autoname: true,
|
|
||||||
locator: {
|
|
||||||
targetIDE:
|
|
||||||
(devConf["DEV_LOCATOR_EDITOR"] as
|
|
||||||
| "vscode"
|
|
||||||
| "atom"
|
|
||||||
| "webstorm"
|
|
||||||
| "vscode-insiders"
|
|
||||||
| "") || undefined,
|
|
||||||
componentLocation: true,
|
|
||||||
jsxLocation: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
suid(),
|
|
||||||
solid(),
|
|
||||||
solidStyled({
|
|
||||||
filter: {
|
|
||||||
include: "src/**/*.{tsx,jsx}",
|
|
||||||
exclude: "node_modules/**/*.{ts,js,tsx,jsx}",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
VitePWA({
|
|
||||||
strategies: "injectManifest",
|
|
||||||
registerType: "autoUpdate",
|
|
||||||
devOptions: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
srcDir: "src/serviceworker",
|
|
||||||
filename: "main.ts",
|
|
||||||
manifest: manifest,
|
|
||||||
pwaAssets: {
|
|
||||||
config: true,
|
|
||||||
},
|
|
||||||
injectManifest: {
|
|
||||||
globPatterns: ["**/*.{js,wasm,css,html,svg,png,ico}"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
version(),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
/* We don't allow directly acessing the source root,
|
|
||||||
because this encourage cross referencing between different
|
|
||||||
module and loose the isolation. (Cross referencing is still
|
|
||||||
possible, we don't stop it in any technical way.)
|
|
||||||
|
|
||||||
If the module is so important and is being referencing
|
|
||||||
everywhere in the app. Consider promoting it to the top
|
|
||||||
dir.
|
|
||||||
|
|
||||||
see docs/devnotes.md#module-isolation for details.
|
|
||||||
*/
|
|
||||||
"~platform": resolve(__dirname, "src/platform"),
|
|
||||||
"~material": resolve(__dirname, "src/material"),
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
server: {
|
VitePWA({
|
||||||
https: serverHttpCertBase
|
registerType: "autoUpdate",
|
||||||
? {
|
devOptions: {
|
||||||
// This config controls https for the *dev server*.
|
enabled: mode === "staging",
|
||||||
// See docs/dev-https.md for setting up https
|
|
||||||
key: serverHttpCertKey,
|
|
||||||
cert: serverHttpCertCrt,
|
|
||||||
passphrase: serverHttpCertPassword,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
esbuild: {
|
|
||||||
pure: isTestBuild ? undefined : ["console.debug", "console.trace"],
|
|
||||||
drop: isTestBuild ? undefined : ["debugger"],
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
|
|
||||||
},
|
|
||||||
css: {
|
|
||||||
devSourcemap: true,
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
|
|
||||||
sourcemap: true,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
version(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
https: {
|
||||||
|
// localhost.direct: https://github.com/Upinel/localhost.direct
|
||||||
|
key: "tools/certs/localhost.direct.key",
|
||||||
|
cert: "tools/certs/localhost.direct.crt",
|
||||||
|
passphrase: "localhost",
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
});
|
define: {
|
||||||
|
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
devSourcemap: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: ["firefox98", "safari15.4", "ios15.4", "chrome84", "edge87"],
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
Loading…
Reference in a new issue