diff --git a/.browserlist b/.browserlist
deleted file mode 100644
index b547231..0000000
--- a/.browserlist
+++ /dev/null
@@ -1 +0,0 @@
->0.3% and not dead, firefox>=98, safari>=15.4, chrome>=84
\ No newline at end of file
diff --git a/README.md b/README.md
index 0bb7288..a4c432a 100644
--- a/README.md
+++ b/README.md
@@ -8,17 +8,17 @@ Tutu is a comfortable experience for tooting. Designed to work on any device - d
The code is built against those targets and Tutu must run on those platforms:
-| Firefox | Safari | iOS | Chrome | Edge |
-| ------- | ------ | ----- | ------ | ---- |
-| 98 | 15.4 | 15.4 | 84 | 87 |
+| Firefox | Safari | iOS | Chrome & Edge |
+| ------- | ------ | ----- | ------------- |
+| 115 | 15.6 | 15.6 | 108 |
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 "Nightly" Branch
-The "next" branch of the app is built on every commit pushed into "master". You can tatse latest change but risks your data.
+Tutu built on the latest code, called the nightly version. You can tatse latest change but risks your data.
-[Launch Tutu (Next)](https://master.tututheapp.pages.dev)
+[Launch Tutu (Nightly)](https://master.tututheapp.pages.dev)
## Build & Depoly
diff --git a/bun.lockb b/bun.lockb
index 0645c92..cd4221a 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/docs/devnotes.md b/docs/devnotes.md
index 577c968..6e10652 100644
--- a/docs/devnotes.md
+++ b/docs/devnotes.md
@@ -84,3 +84,64 @@ But, sometimes you need a redesigned (sometimes better) tool for the generic usa
- *What* this new tool does?
- *How* this tool works?
- Clean up code regularly. Don't keep the unused code forever.
+
+## Managing CSS
+
+Two techniques are still:
+
+- Styled compoenent (solid-styled)
+- Native CSS with CSS layering
+
+The second is recommended for massive use. A stylesheet for a component can be placed alongside
+the component's file. The stylesheet must use the same name as the component's file name, but replace the extension with
+`.css`. Say there is a component file "PreviewCard.tsx", the corresponding stylesheet is "PreviewCard.css". They are imported
+by the component file, so the side effect will be applied by the bundler.
+
+The speicifc component uses a root class to scope the rulesets' scope. This convention allows the component's style can be influenced
+by the other stylesheets. It works because Tutu is an end-user application, we gain the control of all stylesheets in the app (kind of).
+Keep in mind that the native stylesheets will be applied globally at any time, you must carefully craft the stylesheet to avoid leaking
+of style.
+
+Three additional CSS layers are declared as:
+
+- compat: Compatibility rules, like normalize.css
+- theme: The theme rules
+- material: The internal material styles
+
+When working on the material package, if the style is intended to work with the user styles,
+it must be declared under the material layer. Otherwise the unlayer, which has the
+highest priority in the author's, can be used.
+
+Styled component is still existing. Though styled component, using attributes for scoping,
+may not be as performant as the techniques with CSS class names;
+it's still provided in the code infrastructure for its ease.
+
+The following is an example of the recommended usage of solid-styled:
+
+```tsx
+// An example of using solid-styled
+import { css } from "solid-styled";
+import { createSignal } from "solid-js";
+
+const Component = () => {
+ const [width, setWidth] = createSignal(100);
+
+ css`
+ .root {
+ width: ${width()}%;
+ }
+ `
+ return
+};
+```
+
+When developing new component, you can use styled component at first, and migrate
+to native css slowly.
+
+Before v2.0.0, there are CSS modules in use, but they are removed:
+
+- Duplicated loads
+- Unaware of order (failed composing)
+- Not-ready for hot reload
+
+In short, CSS module does not works well if the stylesheet will be accessed from more than one component.
diff --git a/docs/versioning.md b/docs/versioning.md
new file mode 100644
index 0000000..4d50356
--- /dev/null
+++ b/docs/versioning.md
@@ -0,0 +1,32 @@
+# Versioning & Development Cycle
+
+The versioning policy follows the [Semantic Versioning](https://semver.org/).
+Since Tutu is an app for the end user, we redefine the some words in the policy:
+
+- API changes: the app is no longer available on certain platforms.
+
+## Development Cycle
+
+Dependency Freeze -> Development -> Release
+
+### Dependency Freeze
+
+This step is for:
+
+- Update dependencies
+- Prepare the new version (like, bump the version number).
+
+New dependencies should not be added in this step.
+
+### Development
+
+In this step, dependencies can only be updated if it's required to fix bugs.
+
+New dependencies should be added as their use, in this step.
+
+### Release
+
+The version is released to production in this step.
+
+Before the next development step, new versions can still be released to
+fix bugs.
diff --git a/package.json b/package.json
index 4e04928..69b71d9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "tutu",
- "version": "1.1.0",
+ "version": "2.0.0",
"description": "",
"private": true,
"type": "module",
@@ -10,30 +10,40 @@
"preview": "vite preview",
"dist": "vite build",
"count-source-lines": "exec scripts/src-lc.sh",
- "typecheck": "tsc --noEmit --skipLibCheck"
+ "typecheck": "tsc --noEmit --skipLibCheck",
+ "wdio": "wdio run ./wdio.conf.ts"
},
"keywords": [],
"author": "Rubicon",
"license": "Apache-2.0",
"devDependencies": {
- "@solid-devtools/overlay": "^0.30.1",
+ "@solid-devtools/overlay": "^0.33.0",
"@suid/vite-plugin": "^0.3.1",
+ "@testing-library/webdriverio": "^3.2.1",
"@types/hammerjs": "^2.0.46",
"@types/masonry-layout": "^4.2.8",
"@vite-pwa/assets-generator": "^0.2.6",
+ "@wdio/cli": "^9.5.1",
+ "@wdio/lighthouse-service": "^9.5.1",
+ "@wdio/local-runner": "^9.5.1",
+ "@wdio/mocha-framework": "^9.5.0",
+ "@wdio/spec-reporter": "^9.5.0",
"postcss": "^8.4.49",
- "prettier": "^3.3.3",
- "typescript": "^5.6.3",
- "vite": "^5.4.11",
+ "prettier": "^3.4.2",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7",
"vite-plugin-package-version": "^1.1.0",
- "vite-plugin-pwa": "^0.20.5",
- "vite-plugin-solid": "^2.10.2",
+ "vite-plugin-pwa": "^0.21.1",
+ "vite-plugin-solid": "^2.11.0",
"vite-plugin-solid-styled": "^0.11.1",
+ "wdio-vite-service": "^2.0.0",
+ "wdio-wait-for": "^3.0.11",
"workbox-build": "^7.3.0",
- "wrangler": "^3.86.1"
+ "wrangler": "^3.99.0"
},
"dependencies": {
- "@formatjs/intl-localematcher": "^0.5.7",
+ "@formatjs/intl-localematcher": "^0.5.10",
"@nanostores/persistent": "^0.10.2",
"@nanostores/solid": "^0.5.0",
"@solid-primitives/event-listener": "^2.3.3",
@@ -42,7 +52,8 @@
"@solid-primitives/map": "^0.4.13",
"@solid-primitives/page-visibility": "^2.0.17",
"@solid-primitives/resize-observer": "^2.0.26",
- "@solidjs/router": "^0.15.1",
+ "@solidjs/router": "^0.15.2",
+ "@solid-primitives/rootless": "^1.4.5",
"@suid/icons-material": "^0.8.1",
"@suid/material": "^0.18.0",
"blurhash": "^2.0.5",
@@ -55,9 +66,10 @@
"masto": "^6.10.1",
"nanostores": "^0.11.3",
"normalize.css": "^8.0.1",
- "solid-devtools": "^0.30.1",
+ "solid-devtools": "^0.33.0",
"solid-js": "^1.9.3",
"solid-styled": "^0.11.1",
+ "solid-transition-group": "^0.2.3",
"stacktrace-js": "^2.0.2",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0"
diff --git a/src/App.css b/src/App.css
index 70ee2fb..d2643a9 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,41 +1,46 @@
-@import "normalize.css/normalize.css";
-@import "./material/theme.css";
+@layer compat, theme, material;
-:root {
- --safe-area-inset-top: env(safe-area-inset-top);
- --safe-area-inset-left: env(safe-area-inset-left);
- --safe-area-inset-bottom: env(safe-area-inset-bottom);
- --safe-area-inset-right: env(safe-area-inset-right);
- background-color: var(--tutu-color-surface, transparent);
-}
+@import "normalize.css/normalize.css" layer(compat);
+@import "./material/theme.css" layer(theme);
+@import "./material/material.css" layer(material);
-/*
-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;
+@layer compat {
+ :root {
+ --safe-area-inset-top: env(safe-area-inset-top);
+ --safe-area-inset-left: env(safe-area-inset-left);
+ --safe-area-inset-bottom: env(safe-area-inset-bottom);
+ --safe-area-inset-right: env(safe-area-inset-right);
+ background-color: var(--tutu-color-surface, transparent);
}
- #root {
- position: fixed;
- top: 0;
- left: 0;
- height: 100vh;
- width: 100vw;
+ /*
+ Fix the bottom gap on iOS standalone.
+ https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-100-height-gap-on-bottom
+ */
+ @media screen and (display-mode: standalone) {
+ body {
+ width: 100%;
+ height: 100vh;
+ }
+
+ #root {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100vh;
+ width: 100vw;
+ }
+ }
+
+ h1 {
+ margin: 0;
+ }
+
+ * {
+ user-select: none;
}
}
.custom-emoji {
width: 1em;
-}
-
-h1 {
- margin: 0;
-}
-
-* {
- user-select: none;
-}
+}
\ No newline at end of file
diff --git a/src/accounts/MastodonOAuth2Callback.css b/src/accounts/MastodonOAuth2Callback.css
new file mode 100644
index 0000000..c150d03
--- /dev/null
+++ b/src/accounts/MastodonOAuth2Callback.css
@@ -0,0 +1,22 @@
+.MastodonOAuth2Callback {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 448px;
+
+ @media (max-width: 600px) {
+ & {
+ position: static;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ right: 0;
+ transform: none;
+ display: grid;
+ grid-template-rows: 1fr auto;
+ height: 100vh;
+ overflow: auto;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/accounts/MastodonOAuth2Callback.tsx b/src/accounts/MastodonOAuth2Callback.tsx
index 398d79d..d530095 100644
--- a/src/accounts/MastodonOAuth2Callback.tsx
+++ b/src/accounts/MastodonOAuth2Callback.tsx
@@ -8,13 +8,13 @@ import {
} from "solid-js";
import { acceptAccountViaAuthCode } from "./stores";
import { $settings } from "../settings/stores";
-import { useDocumentTitle } from "../utils";
-import cards from "~material/cards.module.css";
+import "~material/cards.css";
import { LinearProgress } from "@suid/material";
import Img from "~material/Img";
import { createRestAPIClient } from "masto";
import { Title } from "~material/typography";
import { useNavigator } from "~platform/StackedRouter";
+import DocumentTitle from "~platform/DocumentTitle";
type OAuth2CallbackParams = {
code?: string;
@@ -27,13 +27,12 @@ const MastodonOAuth2Callback: Component = () => {
const titleId = createUniqueId();
const [params] = useSearchParams();
const { push: navigate } = useNavigator();
- const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
const [siteImg, setSiteImg] = createSignal<{
src: string;
srcset?: string;
blurhash: string;
}>();
- const [siteTitle, setSiteTitle] = createSignal("the Mastodon server");
+ const [siteTitle, setSiteTitle] = createSignal("Mastodon");
onMount(async () => {
const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
@@ -42,7 +41,6 @@ const MastodonOAuth2Callback: Component = () => {
url: onGoingOAuth2Process,
});
const ins = await client.v2.instance.fetch();
- setDocumentTitle(`Back from ${ins.title}...`);
setSiteTitle(ins.title);
const srcset = [];
@@ -93,42 +91,45 @@ const MastodonOAuth2Callback: Component = () => {
});
});
return (
-
-
-
-
- }
- >
-
+ Back from {siteTitle()}
+
+
+
-
+
+ }
+ >
+
+
-
- Contracting {siteTitle}...
-
-
- If this page stays too long, you can close this page and sign in
- again.
-
-
-
+
+ Contracting {siteTitle}...
+
+
+ If this page stays too long, you can close this page and sign in
+ again.
+
+
+
+ >
);
};
diff --git a/src/accounts/SignIn.css b/src/accounts/SignIn.css
new file mode 100644
index 0000000..11f467a
--- /dev/null
+++ b/src/accounts/SignIn.css
@@ -0,0 +1,27 @@
+.SignIn {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 448px;
+
+ @media (max-width: 600px) {
+ & {
+ position: static;
+ height: 100vh;
+ width: 100%;
+ transform: none;
+ overflow: auto;
+ }
+ }
+
+ >.key-content {
+ height: 100%;
+
+ >form {
+ display: flex;
+ flex-flow: column;
+ gap: 16px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/accounts/SignIn.tsx b/src/accounts/SignIn.tsx
index c8699d2..3a6233d 100644
--- a/src/accounts/SignIn.tsx
+++ b/src/accounts/SignIn.tsx
@@ -2,22 +2,21 @@ import {
Component,
Show,
createEffect,
- createSelector,
createSignal,
createUniqueId,
onMount,
} from "solid-js";
-import cards from "~material/cards.module.css";
+import "~material/cards.css";
import TextField from "~material/TextField.js";
import Button from "~material/Button.js";
-import { useDocumentTitle } from "../utils";
import { Title } from "~material/typography";
-import { css } from "solid-styled";
import { LinearProgress } from "@suid/material";
import { createRestAPIClient } from "masto";
import { getOrRegisterApp } from "./stores";
import { useSearchParams } from "@solidjs/router";
import { $settings } from "../settings/stores";
+import "./SignIn.css";
+import DocumentTitle from "~platform/DocumentTitle";
type ErrorParams = {
error: string;
@@ -35,15 +34,6 @@ const SignIn: Component = () => {
const [serverUrlError, setServerUrlError] = createSignal(false);
const [targetSiteTitle, setTargetSiteTitle] = createSignal("");
- useDocumentTitle("Sign In");
- css`
- form {
- display: flex;
- flex-flow: column;
- gap: 16px;
- }
- `;
-
const serverUrl = () => {
const url = rawServerUrl();
if (url.length === 0 || /^%w:/.test(url)) {
@@ -121,55 +111,58 @@ const SignIn: Component = () => {
};
return (
-
-
-
-
Authorization is failed.
-
{params.errorDescription}
-
- Please try again later. If the problem persist, you can seek for
- help from the server administrator.
-
-
-
-
+
+
+
+ >
);
};
diff --git a/src/accounts/stores.ts b/src/accounts/stores.ts
index c44a012..006ea9e 100644
--- a/src/accounts/stores.ts
+++ b/src/accounts/stores.ts
@@ -6,10 +6,19 @@ import {
} from "masto";
import { createMastoClientFor } from "../masto/clients";
-export type Account = {
+export type RemoteServer = {
site: string;
- accessToken: string;
+};
+export type AccountKey = RemoteServer & {
+ accessToken: string;
+};
+
+export function isAccountKey(object: RemoteServer): object is AccountKey {
+ return !!(object as Record
)["accessToken"];
+}
+
+export type Account = AccountKey & {
tokenType: string;
scope: string;
createdAt: number;
@@ -17,6 +26,10 @@ export type Account = {
inf?: mastodon.v1.AccountCredentials;
};
+export function isAccount(object: RemoteServer) {
+ return isAccountKey(object) && !!(object as Record)["tokenType"];
+}
+
export const $accounts = persistentAtom("accounts", [], {
encode: JSON.stringify,
decode: JSON.parse,
diff --git a/src/masto/base.ts b/src/masto/base.ts
new file mode 100644
index 0000000..79d9755
--- /dev/null
+++ b/src/masto/base.ts
@@ -0,0 +1,84 @@
+import { createCacheBucket } from "~platform/cache";
+import { MastoHttpError, MastoTimeoutError, MastoUnexpectedError } from "masto";
+
+export const CACHE_BUCKET_NAME = "mastodon";
+
+export const cacheBucket = /* @__PURE__ */ createCacheBucket(CACHE_BUCKET_NAME);
+
+export function toSmallCamelCase(object: T): T {
+ if (!object || typeof object !== "object") {
+ return object;
+ } else if (Array.isArray(object)) {
+ return object.map(toSmallCamelCase) as T;
+ }
+
+ const result = {} as Record;
+ for (const k in object) {
+ const value = toSmallCamelCase(object[k]);
+ const nk =
+ typeof k === "string"
+ ? k.replace(/_(.)/g, (_, match) => match.toUpperCase())
+ : k;
+ result[nk] = value;
+ }
+
+ return result as T;
+}
+
+function contentTypeOf(headers: Headers) {
+ const raw = headers.get("Content-Type")?.replace(/\s*;.*$/, "");
+ if (!raw) {
+ return;
+ }
+
+ return raw;
+}
+
+/**
+ * Wrpas the error reason into masto's errors:
+ * {@link MastoHttpError} if the reason is a {@link Response},
+ * {@link MastoTimeoutError} if the reason is a timeout error.
+ *
+ * @throws If the reason is unexpected, {@link MastoUnexpectedError} will be thrown.
+ */
+export async function wrapsError(reason: Response): Promise;
+export async function wrapsError(reason: {
+ name: "TimeoutError";
+}): Promise;
+export async function wrapsError(reason: T): Promise;
+
+export async function wrapsError(reason: unknown) {
+ if (reason instanceof Response) {
+ const contentType = contentTypeOf(reason.headers);
+ if (!contentType) {
+ throw new MastoUnexpectedError(
+ "The server returned data with an unknown encoding. The server may be down",
+ );
+ }
+
+ const data = await reason.json();
+ const {
+ error: message,
+ errorDescription,
+ details,
+ ...additionalProperties
+ } = data;
+
+ return new MastoHttpError(
+ {
+ statusCode: reason.status,
+ message,
+ description: errorDescription,
+ details,
+ additionalProperties,
+ },
+ { cause: reason },
+ );
+ }
+
+ if (reason && (reason as { name?: string }).name === "TimeoutError") {
+ return new MastoTimeoutError("Request timed out", { cause: reason });
+ }
+
+ return reason;
+}
diff --git a/src/masto/clients.ts b/src/masto/clients.ts
index f853fa7..7cd22e7 100644
--- a/src/masto/clients.ts
+++ b/src/masto/clients.ts
@@ -4,9 +4,10 @@ import {
createMemo,
createRenderEffect,
createResource,
+ untrack,
useContext,
} from "solid-js";
-import { Account } from "../accounts/stores";
+import { Account, type RemoteServer } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto";
import { useLocation } from "@solidjs/router";
import { useNavigator } from "~platform/StackedRouter";
@@ -57,15 +58,15 @@ export const Provider = Context.Provider;
export function useSessions() {
const sessions = useSessionsRaw();
- const {push} = useNavigator();
+ const { push } = useNavigator();
const location = useLocation();
createRenderEffect(() => {
- if (sessions().length > 0) return;
- push(
- "/accounts/sign-in?back=" + encodeURIComponent(location.pathname),
- { replace: "all" },
- );
+ if (untrack(() => sessions().length) > 0) return;
+
+ push("/accounts/sign-in?back=" + encodeURIComponent(location.pathname), {
+ replace: true,
+ });
});
return sessions;
@@ -114,7 +115,7 @@ export function useDefaultSession() {
* - 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
+ * In an unauthorised session, the `.account` is {@link RemoteServer} and the `client` is an
* unauthorised client for the site. This client may not available for some operations.
*/
export function useSessionForAcctStr(acct: Accessor) {
@@ -130,8 +131,8 @@ export function useSessionForAcctStr(acct: Accessor) {
return (
authedSession ?? {
client: createUnauthorizedClient(inputSite),
- account: undefined,
- }
+ account: { site: inputSite } as RemoteServer, // TODO: we need some security checks here?
+ } as const
);
});
}
diff --git a/src/masto/statuses.ts b/src/masto/statuses.ts
new file mode 100644
index 0000000..4da437e
--- /dev/null
+++ b/src/masto/statuses.ts
@@ -0,0 +1,32 @@
+import { CachedFetch } from "~platform/cache";
+import { cacheBucket, toSmallCamelCase, wrapsError } from "./base";
+import { isAccountKey, type RemoteServer } from "../accounts/stores";
+import { type mastodon } from "masto";
+
+export const fetchStatus = /* @__PURE__ */ new CachedFetch(
+ cacheBucket,
+ (session: RemoteServer, id: string) => {
+ const headers = new Headers({
+ Accept: "application/json",
+ });
+ if (isAccountKey(session)) {
+ headers.set("Authorization", `Bearer ${session.accessToken}`);
+ }
+ return {
+ url: new URL(`./api/v1/statuses/${id}`, session.site).toString(),
+ headers,
+ };
+ },
+ async (response) => {
+ try {
+ if (!response.ok) {
+ throw response;
+ }
+ return toSmallCamelCase(
+ await response.json(),
+ ) as unknown as mastodon.v1.Status;
+ } catch (reason) {
+ throw wrapsError(reason);
+ }
+ },
+);
diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts
index 19abea7..0f04e83 100644
--- a/src/masto/timelines.ts
+++ b/src/masto/timelines.ts
@@ -248,6 +248,64 @@ export type TimelineResource = [
{ refetch(info?: TimelineFetchDirection): void },
];
+export const emptyTimeline = {
+ list() {
+ return emptyTimeline;
+ },
+
+ setDirection() {
+ return emptyTimeline;
+ },
+
+ async next(): Promise> {
+ return {
+ value: undefined,
+ done: true,
+ };
+ },
+
+ getDirection(): TimelineFetchDirection {
+ return "next";
+ },
+
+ clone() {
+ return emptyTimeline;
+ },
+
+ async return(): Promise> {
+ return {
+ value: undefined,
+ done: true,
+ };
+ },
+
+ async throw(e?: unknown) {
+ throw e;
+ },
+
+ async *values() {},
+ async *[Symbol.asyncIterator](): AsyncIterator {
+ return undefined;
+ },
+
+ async then(
+ onresolve?: null | ((value: any[]) => TNext | PromiseLike),
+ onrejected?: null | ((reason: unknown) => ENext | PromiseLike),
+ ) {
+ try {
+ if (!onresolve) {
+ throw new TypeError("no onresolve");
+ }
+ return await onresolve([]);
+ } catch (reason) {
+ if (!onrejected) {
+ throw reason;
+ }
+ return await onrejected(reason);
+ }
+ },
+};
+
/**
* Create auto managed timeline controls.
*
diff --git a/src/material/AppTopBar.css b/src/material/AppTopBar.css
index de3426d..d0976ca 100644
--- a/src/material/AppTopBar.css
+++ b/src/material/AppTopBar.css
@@ -1,6 +1,27 @@
.AppTopBar {
- & > .toolbar {
+ &::before {
+ contain: strict;
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--safe-area-inset-top, 0);
+ background-color: rgba(0, 0, 0, 0.2);
+ }
+
+ &>.MuiToolbar-root {
padding-top: var(--safe-area-inset-top, 0px);
gap: 8px;
+
+ &>button:first-child,
+ &>.MuiButtonBase-root:first-child {
+ margin-left: -0.15em;
+ }
+
+ &>button:last-child,
+ &>.MuiButtonBase-root:last-child {
+ margin-right: -0.15em;
+ }
}
-}
+}
\ No newline at end of file
diff --git a/src/material/AppTopBar.tsx b/src/material/AppTopBar.tsx
index a9ad382..a75c7c3 100644
--- a/src/material/AppTopBar.tsx
+++ b/src/material/AppTopBar.tsx
@@ -20,7 +20,6 @@ const AppTopBar: ParentComponent<{
>
windowSize.height ? "dense" : "regular"}
- class="toolbar"
sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
>
{props.children}
diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx
index 99bfbd0..843f245 100644
--- a/src/material/BottomSheet.tsx
+++ b/src/material/BottomSheet.tsx
@@ -7,7 +7,6 @@ import {
type ParentComponent,
} from "solid-js";
import "./BottomSheet.css";
-import material from "./material.module.css";
import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme";
import {
animateSlideInFromRight,
@@ -51,7 +50,7 @@ function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) {
}
const BottomSheet: ParentComponent = (props) => {
- let element: HTMLDialogElement;
+ let element!: HTMLDialogElement;
let animation: Animation | undefined;
const child = children(() => props.children);
@@ -134,7 +133,7 @@ const BottomSheet: ParentComponent = (props) => {
return (
> = (
props,
) => {
- const [managed, passthough] = splitProps(props, ["class", "type"]);
+ const [managed, passthough] = splitProps(props, [ "type"]);
const type = () => managed.type ?? "button";
return (
);
diff --git a/src/material/Img.tsx b/src/material/Img.tsx
index 774460c..8cb33c9 100644
--- a/src/material/Img.tsx
+++ b/src/material/Img.tsx
@@ -3,14 +3,12 @@ import {
splitProps,
Component,
createSignal,
- createEffect,
onMount,
createRenderEffect,
Show,
} from "solid-js";
import { css } from "solid-styled";
import { decode } from "blurhash";
-import { mergeClass } from "../utils";
type ImgProps = {
blurhash?: string;
@@ -24,6 +22,7 @@ const Img: Component = (props) => {
"blurhash",
"keepBlur",
"class",
+ "classList",
"style",
]);
const [isImgLoaded, setIsImgLoaded] = createSignal(false);
@@ -61,21 +60,21 @@ const Img: Component = (props) => {
const onImgLoaded = () => {
setIsImgLoaded(true);
setImgSize({
- width: imgE.width,
- height: imgE.height,
+ width: imgE!.width,
+ height: imgE!.height,
});
};
const onMetadataLoaded = () => {
setImgSize({
- width: imgE.width,
- height: imgE.height,
+ width: imgE!.width,
+ height: imgE!.height,
});
};
onMount(() => {
setImgSize((x) => {
- const parent = imgE.parentElement;
+ const parent = imgE!.parentElement;
if (!parent) return x;
return x
? x
@@ -87,7 +86,14 @@ const Img: Component = (props) => {
});
return (
-
+
{
diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx
index 931b756..3bc2ab0 100644
--- a/src/material/Menu.tsx
+++ b/src/material/Menu.tsx
@@ -108,7 +108,7 @@ function animateGrowFromTopLeft(
* - Use {@link MenuItem} from SUID as children.
*/
const Menu: Component = (oprops) => {
- let root: HTMLDialogElement;
+ let root!: HTMLDialogElement;
const windowSize = useWindowSize();
const [props, rest] = splitProps(oprops, [
"open",
diff --git a/src/material/Tab.css b/src/material/Tab.css
new file mode 100644
index 0000000..38f49d3
--- /dev/null
+++ b/src/material/Tab.css
@@ -0,0 +1,32 @@
+.Tab {
+ cursor: pointer;
+ background: none;
+ border: none;
+ height: 100%;
+ max-width: min(calc(100% - 56px), 264px);
+ padding: 10px 24px;
+ font-size: 0.8135rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ transition: color 120ms var(--tutu-anim-curve-std);
+
+ :root:where([lang^="zh"],
+ [lang="zh"],
+ [lang^="kr"],
+ [lang="kr"],
+ [lang^="ja"],
+ [lang="ja"]) & {
+ font-size: 0.85rem;
+ }
+}
+
+.MuiToolbar-root .Tab {
+ color: rgba(255, 255, 255, 0.7);
+
+ &:hover,
+ &:focus,
+ &.focus,
+ &.Tabs-focus {
+ color: white;
+ }
+}
\ No newline at end of file
diff --git a/src/material/Tab.tsx b/src/material/Tab.tsx
index 2145b89..10d781a 100644
--- a/src/material/Tab.tsx
+++ b/src/material/Tab.tsx
@@ -1,26 +1,18 @@
import {
- Component,
createEffect,
splitProps,
type JSX,
type ParentComponent,
} from "solid-js";
-import { css } from "solid-styled";
import { useTabListContext } from "./Tabs";
+import "./Tab.css";
const Tab: ParentComponent<
{
focus?: boolean;
- large?: boolean;
} & JSX.ButtonHTMLAttributes
> = (props) => {
- const [managed, rest] = splitProps(props, [
- "focus",
- "large",
- "type",
- "role",
- "ref",
- ]);
+ const [managed, rest] = splitProps(props, ["focus", "type", "role", "ref"]);
let self: HTMLButtonElement;
const {
focusOn: [, setFocusOn],
@@ -35,32 +27,7 @@ const Tab: ParentComponent<
}
return managed.focus;
});
- css`
- .tab {
- cursor: pointer;
- background: none;
- border: none;
- min-width: ${managed.large ? "160px" : "72px"};
- height: 48px;
- max-width: min(calc(100% - 56px), 264px);
- padding: 10px 24px;
- font-size: 0.8135rem;
- font-weight: 600;
- text-transform: uppercase;
- transition: color 120ms var(--tutu-anim-curve-std);
- }
- :global(.MuiToolbar-root) .tab {
- color: rgba(255, 255, 255, 0.7);
-
- &:hover,
- &:focus,
- &.focus,
- &:global(.tablist-focus) {
- color: white;
- }
- }
- `;
return (
{
@@ -68,7 +35,7 @@ const Tab: ParentComponent<
(managed.ref as (e: HTMLButtonElement) => void)?.(x);
}}
type={managed.type ?? "button"}
- classList={{ tab: true, focus: managed.focus }}
+ classList={{ Tab: true, focus: managed.focus }}
role={managed.role ?? "tab"}
{...rest}
>
diff --git a/src/material/Tabs.css b/src/material/Tabs.css
new file mode 100644
index 0000000..b050ff0
--- /dev/null
+++ b/src/material/Tabs.css
@@ -0,0 +1,21 @@
+.Tabs {
+ width: 100%;
+ position: relative;
+ white-space: nowrap;
+ overflow-x: auto;
+ align-self: stretch;
+
+ &::after {
+ transition:
+ left var(--tabs-indkt-movspeed-offset, 0) var(--tutu-anim-curve-std),
+ width var(--tabs-indkt-movspeed-width, 0) var(--tutu-anim-curve-std);
+ position: absolute;
+ content: "";
+ display: block;
+ background-color: white;
+ height: 2px;
+ width: var(--tabs-indkt-width, 0);
+ left: var(--tabs-indkt-offset, 0);
+ bottom: 0;
+ }
+}
\ No newline at end of file
diff --git a/src/material/Tabs.tsx b/src/material/Tabs.tsx
index 3630062..1666375 100644
--- a/src/material/Tabs.tsx
+++ b/src/material/Tabs.tsx
@@ -1,14 +1,12 @@
import {
ParentComponent,
createContext,
- createEffect,
- createMemo,
createRenderEffect,
createSignal,
useContext,
type Signal,
} from "solid-js";
-import { css } from "solid-styled";
+import "./Tabs.css"
const TabListContext = /* @__PURE__ */ createContext<{
focusOn: Signal;
@@ -24,7 +22,7 @@ export function useTabListContext() {
const ANIM_SPEED = 160 / 110; // 160px/110ms
-const TABLIST_FOCUS_CLASS = "tablist-focus";
+const TABS_FOCUS_CLASS = "Tabs-focus";
const Tabs: ParentComponent<{
offset?: number;
@@ -37,11 +35,11 @@ const Tabs: ParentComponent<{
const current = focusOn();
if (lastFocusElement) {
for (const e of lastFocusElement) {
- e.classList.remove(TABLIST_FOCUS_CLASS);
+ e.classList.remove(TABS_FOCUS_CLASS);
}
}
for (const e of current) {
- e.classList.add("tablist-focus");
+ e.classList.add(TABS_FOCUS_CLASS);
}
return current;
});
@@ -109,7 +107,7 @@ const Tabs: ParentComponent<{
return ["0px", "0px", "110ms", "110ms"] as const;
}
const rect = focusBoundingClientRect();
- const rootRect = self.getBoundingClientRect();
+ const rootRect = self!.getBoundingClientRect();
const left = rect.x - rootRect.x;
const width = rect.width;
const [prevEl, nextEl] = focusSiblings();
@@ -130,32 +128,14 @@ const Tabs: ParentComponent<{
return result;
};
- css`
- .tablist {
- width: 100%;
- position: relative;
- white-space: nowrap;
- overflow-x: auto;
-
- &::after {
- transition:
- left ${indicator()[2]} var(--tutu-anim-curve-std),
- width ${indicator()[3]} var(--tutu-anim-curve-std);
- position: absolute;
- content: "";
- display: block;
- background-color: white;
- height: 2px;
- width: ${indicator()[1]};
- left: ${indicator()[0]};
- bottom: 0;
- }
- }
- `;
-
return (
-
+
{props.children}
diff --git a/src/material/form.module.css b/src/material/TextField.css
similarity index 96%
rename from src/material/form.module.css
rename to src/material/TextField.css
index 1fde099..22c09ab 100644
--- a/src/material/form.module.css
+++ b/src/material/TextField.css
@@ -1,5 +1,7 @@
-.textfield {
- composes: touchTarget from "material.module.css";
+.TextField {
+ min-width: 44px;
+ min-height: 44px;
+ cursor: pointer;
--border-color: var(--tutu-color-inactive-on-surface);
--active-border-color: var(--tutu-color-primary);
diff --git a/src/material/TextField.tsx b/src/material/TextField.tsx
index df0f7fc..d139220 100644
--- a/src/material/TextField.tsx
+++ b/src/material/TextField.tsx
@@ -6,7 +6,7 @@ import {
onMount,
Show,
} from "solid-js";
-import formStyles from "./form.module.css";
+import "./TextField.css";
export type TextFieldProps = {
label?: string;
@@ -28,14 +28,14 @@ const TextField: Component
= (props) => {
createEffect(() => {
if (hasContent()) {
- field.classList.add("float-label");
+ field!.classList.add("float-label");
} else {
- field.classList.remove("float-label");
+ field!.classList.remove("float-label");
}
});
onMount(() => {
- setHasContent(input.value.length > 0);
+ setHasContent(input!.value.length > 0);
});
const onInputChange = (e: { currentTarget: HTMLInputElement }) => {
@@ -47,12 +47,12 @@ const TextField: Component = (props) => {
const inputId = () => props.inputId ?? altInputId;
const fieldClass = () => {
- const cls = [formStyles.textfield];
+ const cls = ["TextField"];
if (typeof props.helperText !== "undefined") {
- cls.push(formStyles.withHelperText);
+ cls.push("withHelperText");
}
if (props.error) {
- cls.push(formStyles.error);
+ cls.push("error");
}
return cls.join(" ");
};
@@ -71,7 +71,7 @@ const TextField: Component = (props) => {
name={props.name}
/>
- {props.helperText}
+ {props.helperText}
);
diff --git a/src/material/cards.css b/src/material/cards.css
new file mode 100644
index 0000000..785ae71
--- /dev/null
+++ b/src/material/cards.css
@@ -0,0 +1,57 @@
+@layer material {
+ .card {
+ --card-pad: 20px;
+ --card-gut: 20px;
+
+ background-color: var(--tutu-color-surface);
+ color: var(--tutu-color-on-surface);
+
+ border-radius: 2px;
+ box-shadow: var(--tutu-shadow-e2);
+ transition: var(--tutu-transition-shadow);
+ overflow: hidden;
+ background-color: var(--tutu-color-surface-l);
+
+ &:focus-within,
+ &:focus-visible {
+ box-shadow: var(--tutu-shadow-e8);
+ }
+
+ &>.card-pad {
+ margin-left: var(--card-pad);
+ margin-right: var(--card-pad);
+ }
+
+ &>.card-gut {
+ &:first-child {
+ margin-top: var(--card-gut);
+ }
+
+ &+.card-gut {
+ margin-top: var(--card-gut);
+ }
+
+ &:last-child {
+ margin-bottom: var(--card-gut);
+ }
+ }
+
+ &.card-auto-margin {
+ &> :not(.card-no-pad) {
+ margin-inline: var(--card-pad, 20px);
+ }
+
+ > :not(.card-gut-skip):first-child {
+ margin-top: var(--card-gut, 20px);
+ }
+
+ >.card-gut-skip+*:not(.card-gut-skip) {
+ margin-top: var(--card-gut, 20px);
+ }
+
+ > :not(.card-gut-skip):last-child {
+ margin-bottom: var(--card-gut, 20px);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/material/cards.module.css b/src/material/cards.module.css
deleted file mode 100644
index 6199bf7..0000000
--- a/src/material/cards.module.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.card {
- composes: surface from "material.module.css";
- border-radius: 2px;
- box-shadow: var(--tutu-shadow-e2);
- transition: var(--tutu-transition-shadow);
- overflow: hidden;
- background-color: var(--tutu-color-surface-l);
-
- &:focus-within,
- &:focus-visible {
- box-shadow: var(--tutu-shadow-e8);
- }
-
- &:not(.manualMargin) {
- &> :not(.cardNoPad) {
- margin-inline: var(--card-pad, 20px);
- }
-
- > :not(.cardGutSkip):first-child {
- margin-top: var(--card-gut, 20px);
- }
-
- >.cardGutSkip+*:not(.cardGutSkip) {
- margin-top: var(--card-gut, 20px);
- }
-
- > :not(.cardGutSkip):last-child {
- margin-bottom: var(--card-gut, 20px);
- }
- }
-}
-
-.layoutCentered {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- width: 448px;
-
- @media (max-width: 600px) {
- & {
- position: static;
- height: 100%;
- width: 100%;
- left: 0;
- right: 0;
- transform: none;
- display: grid;
- grid-template-rows: 1fr auto;
- height: 100vh;
- overflow: auto;
- }
- }
-}
diff --git a/src/material/material.module.css b/src/material/material.css
similarity index 95%
rename from src/material/material.module.css
rename to src/material/material.css
index 18d8fc8..28b082e 100644
--- a/src/material/material.module.css
+++ b/src/material/material.css
@@ -1,16 +1,14 @@
+@import "./typography.css";
+
.surface {
background-color: var(--tutu-color-surface);
color: var(--tutu-color-on-surface);
}
-.touchTarget {
+button {
min-width: 44px;
min-height: 44px;
cursor: pointer;
-}
-
-.button {
- composes: touchTarget;
border: none;
background-color: transparent;
diff --git a/src/material/toolbar.module.css b/src/material/toolbar.module.css
deleted file mode 100644
index e69de29..0000000
diff --git a/src/material/typography.css b/src/material/typography.css
index 2e1c9af..4d96c9a 100644
--- a/src/material/typography.css
+++ b/src/material/typography.css
@@ -1,3 +1,6 @@
+/* Don't import this file directly. This file is already included in material.css */
+
+
.display4 {
font-size: 7rem;
font-weight: 300;
diff --git a/src/material/typography.tsx b/src/material/typography.tsx
index ddc8cdd..9979479 100644
--- a/src/material/typography.tsx
+++ b/src/material/typography.tsx
@@ -1,21 +1,11 @@
-import { JSX, ParentComponent, splitProps, type Ref } from "solid-js";
+import { splitProps, type Ref, ComponentProps, ValidComponent } from "solid-js";
import { Dynamic } from "solid-js/web";
-import "./typography.css";
-type AnyElement = keyof JSX.IntrinsicElements | ParentComponent;
-
-type PropsOf =
- E extends ParentComponent
- ? Props
- : E extends keyof JSX.IntrinsicElements
- ? JSX.IntrinsicElements[E]
- : JSX.HTMLAttributes;
-
-export type TypographyProps = {
+export type TypographyProps = {
ref?: Ref;
component?: E;
class?: string;
-} & PropsOf;
+} & ComponentProps;
type TypographyKind =
| "display4"
@@ -30,7 +20,7 @@ type TypographyKind =
| "caption"
| "buttonText";
-export function Typography(
+export function Typography(
props: { typography: TypographyKind } & TypographyProps,
) {
const [managed, passthough] = splitProps(props, [
@@ -49,36 +39,36 @@ export function Typography(
);
}
-export function Display4(props: TypographyProps) {
+export function Display4(props: TypographyProps) {
return ;
}
-export function Display3(props: TypographyProps) {
+export function Display3(props: TypographyProps) {
return ;
}
-export function Display2(props: TypographyProps) {
+export function Display2(props: TypographyProps) {
return ;
}
-export function Display1(props: TypographyProps) {
+export function Display1(props: TypographyProps) {
return ;
}
-export function Headline(props: TypographyProps) {
+export function Headline(props: TypographyProps) {
return ;
}
-export function Title(props: TypographyProps) {
+export function Title(props: TypographyProps) {
return ;
}
-export function Subheading(props: TypographyProps) {
+export function Subheading(props: TypographyProps) {
return ;
}
-export function Body1(props: TypographyProps) {
+export function Body1(props: TypographyProps) {
return ;
}
-export function Body2(props: TypographyProps) {
+export function Body2(props: TypographyProps) {
return ;
}
-export function Caption(props: TypographyProps) {
+export function Caption(props: TypographyProps) {
return ;
}
-export function ButtonText(props: TypographyProps) {
+export function ButtonText(props: TypographyProps) {
return ;
}
diff --git a/src/platform/DocumentTitle.tsx b/src/platform/DocumentTitle.tsx
new file mode 100644
index 0000000..bf1e51f
--- /dev/null
+++ b/src/platform/DocumentTitle.tsx
@@ -0,0 +1,22 @@
+import { children, createRenderEffect, onCleanup, type JSX } from "solid-js";
+
+/**
+ * Document title.
+ *
+ * The `children` must be plain text.
+ */
+export default function (props: { children?: JSX.Element }) {
+ let otitle: string | undefined;
+
+ createRenderEffect(() => (otitle = document.title));
+
+ const title = children(() => props.children);
+
+ createRenderEffect(
+ () => (document.title = (title.toArray() as string[]).join("")),
+ );
+
+ onCleanup(() => (document.title = otitle!));
+
+ return <>>;
+}
diff --git a/src/platform/MediaQuickview.css b/src/platform/MediaQuickview.css
new file mode 100644
index 0000000..3ad798c
--- /dev/null
+++ b/src/platform/MediaQuickview.css
@@ -0,0 +1,76 @@
+.MediaQuickview__root {
+ display: contents;
+}
+
+.MediaQuickview {
+ border: none;
+ position: fixed;
+ width: 100vw;
+ width: 100dvw;
+ height: 100vh;
+ height: 100dvh;
+ max-width: 100vw;
+ max-height: 100vh;
+ contain: content;
+ padding: 0;
+
+ &::backdrop {
+ background: none;
+ }
+
+ >.Scaffold>.topbar {
+ position: fixed;
+ left: 0;
+ right: 0;
+
+ >* {
+ background-color: var(--tutu-color-surface);
+ color: var(--tutu-color-on-surface);
+ }
+ }
+
+ >.Scaffold>.pages {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 100%;
+ height: 100%;
+ width: 100%;
+ overflow: auto hidden;
+ scroll-snap-type: x mandatory;
+ scroll-snap-align: center;
+ scroll-snap-stop: always;
+
+
+ >.page {
+ width: 100%;
+ height: 100%;
+ max-width: 100vw;
+ max-height: 100vh;
+ contain: strict;
+
+ cursor: grab;
+
+ >* {
+ display: block;
+
+ object-fit: contain;
+ object-position: center;
+ transform-origin: 0 0;
+ }
+ }
+ }
+
+ &.lightout {
+ >.Scaffold {
+ >.topbar {
+ visibility: hidden;
+ }
+ }
+ }
+
+ &.moving {
+ >.Scaffold>.pages>.page {
+ cursor: grabbing;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/platform/MediaQuickview.tsx b/src/platform/MediaQuickview.tsx
new file mode 100644
index 0000000..c4b2cde
--- /dev/null
+++ b/src/platform/MediaQuickview.tsx
@@ -0,0 +1,331 @@
+import { createCallback, createSubRoot } from "@solid-primitives/rootless";
+import {
+ batch,
+ createMemo,
+ createRenderEffect,
+ createSignal,
+ Index,
+ Match,
+ onCleanup,
+ onMount,
+ Switch,
+ type Component,
+} from "solid-js";
+import { render } from "solid-js/web";
+import Scaffold from "~material/Scaffold";
+import { isPointNotInRect } from "./dom";
+import "./MediaQuickview.css";
+import AppTopBar from "~material/AppTopBar";
+import { IconButton } from "@suid/material";
+import { Close } from "@suid/icons-material";
+import { createStore, unwrap } from "solid-js/store";
+
+function renderIsolateMediaQuickview(
+ each: QuickviewMedia[],
+ index: number,
+ transitionFrom?: Element,
+) {
+ createSubRoot((disposeAll) => {
+ let container: HTMLDivElement;
+
+ createRenderEffect(() => {
+ container = document.createElement("div");
+ container.setAttribute("role", "presentation");
+ container.classList.add("MediaQuickview__root");
+ document.querySelector("body")!.appendChild(container);
+
+ onCleanup(() => container.remove());
+
+ const dispose = render(() => {
+ return (
+
+ );
+ }, container);
+
+ onCleanup(dispose);
+ });
+ });
+}
+
+export function createMediaQuickview() {
+ return createCallback(renderIsolateMediaQuickview);
+}
+
+type VisualMediaMeta = {
+ width: number;
+ height: number;
+};
+
+function ImagePage(props: {
+ src: string;
+ alt?: string;
+ scale: number;
+ offsetX: number;
+ offsetY: number;
+
+ onMetadata?(metadata: VisualMediaMeta): void;
+}) {
+ const [loaded, setLoaded] = createSignal(false);
+
+ return (
+ {
+ const { naturalHeight, naturalWidth } = currentTarget;
+
+ props.onMetadata?.({
+ width: naturalWidth,
+ height: naturalHeight,
+ });
+
+ setLoaded(true);
+ }}
+ src={props.src}
+ alt={props.alt}
+ style={{
+ transform: `scale(${props.scale}) translateX(${-props.offsetX}px) translateY(${-props.offsetY}px)`,
+ opacity: loaded() ? 1 : 0,
+ }}
+ >
+ );
+}
+
+export type QuickviewMedia = {
+ cat: "image" | "video" | "gifv" | "audio" | "unknown";
+ src: string;
+ alt?: string;
+};
+
+export type MediaQuickviewProps = {
+ each: QuickviewMedia[];
+ defaultIndex: number;
+ transitonFrom?: Element;
+
+ onClose?(): void;
+};
+
+const MediaQuickview: Component = (props) => {
+ let root: HTMLDialogElement;
+
+ const [lightOut, setLightOut] = createSignal(false);
+
+ function onDialogClick(
+ event: MouseEvent & { currentTarget: HTMLDialogElement },
+ ) {
+ event.stopPropagation();
+
+ if (
+ isPointNotInRect(
+ event.currentTarget.getBoundingClientRect(),
+ event.clientX,
+ event.clientY,
+ )
+ ) {
+ event.currentTarget.close();
+ } else {
+ setLightOut((x) => !x);
+ }
+ }
+
+ const [transformations, setTransformations] = createStore(
+ [] as {
+ scale: number;
+ /**
+ * positive = the left edge move towards left
+ */
+ offsetX: number;
+ /**
+ * positive = the top edge move towards top
+ */
+ offsetY: number;
+
+ size?: {
+ width: number;
+ height: number;
+ };
+ }[],
+ );
+
+ const transformationGetOrSetDefault = (index: number) => {
+ if (transformations.length <= index) {
+ setTransformations(index, {
+ scale: 1,
+ offsetX: 0,
+ offsetY: 0,
+ });
+ }
+ return transformations[index];
+ };
+
+ const onWheel = (
+ index: number,
+ event: WheelEvent & { currentTarget: HTMLElement },
+ ) => {
+ // This is a de-facto standard for scaling:
+ // Browsers will simulate ctrl + wheel for two point scaling gesture on trackpad.
+ if (event.ctrlKey) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const { clientX, clientY, currentTarget, deltaY } = event;
+
+ const offset = -deltaY; // not reversed wheel: wheel up = scale +, wheel down = scale -
+ const scaleOffset = offset / currentTarget.clientHeight;
+
+ // Map the screen scale to the image viewport scale
+ const userOriginX = clientX - currentTarget.clientLeft;
+ const userOriginY = clientY - currentTarget.clientTop;
+ setTransformations(index, ({ scale, offsetX, offsetY }) => {
+ const nscale = scale + scaleOffset;
+ return {
+ offsetX: offsetX + (userOriginX * nscale - userOriginX),
+ offsetY: offsetY + (userOriginY * nscale - userOriginX),
+ scale: nscale,
+ };
+ });
+ }
+ };
+
+ const [isMoving, setIsMoving] = createSignal(false);
+ let movOriginX: number = 0,
+ movOriginY: number = 0;
+
+ const onMouseDown = (event: MouseEvent) => {
+ event.preventDefault();
+ setIsMoving(true);
+ movOriginX = event.clientX;
+ movOriginY = event.clientY;
+ };
+
+ const onMouseMove = (index: number, event: MouseEvent) => {
+ if (!isMoving()) {
+ return;
+ }
+ const dx = movOriginX - event.clientX;
+ const dy = movOriginY - event.clientY;
+
+ setTransformations(index, ({ offsetX, offsetY }) => {
+ return {
+ offsetX: offsetX + dx,
+ offsetY: offsetY + dy,
+ };
+ });
+
+ movOriginX = event.clientX;
+ movOriginY = event.clientY;
+ };
+
+ const onMouseUp = (event: MouseEvent) => {
+ setIsMoving(false);
+ };
+
+ const recenter = (index: number) => {
+ const sz = transformations[index].size;
+ if (!sz) return;
+ const { width, height } = sz;
+
+ const xscale = Math.min(window.innerWidth / width, 1);
+ const yscale = Math.min(window.innerHeight / height, 1);
+
+ const finalScale = Math.min(xscale, yscale);
+
+ const nheight = height * finalScale;
+ const top = (window.innerHeight - nheight) / 2;
+
+ const nwidth = width * finalScale;
+ const left = (window.innerWidth - nwidth) / 2;
+
+ setTransformations(index, {
+ scale: finalScale,
+ offsetX: -left,
+ offsetY: -top,
+ });
+ };
+
+ const onMetadata = (index: number, { width, height }: VisualMediaMeta) => {
+ setTransformations(index, {
+ size: {
+ width,
+ height,
+ },
+ });
+
+ recenter(index);
+ };
+
+ return (
+ {
+ root = e;
+ onMount(() => {
+ e.showModal();
+ });
+ }}
+ class="MediaQuickview"
+ classList={{ lightout: lightOut(), moving: isMoving() }}
+ onClose={props.onClose}
+ onCancel={props.onClose}
+ onClick={onDialogClick}
+ >
+
+ root.close()}
+ disableFocusRipple
+ >
+
+
+
+ }
+ >
+ {
+ onMount(() => {
+ e.children.item(props.defaultIndex)!.scrollIntoView({
+ behavior: "instant",
+ inline: "center",
+ });
+ });
+ }}
+ class="pages"
+ >
+
+ {(item, index) => {
+ return (
+
+
+
+ onMetadata(index, m)}
+ src={item().src}
+ alt={item().alt}
+ scale={transformationGetOrSetDefault(index).scale}
+ offsetX={transformationGetOrSetDefault(index).offsetX}
+ offsetY={transformationGetOrSetDefault(index).offsetY}
+ />
+
+
+
+ );
+ }}
+
+
+
+
+ );
+};
+
+export default MediaQuickview;
diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx
index 75e7594..fbfc713 100644
--- a/src/platform/StackedRouter.tsx
+++ b/src/platform/StackedRouter.tsx
@@ -1,10 +1,13 @@
-import { StaticRouter, type RouterProps } from "@solidjs/router";
+import {
+ type RouterProps,
+ type StaticRouterProps,
+ createRouter,
+} from "@solidjs/router";
import {
Component,
createContext,
createMemo,
createRenderEffect,
- createUniqueId,
Index,
onMount,
Show,
@@ -12,6 +15,8 @@ import {
useContext,
onCleanup,
type Accessor,
+ useTransition,
+ getOwner,
} from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import "./StackedRouter.css";
@@ -21,6 +26,12 @@ import { makeEventListener } from "@solid-primitives/event-listener";
import { useWindowSize } from "@solid-primitives/resize-observer";
import { isPointNotInRect } from "./dom";
+let uniqueCounter = 0;
+
+function createUniqueId() {
+ return `sr-${uniqueCounter++}`;
+}
+
export type StackedRouterProps = Omit;
export type StackFrame = {
@@ -70,8 +81,8 @@ export type NewFrameOptions = (T extends undefined
export type FramePusher = T[K] extends
| undefined
| any
- ? (path: K, state?: Readonly>) => Readonly
- : (path: K, state: Readonly>) => Readonly;
+ ? (path: K, state?: Readonly>) => Promise>
+ : (path: K, state: Readonly>) => Promise>;
export type Navigator> = {
frames: readonly StackFrame[];
@@ -387,6 +398,24 @@ function animateUntil(
execStep();
}
+function noOp() {}
+
+function StaticRouter(props: StaticRouterProps) {
+ const url = () => props.url || "";
+
+ // TODO: support onBeforeLeave, see
+ // https://github.com/solidjs/solid-router/blob/main/src/routers/Router.ts
+
+ return createRouter({
+ get: url,
+ set: noOp,
+ init(notify) {
+ createRenderEffect(() => notify(url()));
+ return noOp;
+ },
+ })(props);
+}
+
/**
* The cache key of saved stack for hot reload.
*
@@ -438,6 +467,8 @@ const StackedRouter: Component = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const windowSize = useWindowSize();
+ const [, startTransition] = useTransition();
+
if (import.meta.hot) {
const saveStack = () => {
import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack);
@@ -457,12 +488,12 @@ const StackedRouter: Component = (oprops) => {
};
createRenderEffect(() => {
- loadStack()
+ loadStack();
});
}
- const pushFrame = (path: string, opts?: Readonly>) =>
- untrack(() => {
+ const pushFrame = async (path: string, opts?: Readonly>) =>
+ await untrack(async () => {
const frame = {
path,
state: opts?.state,
@@ -472,11 +503,17 @@ const StackedRouter: Component = (oprops) => {
};
const replace = opts?.replace;
- if (replace === "all") {
- mutStack([frame]);
- } else {
- mutStack(replace ? stack.length - 1 : stack.length, frame);
- }
+ const length = stack.length;
+ await startTransition(() => {
+ if (replace === "all" || length === 0) {
+ mutStack([frame]);
+ } else if (replace) {
+ const idx = length - 1;
+ mutStack(idx, frame);
+ } else {
+ mutStack(length, frame);
+ }
+ });
const savedStack = serializableStack(stack);
@@ -485,6 +522,7 @@ const StackedRouter: Component = (oprops) => {
} else {
window.history.pushState(savedStack, "", path);
}
+
return frame;
});
@@ -541,13 +579,19 @@ const StackedRouter: Component = (oprops) => {
}
});
- createRenderEffect(() => {
- if (stack.length === 0) {
- pushFrame(window.location.pathname, {
- replace: "all",
- });
- }
- });
+ createRenderEffect(() =>
+ untrack(() => {
+ if (stack.length === 0) {
+ const parts = [window.location.pathname] as string[];
+ if (window.location.search) {
+ parts.push(window.location.search);
+ }
+ pushFrame(parts.join(""), {
+ replace: "all",
+ });
+ }
+ }),
+ );
createRenderEffect(() => {
makeEventListener(window, "popstate", (event) => {
@@ -636,7 +680,9 @@ const StackedRouter: Component = (oprops) => {
const currentFrame = () => {
return {
index,
- frame: frame(),
+ get frame() {
+ return frame();
+ },
};
};
diff --git a/src/platform/cache.ts b/src/platform/cache.ts
new file mode 100644
index 0000000..5d5b10a
--- /dev/null
+++ b/src/platform/cache.ts
@@ -0,0 +1,145 @@
+import { addMinutes, formatRFC7231 } from "date-fns";
+import {
+ createRenderEffect,
+ createResource,
+ untrack,
+} from "solid-js";
+
+export function createCacheBucket(name: string) {
+ let bucket: Cache | undefined;
+
+ return async () => {
+ if (bucket) {
+ return bucket;
+ }
+
+ bucket = await self.caches.open(name);
+
+ return bucket;
+ };
+}
+
+export type FetchRequest = {
+ url: string;
+ headers?: HeadersInit | Headers;
+};
+
+async function searchCache(request: Request) {
+ return await self.caches.match(request);
+}
+
+/**
+ * Create a {@link fetch} helper with additional caching support.
+ */
+export class CachedFetch<
+ Transformer extends (response: Response) => any,
+ Keyer extends (...args: any[]) => FetchRequest,
+> {
+ private cacheBucket: () => Promise;
+ keyFor: Keyer;
+ private transform: Transformer;
+
+ constructor(
+ cacheBucket: () => Promise,
+ keyFor: Keyer,
+ tranformer: Transformer,
+ ) {
+ this.cacheBucket = cacheBucket;
+ this.keyFor = keyFor;
+ this.transform = tranformer;
+ }
+
+ private async validateCache(request: Request) {
+ const buk = await this.cacheBucket();
+ const response = await fetch(request);
+ buk.put(request, response.clone());
+ return response;
+ }
+
+ private request(...args: Parameters) {
+ const { url, ...init } = this.keyFor(...args);
+ const request = new Request(url, init);
+ return request;
+ }
+
+ /**
+ * Race between the cache and the network result,
+ * use the fastest result.
+ *
+ * The cache will be revalidated.
+ */
+ async fastest(
+ ...args: Parameters
+ ): Promise>> {
+ const request = this.request(...args);
+ const validating = this.validateCache(request);
+
+ const searching = searchCache(request);
+
+ const earlyResult = await Promise.race([validating, searching]);
+
+ if (earlyResult) {
+ return await this.transform(earlyResult);
+ }
+
+ return await this.transform(await validating);
+ }
+
+ /**
+ * Validate and return the result.
+ */
+ async validate(
+ ...args: Parameters
+ ): Promise>> {
+ return await this.transform(
+ await this.validateCache(this.request(...args)),
+ );
+ }
+
+ /** Set a response as the cache.
+ * Recommend to set `Expires` or `Cache-Control` to limit its live time.
+ */
+ async set(key: Parameters, response: Response) {
+ const buk = await this.cacheBucket();
+ await buk.put(this.request(...key), response);
+ }
+
+ /** Set a json object as the cache.
+ * Only available for 5 minutes.
+ */
+ async setJson(key: Parameters, object: unknown) {
+ const response = new Response(JSON.stringify(object), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ Expires: formatRFC7231(addMinutes(new Date(), 5)),
+ "X-Cache-Src": "set",
+ },
+ });
+
+ await this.set(key, response);
+ }
+
+ /**
+ * Return a resource, using the cache at first, and revalidate
+ * later.
+ */
+ cachedAndRevalidate(args: () => Parameters) {
+ const res = createResource(args, (p) => this.validate(...p));
+
+ const checkCacheIfStillLoading = async () => {
+ const saved = await searchCache(this.request(...args()));
+ if (!saved) {
+ return;
+ }
+ const transformed = await this.transform(saved);
+ if (res[0].loading) {
+ res[1].mutate(transformed);
+ }
+ };
+
+ createRenderEffect(() => void untrack(() => checkCacheIfStillLoading()));
+
+ return res;
+ }
+}
diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts
index 1af3341..fd44153 100644
--- a/src/platform/polyfills.ts
+++ b/src/platform/polyfills.ts
@@ -1,38 +1,27 @@
//! This module has side effect.
//! It recommended to include the module by